The London Perl and Raku Workshop takes place on 26th Oct 2024. If your company depends on Perl, please consider sponsoring and/or attending.
#!/usr/bin/perl -w
use strict;
use IO::Socket;
use IO::Select;
use LWP::UserAgent;
use Getopt::Long;

use vars qw( %CONF $VERSION );

my %listen;               # listening sockets
my %socket;               # tunnel sockets

$VERSION = 0.03;

# default configuration
%CONF = ( 'user-agent' => "connect-tunnel/$VERSION", );

=head1 NAME

connect-tunnel - Create CONNECT tunnels through HTTP proxies

=head1 SYNOPSIS

B<connect-tunnel> S<[ B<-v> ]> S<[ B<-A> I<user:pass> ]> S<[ B<-P> I<proxy:port> ]>
               B<-T> I<port:host:hostport> S<[ B<-T> I<port:host:hostport> ]>

=head1 DESCRIPTION

B<connect-tunnel> sets up tunneled connections to external hosts
by redirecting connections to local ports towards thoses hosts/ports
through a HTTP proxy.

B<connect-tunnel> makes use of the HTTP C<CONNECT> method to ask the
proxy to create a tunnel to an outside server. Be aware that some
proxies are set up to deny some outside tunnels (either to ports other
than 443 or outside a specified set of outside hosts).

=head1 OPTIONS

The  program  follows  the  usual  GNU  command  line syntax, with long
options starting with two dashes.

=over 4

=item B<-A>, B<--proxy-authentication> I<user:password>

Proxy authentication information.

Please note that all the authentication schemes supported by
LWP::UserAgent are supported (we use an LWP::UserAgent).

This means we also support NTLM, since it is supported as from libwww-perl
5.66.

=item B<-L>, B<--local-only>

Create the tunnels so that they will only listen on C<localhost>.
Thus, only connections originating from the machine that runs
B<connect-tunnel> will be accepted.

That was the default behaviour in B<connect-tunnel> version 0.02.

=item B<-P>, B<--proxy> I<proxy>[I<:port>]

The proxy is required to connect the tunnels.
If no port is given, 8080 is used by default.

See also L<ENVIRONMENT VARIABLES>.

=item B<-T>, B<--tunnel> I<port:host:hostport>

Specifies that the given I<port> on the local host is to be forwarded
to the given I<host> and I<hostport> on the remote side. This works by
allocating a socket to listen to I<port> on the local side, and whenever
a connection is made to this I<port>, the connection is forwarded
through the proxy, and a connection is made to the remote I<host> at
port I<hostport>.

On Unix systems, only root can forward privileged ports.

Note that you can setup tunnels to multiple destinations, by using
the B<--tunnel> option several times.

=item B<-U>, B<--user-agent> I<string>

Specify User-Agent value to send in HTTP requests.
The default is to send C<connect-tunnel/I<version>>.

=item B<-v>, B<--verbose>

Verbose output.

This option can be used several times for more verbose output.

=back

=cut

#
# get and check the options
#
GetOptions( \%CONF, "verbose|v+", "tunnel|T=s@", "proxy|P=s",
    "proxy-authentication|A=s", "local-only|L", "user-agent|U=s" );

# create a homebrewed user agent class
{
    my ( $user, $pass );
    ( $user, $pass ) = split ':', $CONF{'proxy-authentication'}
      if defined $CONF{'proxy-authentication'};

    package TunnelAgent;

    use vars qw( @ISA );
    @ISA = qw(LWP::UserAgent);

    sub get_basic_credentials { return ( $user, $pass ); }
}

my $ua = TunnelAgent->new(
    agent => $CONF{'user-agent'},
    env_proxy => 1,
);

# check for a proxy
if ( $CONF{proxy} ) {
    $CONF{proxy} .= ":8080" if not $CONF{proxy} =~ /:/;
    $ua->proxy( http => "http://$CONF{proxy}/" );
}
die "--proxy <proxy:port> option required$/" unless $ua->proxy( 'http' );

# create the tunnels entrances
die "--tunnel <port:host:hostport> option required$/"
  unless exists $CONF{tunnel};
for ( @{ $CONF{tunnel} } ) {
    die "--tunnel <port:host:hostport> format required$/"
      unless /^\d+:[\w.]+:\d+$/;
    my ( $port, $host, $hostport ) = split ':';
    my $socket = IO::Socket::INET->new(
        $CONF{'local-only'} ? ( LocalAddr => 'localhost' ) : (),
        Listen    => 1,
        LocalPort => $port,
        Proto     => 'tcp',
        ReuseAddr => 1,
    );
    die "Tunnel error: $! for $_\n" if not defined $socket;
    @{ $listen{$socket} }{qw( self port host hostport dest )} =
      ( $socket, $port, $host, $hostport, "$host:$hostport" );
}

=head1 EXAMPLES

To connect to a SSH server running beyond the proxy on port 443,
through the proxy proxy.company.com, running on port 8080,
use the following command:

 connect-tunnel -P proxy.company.com:8080 -T 22:ssh.example.com:443

And now point your favorite ssh client to the machine running
B<connect-tunnel>.

You can also emulate a "standard" user-agent:

 connect-tunnel -U "Mozilla/4.03 [en] (X11; I; Linux 2.1.89 i586)"
                -P proxy.company.com:8080 -T 22:ssh.example.com:443

