Monday, February 10, 2014

Yet another Mac SSH SOCKS proxy script

Comcast hasn't been handling video streaming very well lately, so I thought I'd try through a VPN. I've already got a private server with SSH access and unlimited bandwidth. SSH has a flag to present a SOCKS proxy to the client machine and forward through the connection and a SOCKS client is built in to the OS X networking stack, so we'll give that a try. I found one or two scripts to establish a the proxy with SSH and configure OS X to use it, but they don't handle error conditions very nicely and require me to enter an admin password twice - once to establish the connection, and once to break it down. Here's my entry in the genre, followed by a description of how it works.

Launching SSH

We can launch the proxy with:

ssh -ND 1080 $host
'-N' keeps the connection open without running a command; '-D 1080' configures the SOCKS proxy on port 1080. Ideally we want to try to start the SSH tunnel first so that if it can't connect we don't bother to prompt for an admin password and enable, then disable, the proxy configuration. We'll need to keep the SSH connection open while we complete the subsequent configuration steps, so we want to launch it as a background subprocess. That's pretty easy to do with python's subprocess module:
import subprocess, sys
host = sys.argv[1]
ssh = subprocess.Popen(['ssh', '-ND', '1080', host])
# configure OS X to use the proxy here...
However, that doesn't give us any way to know when it has successfully connected and we can move on. We need ssh to print something out upon a successful connection, and we need our script to look for that output. Let's use the ssh LocalCommand option, which runs a command in your shell once the connection has been established. We'll just echo a string we can look for from the script.
import subprocess, sys
host = sys.argv[1]
ssh = subprocess.Popen([
    'ssh', '-ND', '1080',

    # Have ssh print 'connected' after successful connection
    '-o', 'PermitLocalCommand=yes',
    '-o', "LocalCommand=echo connected",

    host],

    stdout=subprocess.PIPE
    )

if ssh.stdout.readline() == "connected\n":
    # configure OS X to use the proxy here...

Configuring OS X to use the proxy

It's easy enough to configure use of a SOCKS proxy from Network Preferences, but it takes a few clicks. Let's use the networksetup tool on the command line instead. Initially we need to point it to localhost at the right port:

sudo networksetup -setsocksfirewallproxy Wi-Fi localhost 1080
Subsequently, we can turn it on:
sudo networksetup -setsocksfirewallproxystate Wi-Fi on
and off:
sudo networksetup -setsocksfirewallproxystate Wi-Fi off
For our implementation in python we could just call each of those commands with Popen as above. But if there's a long time between turning the proxy configuration on and turning it off, sudo will ask for our password both times. We can do better. Let's launch a root privilege shell with with "sudo -s" and send it commands:
proxy_command_base = ['networksetup', '-setsocksfirewallproxystate', 'Wi-Fi']
admin_shell = subprocess.Popen(
        ['sudo', '-s'],
        # Line buffered so we're sure the shell gets our commands immediately.
        bufsize=1,
        stdin=subprocess.PIPE)

admin_shell.stdin.write(' '.join(proxy_command_base + ['on']) + \
        " && echo 'SOCKS proxy via %s on. CTRL-C to turn off.'\n" % host)

ssh.wait()

admin_shell.stdin.write(' '.join(proxy_command_base + ['off']) + \
        ' && echo "SOCKS proxy off."\n')

admin_shell.stdin.close()
admin_shell.wait()
No matter how long the SSH connection stays open, when it terminates we'll still be able to turn off the proxy configuration without authenticating again.

We still need to handle clean termination of the connection, however. Let's allow the user to disable the proxy by sending our script SIGINT, eg. pressing CTRL-C. When we press CTRL-C, the shell sends SIGINT to each process in the current process group. That'll include our python interpreter, the ssh process, and the privileged shell. We want ssh to respond by terminating, but we want the other two processes to stay alive so we can turn the proxy configuration off. We can keep our script alive by catching the KeyboardInterrupt exception:

try:
    # configure proxy here
    ssh.wait()

# SIGINT will have killed ssh; carry on to clean up SOCKS config.
except KeyboardInterrupt:
    print

# disable proxy configuration here
Finally, we need to stop the privileged shell from exiting. We'll use the preexec_fn option of the Popen object to remap SIGINT so that it's ignored in the subprocess:
admin_shell = subprocess.Popen(
        ['sudo', '-s'],

        # Line buffered so we're sure the shell gets our commands immediately.
        bufsize=1,

        stdin=subprocess.PIPE,

        # Don't let SIGINT (ctrl-c) kill the shell - we need to use it to clean up.
        preexec_fn = lambda: signal.signal(signal.SIGINT, signal.SIG_IGN)
        )
That's about it. Comments welcome.

No comments:

Post a Comment