1#!/usr/bin/perl
2#
3# Copyright (c) 2006-2010 by Karl J. Runge <runge@karlrunge.com>
4#
5# connect_switch is free software; you can redistribute it and/or modify
6# it under the terms of the GNU General Public License as published by
7# the Free Software Foundation; either version 2 of the License, or (at
8# your option) any later version.
9#
10# connect_switch is distributed in the hope that it will be useful,
11# but WITHOUT ANY WARRANTY; without even the implied warranty of
12# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13# GNU General Public License for more details.
14#
15# You should have received a copy of the GNU General Public License
16# along with connect_switch; if not, write to the Free Software
17# Foundation, Inc., 675 Mass Ave, Cambridge, MA 02139, USA
18# or see <http://www.gnu.org/licenses/>.
19#
20#
21# connect_switch:
22#
23# A kludge script that sits between web clients and a mod_ssl (https)
24# enabled apache webserver.
25#
26# If an incoming web client connection makes a proxy CONNECT request
27# it is handled directly by this script (apache is not involved).
28# Otherwise, all other connections are forwarded to the apache webserver.
29#
30# This can be useful for VNC redirection using an existing https (port
31# 443) webserver, thereby not requiring a 2nd (non-https) port open on
32# the firewall for the CONNECT requests.
33#
34# It does not seem possible (to me) to achieve this entirely within apache
35# because the CONNECT request appears to be forwarded encrypted to
36# the remote host and so the SSL dies immediately.
37#
38# It can also be used to redirect ANY protocol, e.g. SSH, not just VNC.
39# See CONNECT_SWITCH_APPLY_VNC_OFFSET=0 to disable VNC 5900 shift.
40#
41# Note: There is no need to use this script for a non-ssl apache webserver
42# port because mod_proxy works fine for doing the switching all inside
43# apache (see ProxyRequests and AllowCONNECT parameters).
44#
45#
46# Apache configuration:
47#
48# The mod_ssl configuration is often in a file named ssl.conf.  In the
49# simplest case you change something like this:
50#
51#   From:
52#
53#     Listen 443
54#
55#     <VirtualHost _default_:443>
56#     ...
57#     </VirtualHost>
58#
59#   To:
60#
61#     Listen 127.0.0.1:443
62#
63#     <VirtualHost _default_:443>
64#     ...
65#     </VirtualHost>
66#
67# (i.e. just change the Listen directive).
68#
69# If you have mod_ssl listening on a different internal port, you do
70# not need to specify the localhost Listen address.
71#
72# It is probably a good idea to set $listen_host below to the known
73# IP address you want the service to listen on (to avoid localhost where
74# apache is listening).
75#
76
77####################################################################
78# NOTE: For more info on configuration settings, read below for
79#       all of the CONNECT_SWITCH_* env. var. parameters.
80####################################################################
81
82
83####################################################################
84# Allow env vars to also be specified on cmdline:
85#
86foreach my $arg (@ARGV) {
87	if ($arg =~ /^(CONNECT_SWITCH.*?)=(.*)$/) {
88		$ENV{$1} = $2;
89	}
90}
91
92# Set up logging:
93#
94if (exists $ENV{CONNECT_SWITCH_LOGFILE}) {
95	close STDOUT;
96	if (!open(STDOUT, ">>$ENV{CONNECT_SWITCH_LOGFILE}")) {
97		die "connect_switch: $ENV{CONNECT_SWITCH_LOGFILE} $!\n";
98	}
99	close STDERR;
100	open(STDERR, ">&STDOUT");
101}
102select(STDERR); $| = 1;
103select(STDOUT); $| = 1;
104
105# interrupt handler:
106#
107my $looppid = '';
108my $pidfile = '';
109my $listen_sock = '';	# declared here for get_out()
110#
111sub get_out {
112	print STDERR "$_[0]:\t$$ looppid=$looppid\n";
113	close $listen_sock if $listen_sock;
114	if ($looppid) {
115		kill 'TERM', $looppid;
116		fsleep(0.2);
117	}
118	unlink $pidfile if $pidfile;
119	exit 0;
120}
121$SIG{INT}  = \&get_out;
122$SIG{TERM} = \&get_out;
123
124# pidfile:
125#
126sub open_pidfile {
127	if (exists $ENV{CONNECT_SWITCH_PIDFILE}) {
128		my $pf = $ENV{CONNECT_SWITCH_PIDFILE};
129		if (open(PID, ">$pf")) {
130			print PID "$$\n";
131			close PID;
132			$pidfile = $pf;
133		} else {
134			print STDERR "could not open pidfile: $pf - $! - continuing...\n";
135		}
136		delete $ENV{CONNECT_SWITCH_PIDFILE};
137	}
138}
139
140####################################################################
141# Set CONNECT_SWITCH_LOOP=1 to have this script create an outer loop
142# restarting itself if it ever exits.  Set CONNECT_SWITCH_LOOP=BG to
143# do this in the background as a daemon.
144
145if (exists $ENV{CONNECT_SWITCH_LOOP}) {
146	my $csl = $ENV{CONNECT_SWITCH_LOOP};
147	if ($csl ne 'BG' && $csl ne '1') {
148		die "connect_switch: invalid CONNECT_SWITCH_LOOP.\n";
149	}
150	if ($csl eq 'BG') {
151		# go into bg as "daemon":
152		setpgrp(0, 0);
153		my $pid = fork();
154		if (! defined $pid) {
155			die "connect_switch: $!\n";
156		} elsif ($pid) {
157			wait;
158			exit 0;
159		}
160		if (fork) {
161			exit 0;
162		}
163		setpgrp(0, 0);
164		close STDIN;
165		if (! $ENV{CONNECT_SWITCH_LOGFILE}) {
166			close STDOUT;
167			close STDERR;
168		}
169	}
170	delete $ENV{CONNECT_SWITCH_LOOP};
171
172	if (exists $ENV{CONNECT_SWITCH_PIDFILE}) {
173		open_pidfile();
174	}
175
176	print STDERR "connect_switch: starting service at ", scalar(localtime), " master-pid=$$\n";
177	while (1) {
178		$looppid = fork;
179		if (! defined $looppid) {
180			sleep 10;
181		} elsif ($looppid) {
182			wait;
183		} else {
184			exec $0;
185			exit 1;
186		}
187		print STDERR "connect_switch: re-starting service at ", scalar(localtime), " master-pid=$$\n";
188		sleep 1;
189	}
190	exit 0;
191}
192if (exists $ENV{CONNECT_SWITCH_PIDFILE}) {
193	open_pidfile();
194}
195
196
197############################################################################
198# The defaults for hosts and ports (you can override them below if needed):
199#
200# Look below for these environment variables that let you set the various
201# parameters without needing to edit this script:
202#
203#	CONNECT_SWITCH_LISTEN
204#	CONNECT_SWITCH_HTTPD
205#	CONNECT_SWITCH_ALLOWED
206#	CONNECT_SWITCH_ALLOW_FILE
207#	CONNECT_SWITCH_VERBOSE
208#	CONNECT_SWITCH_APPLY_VNC_OFFSET
209#	CONNECT_SWITCH_VNC_OFFSET
210#	CONNECT_SWITCH_LISTEN_IPV6
211#	CONNECT_SWITCH_BUFSIZE
212#	CONNECT_SWITCH_LOGFILE
213#	CONNECT_SWITCH_PIDFILE
214#	CONNECT_SWITCH_MAX_CONNECTIONS
215#
216# You can also set these on the cmdline:
217#      connect_switch CONNECT_SWITCH_LISTEN=X CONNECT_SWITCH_ALLOW_FILE=Y ...
218#
219
220# By default we will use hostname and assume it resolves:
221#
222my $hostname = `hostname`;
223chomp $hostname;
224
225my $listen_host = $hostname;
226my $listen_port = 443;
227
228# Let user override listening situation, e.g. multihomed:
229#
230if (exists $ENV{CONNECT_SWITCH_LISTEN}) {
231	#
232	# E.g. CONNECT_SWITCH_LISTEN=192.168.0.32:443
233	#
234	$listen_host = '';
235	$listen_port = '';
236	if ($ENV{CONNECT_SWITCH_LISTEN} =~ /^(.*):(\d+)$/) {
237		($listen_host, $listen_port) = ($1, $2);
238	}
239}
240
241my $httpd_host = 'localhost';
242my $httpd_port = 443;
243
244if (exists $ENV{CONNECT_SWITCH_HTTPD}) {
245	#
246	# E.g. CONNECT_SWITCH_HTTPD=127.0.0.1:443
247	#
248	$httpd_host = '';
249	$httpd_port = '';
250	if ($ENV{CONNECT_SWITCH_HTTPD} =~ /^(.*):(\d+)$/) {
251		($httpd_host, $httpd_port) = ($1, $2);
252	}
253}
254
255my $bufsize = 8192;
256if (exists $ENV{CONNECT_SWITCH_BUFSIZE}) {
257	#
258	# E.g. CONNECT_SWITCH_BUFSIZE=32768
259	#
260	$bufsize = $ENV{CONNECT_SWITCH_BUFSIZE};
261}
262
263
264############################################################################
265# You can/should override the host/port settings here:
266#
267#$listen_host = '23.45.67.89';		# set to your interface IP number.
268#$listen_port = 555;			# and/or nonstandard port.
269#$httpd_host  = 'somehost';		# maybe you redir https to another machine.
270#$httpd_port  = 666;			# and/or nonstandard port.
271
272# You must set the allowed host:port CONNECT redirection list.
273# Only these host:port pairs will be redirected to.
274# Port ranges are allowed too:  host:5900-5930.
275# If there is one entry named ALL all connections are allow.
276# You must supply something, default is deny.
277#
278my @allowed = qw(
279	machine1:5915
280	machine2:5900
281);
282
283if (exists $ENV{CONNECT_SWITCH_ALLOWED}) {
284	#
285	# E.g. CONNECT_SWITCH_ALLOWED=machine1:5915,machine2:5900
286	#
287	@allowed = split(/,/, $ENV{CONNECT_SWITCH_ALLOWED});
288}
289
290# Or you could also use an external "allow file".
291# They get added to the @allowed list.
292# The file is re-read for each new connection.
293#
294# Format of $allow_file:
295#
296#     host1 vncdisp
297#     host2 vncdisp
298#
299# where, e.g. vncdisp = 15 => port 5915, say
300#
301#     joesbox  15
302#     fredsbox 15
303#     rupert    1
304
305# For examply, mine is:
306#
307my $allow_file = '/dist/apache/2.0/conf/vnc.hosts';
308$allow_file = '';
309
310if (exists $ENV{CONNECT_SWITCH_ALLOW_FILE}) {
311	# E.g. CONNECT_SWITCH_ALLOW_FILE=/usr/local/etc/allow.txt
312	$allow_file = $ENV{CONNECT_SWITCH_ALLOW_FILE};
313}
314
315# Set to 1 to re-map to vnc port, e.g. 'hostname 15' to 'hostname 5915'
316# i.e. assume a port 0 <= port < 200 is actually a VNC display
317# and add 5900 to it.  Set to 0 to not do the mapping.
318# Note that negative ports, e.g. 'joesbox -22' go directly to -port.
319#
320my $apply_vnc_offset = 1;
321my $vnc_offset = 5900;
322
323if (exists $ENV{CONNECT_SWITCH_APPLY_VNC_OFFSET}) {
324	#
325	# E.g. CONNECT_SWITCH_APPLY_VNC_OFFSET=0
326	#
327	$apply_vnc_offset = $ENV{CONNECT_SWITCH_APPLY_VNC_OFFSET};
328}
329if (exists $ENV{CONNECT_SWITCH_VNC_OFFSET}) {
330	#
331	# E.g. CONNECT_SWITCH_VNC_OFFSET=6000
332	#
333	$vnc_offset = $ENV{CONNECT_SWITCH_VNC_OFFSET};
334}
335
336# Set to 1 or higher for more info output:
337#
338my $verbose = 0;
339
340if (exists $ENV{CONNECT_SWITCH_VERBOSE}) {
341	#
342	# E.g. CONNECT_SWITCH_VERBOSE=1
343	#
344	$verbose = $ENV{CONNECT_SWITCH_VERBOSE};
345}
346
347# zero means loop forever, positive value means exit after handling that
348# many connections.
349#
350my $cmax = 0;
351if (exists $ENV{CONNECT_SWITCH_MAX_CONNECTIONS}) {
352	$cmax = $ENV{CONNECT_SWITCH_MAX_CONNECTIONS};
353}
354
355
356#===========================================================================
357#  No need for any changes below here.
358#===========================================================================
359
360use IO::Socket::INET;
361use strict;
362use warnings;
363
364# Test for INET6 support:
365#
366my $have_inet6 = 0;
367eval "use IO::Socket::INET6;";
368$have_inet6 = 1 if $@ eq "";
369
370my $killpid = 1;
371
372setpgrp(0, 0);
373
374if (exists $ENV{CONNECT_SWITCH_LISTEN_IPV6}) {
375	# note we leave out LocalAddr.
376	my $cmd = '
377		$listen_sock = IO::Socket::INET6->new(
378			Listen    => 10,
379			LocalPort => $listen_port,
380			ReuseAddr => 1,
381			Domain    => AF_INET6,
382			Proto     => "tcp"
383		);
384	';
385	eval $cmd;
386	die "$@\n" if $@;
387} else {
388	$listen_sock = IO::Socket::INET->new(
389		Listen    => 10,
390		LocalAddr => $listen_host,
391		LocalPort => $listen_port,
392		ReuseAddr => 1,
393		Proto     => "tcp"
394	);
395}
396
397if (! $listen_sock) {
398	die "connect_switch: $!\n";
399}
400
401my $current_fh1 = '';
402my $current_fh2 = '';
403
404my $conn = 0;
405
406while (1) {
407	$conn++;
408	if ($cmax > 0 && $conn > $cmax) {
409		print STDERR "last connection ($cmax)\n" if $verbose;
410		last;
411	}
412	print STDERR "listening for connection: $conn\n" if $verbose;
413	my ($client, $ip) = $listen_sock->accept();
414	if (! $client) {
415		fsleep(0.5);
416		next;
417	}
418	print STDERR "conn: $conn -- ", $client->peerhost(), " at ", scalar(localtime), "\n" if $verbose;
419
420	my $pid = fork();
421	if (! defined $pid) {
422		die "connect_switch: $!\n";
423	} elsif ($pid) {
424		wait;
425		next;
426	} else {
427		close $listen_sock;
428		if (fork) {
429			exit 0;
430		}
431		setpgrp(0, 0);
432		handle_conn($client);
433	}
434}
435
436exit 0;
437
438sub handle_conn {
439	my $client = shift;
440
441	my $start = time();
442
443	my @allow = @allowed;
444
445	# read allow file.  Note we read it for every connection
446	# to allow the admin to modify it w/o restarting us.
447	# better way would be to read in parent and check mtime.
448	#
449	if ($allow_file && -f $allow_file) {
450		if (open(ALLOW, "<$allow_file")) {
451			while (<ALLOW>) {
452				next if /^\s*#/;
453				next if /^\s*$/;
454				chomp;
455				my ($host, $dpy) = split(' ', $_);
456				next if ! defined $host;
457				next if ! defined $dpy;
458				if ($dpy < 0) {
459					$dpy = -$dpy;
460				} elsif ($apply_vnc_offset) {
461					$dpy += $vnc_offset if $dpy < 200;
462				}
463				push @allow, "$host:$dpy";
464			}
465			close(ALLOW);
466		} else {
467			warn "$allow_file: $!\n";
468		}
469	}
470
471	# Read the first 7 bytes of connection, see if it is 'CONNECT'
472	#
473	my $str = '';
474	my $N = 0;
475	my $isconn = 1;
476	for (my $i = 0; $i < 7; $i++) {
477		my $b;
478		sysread($client, $b, 1);
479		$str .= $b;
480		$N++;
481		print STDERR "read: '$str'\n" if $verbose > 1;
482		my $cstr = substr('CONNECT', 0, $i+1);
483		if ($str ne $cstr) {
484			$isconn = 0;
485			last;
486		}
487	}
488
489	my $sock = '';
490
491	if ($isconn) {
492		# it is CONNECT, read rest of HTTP header:
493		#
494		while ($str !~ /\r\n\r\n/) {
495			my $b;
496			sysread($client, $b, 1);
497			$str .= $b;
498		}
499		print STDERR "read:  $str\n" if $verbose > 1;
500
501		# get http version and host:port
502		#
503		my $ok = 0;
504		my $hostport = '';
505		my $http_vers = '1.0';
506		if ($str =~ /^CONNECT\s+(\S+)\s+HTTP\/(\S+)/) {
507			$hostport = $1;
508			$http_vers = $2;
509			my $h = '';
510			my $p = '';
511
512			if ($hostport =~ /^(.*):(\d+)$/) {
513				($h, $p) = ($1, $2);
514			}
515			if ($p =~ /^\d+$/) {
516				# check allowed host list:
517				foreach my $hp (@allow) {
518					if ($hp eq 'ALL') {
519						$ok = 1;
520					}
521					if ($hp eq $hostport) {
522						$ok = 1;
523					}
524					if ($hp =~ /^(.*):(\d+)-(\d+)$/) {
525						my $ahost = $1;
526						my $pmin  = $2;
527						my $pmax  = $3;
528						if ($h eq $ahost) {
529							if ($p >= $pmin && $p <= $pmax) {
530								$ok = 1;
531							}
532						}
533					}
534					last if $ok;
535				}
536			}
537		}
538
539		my $msg_1 = "HTTP/$http_vers 200 Connection Established\r\n"
540		     . "Proxy-agent: connect_switch v0.2\r\n\r\n";
541		my $msg_2 = "HTTP/$http_vers 502 Bad Gateway\r\n"
542			     . "Connection: close\r\n\r\n";
543
544		if (! $ok) {
545			# disallowed. drop with message.
546			#
547			syswrite($client, $msg_2, length($msg_2));
548			close $client;
549			exit 0;
550		}
551
552		my $host = '';
553		my $port = '';
554
555		if ($hostport =~ /^(.*):(\d+)$/) {
556			($host, $port) = ($1, $2);
557		}
558
559		print STDERR "connecting to: $host:$port\n" if $verbose;
560
561		$sock = IO::Socket::INET->new(
562			PeerAddr => $host,
563			PeerPort => $port,
564			Proto => "tcp"
565		);
566		print STDERR "connect to host='$host' port='$port' failed: $!\n" if !$sock;
567		if (! $sock && $have_inet6) {
568			eval {$sock = IO::Socket::INET6->new(
569				PeerAddr => $host,
570				PeerPort => $port,
571				Proto => "tcp"
572			);};
573			print STDERR "connect to host='$host' port='$port' failed: $! (ipv6)\n" if !$sock;
574		}
575		my $msg;
576
577		# send the connect proxy reply:
578		#
579		if ($sock) {
580			$msg = $msg_1;
581		} else {
582			$msg = $msg_2;
583		}
584		syswrite($client, $msg, length($msg));
585		$str = '';
586	} else {
587		# otherwise, redirect to apache for normal https:
588		#
589		print STDERR "connecting to: $httpd_host:$httpd_port\n" if $verbose;
590		$sock = IO::Socket::INET->new(
591			PeerAddr => $httpd_host,
592			PeerPort => $httpd_port,
593			Proto => "tcp"
594		);
595		if (! $sock && $have_inet6) {
596			eval {$sock = IO::Socket::INET6->new(
597				PeerAddr => $httpd_host,
598				PeerPort => $httpd_port,
599				Proto => "tcp"
600			);};
601		}
602	}
603
604	if (! $sock) {
605		close $client;
606		die "connect_switch: $!\n";
607	}
608
609	# get ready for xfer phase:
610	#
611	$current_fh1 = $client;
612	$current_fh2 = $sock;
613
614	$SIG{TERM} = sub {print STDERR "got sigterm\[$$]\n" if $verbose; close $current_fh1; close $current_fh2; exit 0};
615
616	my $parent = $$;
617	if (my $child = fork()) {
618		xfer($sock, $client, 'S->C');
619		if ($killpid) {
620			fsleep(0.5);
621			kill 'TERM', $child;
622		}
623	} else {
624		# write those first bytes if not CONNECT:
625		#
626		if ($str ne '' && $N > 0) {
627			syswrite($sock, $str, $N);
628		}
629		xfer($client, $sock, 'C->S');
630		if ($killpid) {
631			fsleep(0.75);
632			kill 'TERM', $parent;
633		}
634	}
635	if ($verbose > 1) {
636		my $dt = time() - $start;
637		print STDERR "duration\[$$]: $dt seconds. ", scalar(localtime), "\n";
638	}
639	exit 0;
640}
641
642sub xfer {
643	my($in, $out, $lab) = @_;
644	my ($RIN, $WIN, $EIN, $ROUT);
645	$RIN = $WIN = $EIN = "";
646	$ROUT = "";
647	vec($RIN, fileno($in), 1) = 1;
648	vec($WIN, fileno($in), 1) = 1;
649	$EIN = $RIN | $WIN;
650	my $buf;
651
652	while (1) {
653		my $nf = 0;
654		while (! $nf) {
655			$nf = select($ROUT=$RIN, undef, undef, undef);
656		}
657		my $len = sysread($in, $buf, $bufsize);
658		if (! defined($len)) {
659			next if $! =~ /^Interrupted/;
660			print STDERR "connect_switch\[$lab/$conn/$$]: $!\n";
661			last;
662		} elsif ($len == 0) {
663			print STDERR "connect_switch\[$lab/$conn/$$]: "
664			    . "Input is EOF.\n";
665			last;
666		}
667
668		if (0) {
669			# very verbose debugging of data:
670			syswrite(STDERR , "\n$lab: ", 6);
671			syswrite(STDERR , $buf, $len);
672		}
673
674		my $offset = 0;
675		my $quit = 0;
676		while ($len) {
677			my $written = syswrite($out, $buf, $len, $offset);
678			if (! defined $written) {
679				print STDERR "connect_switch\[$lab/$conn/$$]: "
680				    . "Output is EOF. $!\n";
681				$quit = 1;
682				last;
683			}
684			$len -= $written;
685			$offset += $written;
686		}
687		last if $quit;
688	}
689	close($in);
690	close($out);
691}
692
693sub fsleep {
694	my ($time) = @_;
695	select(undef, undef, undef, $time) if $time;
696}
697