B<connect-tunnel> can easily use your proxy credentials to connect
outside:

 connect-tunnel -U "Mozilla/4.03 [en] (X11; I; Linux 2.1.89 i586)"
                -P proxy.company.com:8080 -T 22:ssh.example.com:443
                -A book:s3kr3t

But if you don't want anybody else to connect to your tunnels
and through the proxy with I<your> credentials, use the
B<--local-only> option:

 connect-tunnel -U "Mozilla/4.03 [en] (X11; I; Linux 2.1.89 i586)"
                -P proxy.company.com:8080 -T 22:ssh.example.com:443
                -A book:s3kr3t -L

If you have several destinations, there is no need to run several
instances of B<connect-tunnel>:

 connect-tunnel -U "Mozilla/4.03 [en] (X11; I; Linux 2.1.89 i586)"
                -P proxy.company.com:8080 -A book:s3kr3t -L
                -T 22:ssh.example.com:443
                -T 222:ssh2.example.com:443

But naturally, you will need to correctly set up the ports in your clients.

Mmm, such a long command line would perfectly fit in an alias or a F<.BAT>
file. C<;-)>

=head1 ENVIRONMENT VARIABLES

The LWP::UserAgent that is used to connect to the proxy accept
the usual C<HTTP_PROXY> environment variable to define the proxy.

The environment variable is overriden by the B<--proxy> option,
if passed to B<connect-tunnel>.

=cut

# listen to the tunnels entrances
my $select = IO::Select->new;
$select->add( map { $listen{$_}{self} } keys %listen );

# the loop
while ( my @ready = $select->can_read ) {
    for (@ready) {

        # a new connection to the listening ports
        if ( exists $listen{$_} ) {

            # Create a new socket
            my $client = $listen{$_}{self}->accept;
            my ( $port, $addr ) = unpack_sockaddr_in( $client->peername );
            $addr = join '.', unpack( 'C4', $addr );
            print "$addr:$port -> $listen{$_}{dest} : connection request$/"
              if $CONF{verbose} > 0;

            # connect to the proxy
            my $req =
              HTTP::Request->new(
                CONNECT => 'http://' . $listen{$_}{dest} . '/' );
            my $res = $ua->request($req);

            # authentication failed
            if ( not $res->is_success ) {
                warn "$addr:$port -> $listen{$_}{dest} : "
                  . "connection failed with status "
                  . $res->status_line
                  . $/;
                $client->close;
                next;
            }

            # the proxy socket
            my $proxy = $res->{client_socket};
            my ( $pport, $paddr ) = unpack_sockaddr_in( $proxy->peername );
            $paddr = join '.', unpack( 'C4', $paddr );
            print "$addr:$port -> $listen{$_}{dest} : ",
              "connection established via $paddr:$pport$/"
              if $CONF{verbose} > 0;

            # set up peers data structures
            @{ $socket{$client} }{qw( self peer host port host_port )} =
              ( $client, $proxy, $addr, $port, "$addr:$port" );
            @{ $socket{$proxy} }{qw( self peer host port host_port dest )} = (
                $proxy, $client, $paddr, $pport, "$paddr:$pport",
                $listen{$_}{dest}
            );
            for ( $client, $proxy ) {
                $_->autoflush(1);
                $_->blocking(0);
                $select->add($_);
            }
        }
        else {    # data on established connections

            # useful data
            my $sock = $socket{$_}{self};
            my $peer = $socket{$_}{peer};
            my ( $data, $src, $dst );
            if ( $CONF{verbose} > 0 ) {
                $src =
                  $socket{$sock}{host_port}
                  . (
                    exists $socket{$sock}{dest}
                    ? ' (' . $socket{$sock}{dest} . ')'
                    : '' );
                $dst =
                  $socket{$peer}{host_port}
                  . (
                    exists $socket{$peer}{dest}
                    ? ' (' . $socket{$peer}{dest} . ')'
                    : '' );
            }

            # read the data
            my $read = $sock->sysread( $data, 4096 );

            # check for errors or end of connection
            if ( not defined $read ) {
                warn "Unable to read from " . $socket{$sock}{host_port} . $/;
                $read = 0;
            }

            # end of connection
            if ( $read == 0 ) {
                for ( $sock, $peer ) {
                    $_->close;
                    delete $socket{$_};

                    # reset the IO::Select object, since $select->remove
                    # removes more than we want to
                    $select = IO::Select->new;
                    $select->add( map { $listen{$_}{self} } keys %listen );
                    $select->add( map { $socket{$_}{self} } keys %socket );
                }
                print "$src -> $dst : connection closed$/"
                  if $CONF{verbose} > 0;
                next;
            }

            # proxy the data
            $peer->syswrite($data);

            print "$src -> $dst : ", length($data), " bytes sent$/"
              if ( $CONF{verbose} > 1 );
        }
    }
}

=head1 TODO

Next version should have an option to create a control port, to which one
could connect to interact with B<connect-tunnel> and add/remove tunnels,
close connections, change the User-Agent string, and so on.

=head1 AUTHOR

Philippe "BooK" Bruhat E<lt>book@cpan.orgE<gt>

I seem to have re-invented a well-known wheel with that script, but at
least hope I have added a few interesting options to it.

Bits of the documentation wording is stolen from OpenSSH documentation
about options B<-L> and B<-R>.

=head1 COPYRIGHT

This module is free software; you can redistribute it or modify it under
the same terms as Perl itself.

=cut