#!/usr/bin/perl # # Copyright (c) 2006-2010 by Karl J. Runge <runge@karlrunge.com> # # connect_switch 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. # # connect_switch 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 connect_switch; if not, write to the Free Software # Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA # or see <http://www.gnu.org/licenses/>. # # # connect_switch: # # A kludge script that sits between web clients and a mod_ssl (https) # enabled apache webserver. # # If an incoming web client connection makes a proxy CONNECT request # it is handled directly by this script (apache is not involved). # Otherwise, all other connections are forwarded to the apache webserver. # # This can be useful for VNC redirection using an existing https (port # 443) webserver, thereby not requiring a 2nd (non-https) port open on # the firewall for the CONNECT requests. # # It does not seem possible (to me) to achieve this entirely within apache # because the CONNECT request appears to be forwarded encrypted to # the remote host and so the SSL dies immediately. # # It can also be used to redirect ANY protocol, e.g. SSH, not just VNC. # See CONNECT_SWITCH_APPLY_VNC_OFFSET=0 to disable VNC 5900 shift. # # Note: There is no need to use this script for a non-ssl apache webserver # port because mod_proxy works fine for doing the switching all inside # apache (see ProxyRequests and AllowCONNECT parameters). # # # Apache configuration: # # The mod_ssl configuration is often in a file named ssl.conf. In the # simplest case you change something like this: # # From: # # Listen 443 # # <VirtualHost _default_:443> # ... # </VirtualHost> # # To: # # Listen 127.0.0.1:443 # # <VirtualHost _default_:443> # ... # </VirtualHost> # # (i.e. just change the Listen directive). # # If you have mod_ssl listening on a different internal port, you do # not need to specify the localhost Listen address. # # It is probably a good idea to set $listen_host below to the known # IP address you want the service to listen on (to avoid localhost where # apache is listening). # #################################################################### # NOTE: For more info on configuration settings, read below for # all of the CONNECT_SWITCH_* env. var. parameters. #################################################################### #################################################################### # Allow env vars to also be specified on cmdline: # foreach my $arg (@ARGV) { if ($arg =~ /^(CONNECT_SWITCH.*?)=(.*)$/) { $ENV{$1} = $2; } } # Set up logging: # if (exists $ENV{CONNECT_SWITCH_LOGFILE}) { close STDOUT; if (!open(STDOUT, ">>$ENV{CONNECT_SWITCH_LOGFILE}")) { die "connect_switch: $ENV{CONNECT_SWITCH_LOGFILE} $!\n"; } close STDERR; open(STDERR, ">&STDOUT"); } select(STDERR); $| = 1; select(STDOUT); $| = 1; # interrupt handler: # my $looppid = ''; my $pidfile = ''; my $listen_sock = ''; # declared here for get_out() # sub get_out { print STDERR "$_[0]:\t$$ looppid=$looppid\n"; close $listen_sock if $listen_sock; if ($looppid) { kill 'TERM', $looppid; fsleep(0.2); } unlink $pidfile if $pidfile; exit 0; } $SIG{INT} = \&get_out; $SIG{TERM} = \&get_out; # pidfile: # sub open_pidfile { if (exists $ENV{CONNECT_SWITCH_PIDFILE}) { my $pf = $ENV{CONNECT_SWITCH_PIDFILE}; if (open(PID, ">$pf")) { print PID "$$\n"; close PID; $pidfile = $pf; } else { print STDERR "could not open pidfile: $pf - $! - continuing...\n"; } delete $ENV{CONNECT_SWITCH_PIDFILE}; } } #################################################################### # Set CONNECT_SWITCH_LOOP=1 to have this script create an outer loop # restarting itself if it ever exits. Set CONNECT_SWITCH_LOOP=BG to # do this in the background as a daemon. if (exists $ENV{CONNECT_SWITCH_LOOP}) { my $csl = $ENV{CONNECT_SWITCH_LOOP}; if ($csl ne 'BG' && $csl ne '1') { die "connect_switch: invalid CONNECT_SWITCH_LOOP.\n"; } if ($csl eq 'BG') { # go into bg as "daemon": setpgrp(0, 0); my $pid = fork(); if (! defined $pid) { die "connect_switch: $!\n"; } elsif ($pid) { wait; exit 0; } if (fork) { exit 0; } setpgrp(0, 0); close STDIN; if (! $ENV{CONNECT_SWITCH_LOGFILE}) { close STDOUT; close STDERR; } } delete $ENV{CONNECT_SWITCH_LOOP}; if (exists $ENV{CONNECT_SWITCH_PIDFILE}) { open_pidfile(); } print STDERR "connect_switch: starting service at ", scalar(localtime), " master-pid=$$\n"; while (1) { $looppid = fork; if (! defined $looppid) { sleep 10; } elsif ($looppid) { wait; } else { exec $0; exit 1; } print STDERR "connect_switch: re-starting service at ", scalar(localtime), " master-pid=$$\n"; sleep 1; } exit 0; } if (exists $ENV{CONNECT_SWITCH_PIDFILE}) { open_pidfile(); } ############################################################################ # The defaults for hosts and ports (you can override them below if needed): # # Look below for these environment variables that let you set the various # parameters without needing to edit this script: # # CONNECT_SWITCH_LISTEN # CONNECT_SWITCH_HTTPD # CONNECT_SWITCH_ALLOWED # CONNECT_SWITCH_ALLOW_FILE # CONNECT_SWITCH_VERBOSE # CONNECT_SWITCH_APPLY_VNC_OFFSET # CONNECT_SWITCH_VNC_OFFSET # CONNECT_SWITCH_LISTEN_IPV6 # CONNECT_SWITCH_BUFSIZE # CONNECT_SWITCH_LOGFILE # CONNECT_SWITCH_PIDFILE # CONNECT_SWITCH_MAX_CONNECTIONS # # You can also set these on the cmdline: # connect_switch CONNECT_SWITCH_LISTEN=X CONNECT_SWITCH_ALLOW_FILE=Y ... # # By default we will use hostname and assume it resolves: # my $hostname = `hostname`; chomp $hostname; my $listen_host = $hostname; my $listen_port = 443; # Let user override listening situation, e.g. multihomed: # if (exists $ENV{CONNECT_SWITCH_LISTEN}) { # # E.g. CONNECT_SWITCH_LISTEN=192.168.0.32:443 # $listen_host = ''; $listen_port = ''; if ($ENV{CONNECT_SWITCH_LISTEN} =~ /^(.*):(\d+)$/) { ($listen_host, $listen_port) = ($1, $2); } } my $httpd_host = 'localhost'; my $httpd_port = 443; if (exists $ENV{CONNECT_SWITCH_HTTPD}) { # # E.g. CONNECT_SWITCH_HTTPD=127.0.0.1:443 # $httpd_host = ''; $httpd_port = ''; if ($ENV{CONNECT_SWITCH_HTTPD} =~ /^(.*):(\d+)$/) { ($httpd_host, $httpd_port) = ($1, $2); } } my $bufsize = 8192; if (exists $ENV{CONNECT_SWITCH_BUFSIZE}) { # # E.g. CONNECT_SWITCH_BUFSIZE=32768 # $bufsize = $ENV{CONNECT_SWITCH_BUFSIZE}; } ############################################################################ # You can/should override the host/port settings here: # #$listen_host = '23.45.67.89'; # set to your interface IP number. #$listen_port = 555; # and/or nonstandard port. #$httpd_host = 'somehost'; # maybe you redir https to another machine. #$httpd_port = 666; # and/or nonstandard port. # You must set the allowed host:port CONNECT redirection list. # Only these host:port pairs will be redirected to. # Port ranges are allowed too: host:5900-5930. # If there is one entry named ALL all connections are allow. # You must supply something, default is deny. # my @allowed = qw( machine1:5915 machine2:5900 ); if (exists $ENV{CONNECT_SWITCH_ALLOWED}) { # # E.g. CONNECT_SWITCH_ALLOWED=machine1:5915,machine2:5900 # @allowed = split(/,/, $ENV{CONNECT_SWITCH_ALLOWED}); } # Or you could also use an external "allow file". # They get added to the @allowed list. # The file is re-read for each new connection. # # Format of $allow_file: # # host1 vncdisp # host2 vncdisp # # where, e.g. vncdisp = 15 => port 5915, say # # joesbox 15 # fredsbox 15 # rupert 1 # For examply, mine is: # my $allow_file = '/dist/apache/2.0/conf/vnc.hosts'; $allow_file = ''; if (exists $ENV{CONNECT_SWITCH_ALLOW_FILE}) { # E.g. CONNECT_SWITCH_ALLOW_FILE=/usr/local/etc/allow.txt $allow_file = $ENV{CONNECT_SWITCH_ALLOW_FILE}; } # Set to 1 to re-map to vnc port, e.g. 'hostname 15' to 'hostname 5915' # i.e. assume a port 0 <= port < 200 is actually a VNC display # and add 5900 to it. Set to 0 to not do the mapping. # Note that negative ports, e.g. 'joesbox -22' go directly to -port. # my $apply_vnc_offset = 1; my $vnc_offset = 5900; if (exists $ENV{CONNECT_SWITCH_APPLY_VNC_OFFSET}) { # # E.g. CONNECT_SWITCH_APPLY_VNC_OFFSET=0 # $apply_vnc_offset = $ENV{CONNECT_SWITCH_APPLY_VNC_OFFSET}; } if (exists $ENV{CONNECT_SWITCH_VNC_OFFSET}) { # # E.g. CONNECT_SWITCH_VNC_OFFSET=6000 # $vnc_offset = $ENV{CONNECT_SWITCH_VNC_OFFSET}; } # Set to 1 or higher for more info output: # my $verbose = 0; if (exists $ENV{CONNECT_SWITCH_VERBOSE}) { # # E.g. CONNECT_SWITCH_VERBOSE=1 # $verbose = $ENV{CONNECT_SWITCH_VERBOSE}; } # zero means loop forever, positive value means exit after handling that # many connections. # my $cmax = 0; if (exists $ENV{CONNECT_SWITCH_MAX_CONNECTIONS}) { $cmax = $ENV{CONNECT_SWITCH_MAX_CONNECTIONS}; } #=========================================================================== # No need for any changes below here. #=========================================================================== use IO::Socket::INET; use strict; use warnings; # Test for INET6 support: # my $have_inet6 = 0; eval "use IO::Socket::INET6;"; $have_inet6 = 1 if $@ eq ""; my $killpid = 1; setpgrp(0, 0); if (exists $ENV{CONNECT_SWITCH_LISTEN_IPV6}) { # note we leave out LocalAddr. my $cmd = ' $listen_sock = IO::Socket::INET6->new( Listen => 10, LocalPort => $listen_port, ReuseAddr => 1, Domain => AF_INET6, Proto => "tcp" ); '; eval $cmd; die "$@\n" if $@; } else { $listen_sock = IO::Socket::INET->new( Listen => 10, LocalAddr => $listen_host, LocalPort => $listen_port, ReuseAddr => 1, Proto => "tcp" ); } if (! $listen_sock) { die "connect_switch: $!\n"; } my $current_fh1 = ''; my $current_fh2 = ''; my $conn = 0; while (1) { $conn++; if ($cmax > 0 && $conn > $cmax) { print STDERR "last connection ($cmax)\n" if $verbose; last; } print STDERR "listening for connection: $conn\n" if $verbose; my ($client, $ip) = $listen_sock->accept(); if (! $client) { fsleep(0.5); next; } print STDERR "conn: $conn -- ", $client->peerhost(), " at ", scalar(localtime), "\n" if $verbose; my $pid = fork(); if (! defined $pid) { die "connect_switch: $!\n"; } elsif ($pid) { wait; next; } else { close $listen_sock; if (fork) { exit 0; } setpgrp(0, 0); handle_conn($client); } } exit 0; sub handle_conn { my $client = shift; my $start = time(); my @allow = @allowed; # read allow file. Note we read it for every connection # to allow the admin to modify it w/o restarting us. # better way would be to read in parent and check mtime. # if ($allow_file && -f $allow_file) { if (open(ALLOW, "<$allow_file")) { while (<ALLOW>) { next if /^\s*#/; next if /^\s*$/; chomp; my ($host, $dpy) = split(' ', $_); next if ! defined $host; next if ! defined $dpy; if ($dpy < 0) { $dpy = -$dpy; } elsif ($apply_vnc_offset) { $dpy += $vnc_offset if $dpy < 200; } push @allow, "$host:$dpy"; } close(ALLOW); } else { warn "$allow_file: $!\n"; } } # Read the first 7 bytes of connection, see if it is 'CONNECT' # my $str = ''; my $N = 0; my $isconn = 1; for (my $i = 0; $i < 7; $i++) { my $b; sysread($client, $b, 1); $str .= $b; $N++; print STDERR "read: '$str'\n" if $verbose > 1; my $cstr = substr('CONNECT', 0, $i+1); if ($str ne $cstr) { $isconn = 0; last; } } my $sock = ''; if ($isconn) { # it is CONNECT, read rest of HTTP header: # while ($str !~ /\r\n\r\n/) { my $b; sysread($client, $b, 1); $str .= $b; } print STDERR "read: $str\n" if $verbose > 1; # get http version and host:port # my $ok = 0; my $hostport = ''; my $http_vers = '1.0'; if ($str =~ /^CONNECT\s+(\S+)\s+HTTP\/(\S+)/) { $hostport = $1; $http_vers = $2; my $h = ''; my $p = ''; if ($hostport =~ /^(.*):(\d+)$/) { ($h, $p) = ($1, $2); } if ($p =~ /^\d+$/) { # check allowed host list: foreach my $hp (@allow) { if ($hp eq 'ALL') { $ok = 1; } if ($hp eq $hostport) { $ok = 1; } if ($hp =~ /^(.*):(\d+)-(\d+)$/) { my $ahost = $1; my $pmin = $2; my $pmax = $3; if ($h eq $ahost) { if ($p >= $pmin && $p <= $pmax) { $ok = 1; } } } last if $ok; } } } my $msg_1 = "HTTP/$http_vers 200 Connection Established\r\n" . "Proxy-agent: connect_switch v0.2\r\n\r\n"; my $msg_2 = "HTTP/$http_vers 502 Bad Gateway\r\n" . "Connection: close\r\n\r\n"; if (! $ok) { # disallowed. drop with message. # syswrite($client, $msg_2, length($msg_2)); close $client; exit 0; } my $host = ''; my $port = ''; if ($hostport =~ /^(.*):(\d+)$/) { ($host, $port) = ($1, $2); } print STDERR "connecting to: $host:$port\n" if $verbose; $sock = IO::Socket::INET->new( PeerAddr => $host, PeerPort => $port, Proto => "tcp" ); print STDERR "connect to host='$host' port='$port' failed: $!\n" if !$sock; if (! $sock && $have_inet6) { eval {$sock = IO::Socket::INET6->new( PeerAddr => $host, PeerPort => $port, Proto => "tcp" );}; print STDERR "connect to host='$host' port='$port' failed: $! (ipv6)\n" if !$sock; } my $msg; # send the connect proxy reply: # if ($sock) { $msg = $msg_1; } else { $msg = $msg_2; } syswrite($client, $msg, length($msg)); $str = ''; } else { # otherwise, redirect to apache for normal https: # print STDERR "connecting to: $httpd_host:$httpd_port\n" if $verbose; $sock = IO::Socket::INET->new( PeerAddr => $httpd_host, PeerPort => $httpd_port, Proto => "tcp" ); if (! $sock && $have_inet6) { eval {$sock = IO::Socket::INET6->new( PeerAddr => $httpd_host, PeerPort => $httpd_port, Proto => "tcp" );}; } } if (! $sock) { close $client; die "connect_switch: $!\n"; } # get ready for xfer phase: # $current_fh1 = $client; $current_fh2 = $sock; $SIG{TERM} = sub {print STDERR "got sigterm\[$$]\n" if $verbose; close $current_fh1; close $current_fh2; exit 0}; my $parent = $$; if (my $child = fork()) { xfer($sock, $client, 'S->C'); if ($killpid) { fsleep(0.5); kill 'TERM', $child; } } else { # write those first bytes if not CONNECT: # if ($str ne '' && $N > 0) { syswrite($sock, $str, $N); } xfer($client, $sock, 'C->S'); if ($killpid) { fsleep(0.75); kill 'TERM', $parent; } } if ($verbose > 1) { my $dt = time() - $start; print STDERR "duration\[$$]: $dt seconds. ", scalar(localtime), "\n"; } exit 0; } sub xfer { my($in, $out, $lab) = @_; my ($RIN, $WIN, $EIN, $ROUT); $RIN = $WIN = $EIN = ""; $ROUT = ""; vec($RIN, fileno($in), 1) = 1; vec($WIN, fileno($in), 1) = 1; $EIN = $RIN | $WIN; my $buf; while (1) { my $nf = 0; while (! $nf) { $nf = select($ROUT=$RIN, undef, undef, undef); } my $len = sysread($in, $buf, $bufsize); if (! defined($len)) { next if $! =~ /^Interrupted/; print STDERR "connect_switch\[$lab/$conn/$$]: $!\n"; last; } elsif ($len == 0) { print STDERR "connect_switch\[$lab/$conn/$$]: " . "Input is EOF.\n"; last; } if (0) { # very verbose debugging of data: syswrite(STDERR , "\n$lab: ", 6); syswrite(STDERR , $buf, $len); } my $offset = 0; my $quit = 0; while ($len) { my $written = syswrite($out, $buf, $len, $offset); if (! defined $written) { print STDERR "connect_switch\[$lab/$conn/$$]: " . "Output is EOF. $!\n"; $quit = 1; last; } $len -= $written; $offset += $written; } last if $quit; } close($in); close($out); } sub fsleep { my ($time) = @_; select(undef, undef, undef, $time) if $time; }