Compare commits

..

29 Commits

Author SHA1 Message Date
668441adb6 Merge branch 'macapp'
A cute little GUI for sshuttle on MacOS, written using pyobjc.

* macapp:
  ui-macos: call the main binary MacOS/Sshuttle.
  ui-macos/git-export.do: write the generated app to a branch.
  ui-macos/default.app.do: get rid of some duplicated files.
  ui-macos: Actually prompt for passwords instead of assuming a default.
  ui-macos: Don't enable connecting for hosts with Custom but zero subnets.
  ui-macos: Smoother log messages in the log window.
  ui-macos: Notice when we've connected; make debug logs optional.
  ui-macos: Much better connection status reporting.
  ui-macos/run.do: a shortcut for running debug.app.
  ui-macos/*: "a series of unfortunate events."
2011-01-22 16:49:21 -08:00
d4ccd30c24 ui-macos: call the main binary MacOS/Sshuttle.
That way we can 'killall Sshuttle' and so on, and it looks right in the
process list.
2011-01-22 16:44:20 -08:00
522bc23854 ui-macos/git-export.do: write the generated app to a branch. 2011-01-22 16:44:20 -08:00
191d97a897 ui-macos/default.app.do: get rid of some duplicated files. 2011-01-22 16:44:15 -08:00
da7a490cd7 ui-macos: Actually prompt for passwords instead of assuming a default. 2011-01-22 16:44:11 -08:00
342ce12840 ui-macos: Don't enable connecting for hosts with Custom but zero subnets.
That would cause the sshuttle client to die.
2011-01-22 16:44:05 -08:00
701f59a5b8 ui-macos: Smoother log messages in the log window. 2011-01-22 16:44:01 -08:00
9f7b2bb4f6 ui-macos: Notice when we've connected; make debug logs optional. 2011-01-22 16:43:48 -08:00
d301184184 ui-macos: Much better connection status reporting. 2011-01-22 16:43:44 -08:00
659a57beb2 ui-macos/run.do: a shortcut for running debug.app. 2011-01-22 16:43:09 -08:00
c70b9937df ui-macos/*: "a series of unfortunate events."
Just kidding.  This is a squash of a whole bunch of unlabeled temporary
commits that I produced over the last couple of weeks while writing a UI
for MacOS while riding on airplanes and sitting in airports.

So long, batch of useless commits!
2011-01-22 16:43:09 -08:00
415be935d4 options.py: don't die if tty width is set to 0.
This sometimes happens if weird people, such as myself, open a pty without
setting the width field correctly.
2011-01-22 16:42:32 -08:00
d4c9d31068 Oops, we were being overzealous about calling nowrite().
We were doing it as soon as the other end was shut_read, but we didn't
confirm first that the other end's read buffer had been totally emptied.
This caused problems on twitter.com, at least, where they create a zillion
short-lived connections.

I don't actually need these lines at all, as it turns out, because we were
already calling nowrite() correctly on the other end after a buffer got
emptied.
2011-01-12 09:33:56 -08:00
38bb7f3c21 If we get EPIPE on uwrite(), don't close, just do nowrite().
EPIPE doesn't mean the whole socket is dead, it just means we can't write to
it.  Maybe there's still data waiting to be read, though.
2011-01-12 09:19:43 -08:00
b7f1530aef Remember which syscall got a particular stream-killing error. 2011-01-12 09:18:46 -08:00
973d5a95a1 man page update for daemonization options. 2011-01-01 00:32:37 -08:00
95ab6e7119 ssyslog.py: use daemon.notice instead of daemon.info
...MacOS X seems to default (in /etc/syslog.conf) to not logging daemon.info
anywhere.  That kind of defeats the purpose, I think.
2011-01-01 00:28:31 -08:00
e6d7c44e27 Merge branch 'daemon'
* daemon:
  daemonization: make sure the firewall subproc sends to syslog too.
  Rearrange daemonization/syslog stuff and make it more resilient.
  run in background (daemon) and option
2011-01-01 00:22:43 -08:00
5bf6e40682 daemonization: make sure the firewall subproc sends to syslog too. 2011-01-01 00:22:09 -08:00
8a5ae1a40a Rearrange daemonization/syslog stuff and make it more resilient.
Rename --background to -D/--daemon, to match other programs (like smbd).

You can now have --syslog even without --daemon.

Avoid using atexit(); try/finally is better.

Don't just close stderr; we'll end up eating error output from ssh!
Instead, redirect stderr to a 'logger' subprocess that will send to syslog.

Delay redirecting stderr until after we know we're daemonizing, so handy
error messages can go to stderr instead of syslog.

Make pidfile stuff more resilient: support already-existing files, files
with strict permissions, outdated files containing an already-dead pid.  Add
a --pidfile option to let you specify the pidfile path.

chdir("/") while daemonizing, so that the filesystem containing $PWD can
later be unmounted without killing the daemon.

fw.done() can't wait on the firewall subprocess on exit when daemonized; we
no longer are the parent of that process.
2010-12-31 23:55:19 -08:00
651b607361 If ssh dies right after starting, we might get ECONNRESET.
Turn it into a nicer-looking fatal error instead of an exception dump.
2010-12-31 23:46:47 -08:00
dc9a5e63c7 Minor tweak to help for the python= option. 2010-12-31 23:46:05 -08:00
33bc55be27 Merge branch 'closing'
* closing:
  Correctly close server connection when client disconnects.
  "Too many open files" shouldn't be a fatal condition.
2010-12-31 22:12:54 -08:00
c3204d2728 Correctly close server connection when client disconnects.
When the server disconnected, we were forwarding that information to the
client.  But we weren't forwarding back the other way when the client
disconnected since there was no callback in place to do that.

Relatedly, when we failed entirely to connect to the server, we didn't notify the
client right away.  Now we do.

Thanks to 'Roger' on the mailing list for pointing out these bugs.
2010-12-31 21:59:36 -08:00
b1edb226a5 "Too many open files" shouldn't be a fatal condition.
It can happen if there are too many sockets open.  If that happens, just
throw away any connections that arrive in the meantime instead of aborting
completely.
2010-12-31 21:32:51 -08:00
7fa1c3c4e4 Listen on localhost:0 instead of 0.0.0.0:0 by default.
This avoids any possible problem caused by other people on your network
using you as a proxy.  If you want to allow this, you can force it back to
the old way using the --listen option.

Thanks to 'tass' on github for reporting portscans that revealed this
potential security problem.
2010-12-31 21:22:40 -08:00
cca69eb496 Don't allow proxying of connections to the proxy port.
Add some cleverness for breaking infinite loops.  Previously we'd only
detect it successfully if you connected to exactly the same IP as we were
listening on, but that was unreliable if we're listening on 0.0.0.0 and you
connected to one of the IP addresses we haven't heard of.

Now, if you try to connect to our listen port on *any* IP, we try binding to
that IP as a local socket; if it works, that's a local IP, and therefore
it's our socket, so reject the connection.  If it doesn't work, it's a
remote IP, so forward it along.

Thanks to 'tass' on github for noticing the problem.
2010-12-31 21:22:32 -08:00
91f65132be Get rid of ugly quotes on "Accept:" log messages. 2010-12-31 20:54:46 -08:00
2ef3a301fb run in background (daemon) and option 2010-12-12 12:08:54 +08:00
38 changed files with 3350 additions and 38 deletions

166
client.py
View File

@ -1,9 +1,98 @@
import struct, socket, select, errno, re
import struct, socket, select, errno, re, signal
import compat.ssubprocess as ssubprocess
import helpers, ssnet, ssh
import helpers, ssnet, ssh, ssyslog
from ssnet import SockWrapper, Handler, Proxy, Mux, MuxWrapper
from helpers import *
_extra_fd = os.open('/dev/null', os.O_RDONLY)
def _islocal(ip):
sock = socket.socket()
try:
try:
sock.bind((ip, 0))
except socket.error, e:
if e.args[0] == errno.EADDRNOTAVAIL:
return False # not a local IP
else:
raise
finally:
sock.close()
return True # it's a local IP, or there would have been an error
def got_signal(signum, frame):
log('exiting on signal %d\n' % signum)
sys.exit(1)
_pidname = None
def check_daemon(pidfile):
global _pidname
_pidname = os.path.abspath(pidfile)
try:
oldpid = open(_pidname).read(1024)
except IOError, e:
if e.errno == errno.ENOENT:
return # no pidfile, ok
else:
raise Fatal("can't read %s: %s" % (_pidname, e))
if not oldpid:
os.unlink(_pidname)
return # invalid pidfile, ok
oldpid = int(oldpid.strip() or 0)
if oldpid <= 0:
os.unlink(_pidname)
return # invalid pidfile, ok
try:
os.kill(oldpid, 0)
except OSError, e:
if e.errno == errno.ESRCH:
os.unlink(_pidname)
return # outdated pidfile, ok
elif e.errno == errno.EPERM:
pass
else:
raise
raise Fatal("%s: sshuttle is already running (pid=%d)"
% (_pidname, oldpid))
def daemonize():
if os.fork():
os._exit(0)
os.setsid()
if os.fork():
os._exit(0)
outfd = os.open(_pidname, os.O_WRONLY|os.O_CREAT|os.O_EXCL, 0666)
try:
os.write(outfd, '%d\n' % os.getpid())
finally:
os.close(outfd)
os.chdir("/")
# Normal exit when killed, or try/finally won't work and the pidfile won't
# be deleted.
signal.signal(signal.SIGTERM, got_signal)
si = open('/dev/null', 'r+')
os.dup2(si.fileno(), 0)
os.dup2(si.fileno(), 1)
si.close()
ssyslog.stderr_to_syslog()
def daemon_cleanup():
try:
os.unlink(_pidname)
except OSError, e:
if e.errno == errno.ENOENT:
pass
else:
raise
def original_dst(sock):
try:
@ -30,6 +119,8 @@ class FirewallClient:
argvbase = ([sys.argv[0]] +
['-v'] * (helpers.verbose or 0) +
['--firewall', str(port)])
if ssyslog._p:
argvbase += ['--syslog']
argv_tries = [
['sudo', '-p', '[local sudo] Password: '] + argvbase,
['su', '-c', ' '.join(argvbase)],
@ -98,25 +189,34 @@ class FirewallClient:
raise Fatal('cleanup: %r returned %d' % (self.argv, rv))
def _main(listener, fw, ssh_cmd, remotename, python, seed_hosts, auto_nets):
def _main(listener, fw, ssh_cmd, remotename, python, seed_hosts, auto_nets,
syslog, daemon):
handlers = []
if helpers.verbose >= 1:
helpers.logprefix = 'c : '
else:
helpers.logprefix = 'client: '
debug1('connecting to server...\n')
try:
(serverproc, serversock) = ssh.connect(ssh_cmd, remotename, python)
(serverproc, serversock) = ssh.connect(ssh_cmd, remotename, python,
stderr=ssyslog._p and ssyslog._p.stdin)
except socket.error, e:
if e.errno == errno.EPIPE:
raise Fatal("failed to establish ssh session")
if e.args[0] == errno.EPIPE:
raise Fatal("failed to establish ssh session (1)")
else:
raise
mux = Mux(serversock, serversock)
handlers.append(mux)
expected = 'SSHUTTLE0001'
initstring = serversock.recv(len(expected))
try:
initstring = serversock.recv(len(expected))
except socket.error, e:
if e.args[0] == errno.ECONNRESET:
raise Fatal("failed to establish ssh session (2)")
else:
raise
rv = serverproc.poll()
if rv:
@ -126,6 +226,14 @@ def _main(listener, fw, ssh_cmd, remotename, python, seed_hosts, auto_nets):
raise Fatal('expected server init string %r; got %r'
% (expected, initstring))
debug1('connected.\n')
print 'Connected.'
sys.stdout.flush()
if daemon:
daemonize()
log('daemonizing (%s).\n' % _pidname)
elif syslog:
debug1('switching to syslog.\n')
ssyslog.stderr_to_syslog()
def onroutes(routestr):
if auto_nets:
@ -153,11 +261,26 @@ def _main(listener, fw, ssh_cmd, remotename, python, seed_hosts, auto_nets):
mux.got_host_list = onhostlist
def onaccept():
sock,srcip = listener.accept()
global _extra_fd
try:
sock,srcip = listener.accept()
except socket.error, e:
if e.args[0] in [errno.EMFILE, errno.ENFILE]:
debug1('Rejected incoming connection: too many open files!\n')
# free up an fd so we can eat the connection
os.close(_extra_fd)
try:
sock,srcip = listener.accept()
sock.close()
finally:
_extra_fd = os.open('/dev/null', os.O_RDONLY)
return
else:
raise
dstip = original_dst(sock)
debug1('Accept: %r:%r -> %r:%r.\n' % (srcip[0],srcip[1],
dstip[0],dstip[1]))
if dstip == listener.getsockname():
debug1('Accept: %s:%r -> %s:%r.\n' % (srcip[0],srcip[1],
dstip[0],dstip[1]))
if dstip[1] == listener.getsockname()[1] and _islocal(dstip[0]):
debug1("-- ignored: that's my address!\n")
sock.close()
return
@ -182,7 +305,15 @@ def _main(listener, fw, ssh_cmd, remotename, python, seed_hosts, auto_nets):
def main(listenip, ssh_cmd, remotename, python, seed_hosts, auto_nets,
subnets_include, subnets_exclude):
subnets_include, subnets_exclude, syslog, daemon, pidfile):
if syslog:
ssyslog.start_syslog()
if daemon:
try:
check_daemon(pidfile)
except Fatal, e:
log("%s\n" % e)
return 5
debug1('Starting sshuttle proxy.\n')
listener = socket.socket()
listener.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
@ -213,6 +344,13 @@ def main(listenip, ssh_cmd, remotename, python, seed_hosts, auto_nets,
try:
return _main(listener, fw, ssh_cmd, remotename,
python, seed_hosts, auto_nets)
python, seed_hosts, auto_nets, syslog, daemon)
finally:
fw.done()
try:
if daemon:
# it's not our child anymore; can't waitpid
fw.p.returncode = 0
fw.done()
finally:
if daemon:
daemon_cleanup()

View File

@ -1,6 +1,6 @@
import re, errno
import compat.ssubprocess as ssubprocess
import helpers
import helpers, ssyslog
from helpers import *
@ -216,7 +216,7 @@ def restore_etc_hosts(port):
# exit. In case that fails, it's not the end of the world; future runs will
# supercede it in the transproxy list, at least, so the leftover rules
# are hopefully harmless.
def main(port):
def main(port, syslog):
assert(port > 0)
assert(port <= 65535)
@ -235,6 +235,10 @@ def main(port):
# can read from it.
os.dup2(1, 0)
if syslog:
ssyslog.start_syslog()
ssyslog.stderr_to_syslog()
debug1('firewall manager ready.\n')
sys.stdout.write('READY\n')
sys.stdout.flush()

18
main.py
View File

@ -1,6 +1,7 @@
#!/usr/bin/env python
import sys, os, re
import helpers, options, client, server, firewall, hostwatch
import compat.ssubprocess as ssubprocess
from helpers import *
@ -46,18 +47,22 @@ def parse_ipport(s):
optspec = """
sshuttle [-l [ip:]port] [-r [username@]sshserver[:port]] <subnets...>
sshuttle --firewall <port> <subnets...>
sshuttle --server
sshuttle --firewall <port> <subnets...>
sshuttle --hostwatch
--
l,listen= transproxy to this ip address and port number [0.0.0.0:0]
l,listen= transproxy to this ip address and port number [127.0.0.1:0]
H,auto-hosts scan for remote hostnames and update local /etc/hosts
N,auto-nets automatically determine subnets to route
python= specify the name/path of the python interpreter on the remote server [python]
python= path to python interpreter on the remote server [python]
r,remote= ssh hostname (and optional username) of remote sshuttle server
x,exclude= exclude this subnet (can be used more than once)
v,verbose increase debug message verbosity
e,ssh-cmd= the command to use to connect to the remote [ssh]
seed-hosts= with -H, use these hostnames for initial scan (comma-separated)
D,daemon run in the background as a daemon
syslog send log messages to syslog (default if you use --daemon)
pidfile= pidfile name (only if using --daemon) [./sshuttle.pid]
server (internal use only)
firewall (internal use only)
hostwatch (internal use only)
@ -65,6 +70,8 @@ hostwatch (internal use only)
o = options.Options('sshuttle', optspec)
(opt, flags, extra) = o.parse(sys.argv[1:])
if opt.daemon:
opt.syslog = 1
helpers.verbose = opt.verbose
try:
@ -75,7 +82,7 @@ try:
elif opt.firewall:
if len(extra) != 1:
o.fatal('exactly one argument expected')
sys.exit(firewall.main(int(extra[0])))
sys.exit(firewall.main(int(extra[0]), opt.syslog))
elif opt.hostwatch:
sys.exit(hostwatch.hw_main(extra))
else:
@ -104,7 +111,8 @@ try:
sh,
opt.auto_nets,
parse_subnets(includes),
parse_subnets(excludes)))
parse_subnets(excludes),
opt.syslog, opt.daemon, opt.pidfile))
except Fatal, e:
log('fatal: %s\n' % e)
sys.exit(99)

View File

@ -60,7 +60,7 @@ def _tty_width():
except (IOError, ImportError):
return _atoi(os.environ.get('WIDTH')) or 70
(ysize,xsize,ypix,xpix) = struct.unpack('HHHH', s)
return xsize
return xsize or 70
class Options:

View File

@ -166,6 +166,7 @@ def main():
while mux.ok:
if hw.pid:
assert(hw.pid > 0)
(rpid, rv) = os.waitpid(hw.pid, os.WNOHANG)
if rpid:
raise Fatal('hostwatch exited unexpectedly: code 0x%04x\n' % rv)

4
ssh.py
View File

@ -21,7 +21,7 @@ def empackage(z, filename):
return '%s\n%d\n%s' % (basename,len(content), content)
def connect(ssh_cmd, rhostport, python):
def connect(ssh_cmd, rhostport, python, stderr):
main_exe = sys.argv[0]
portl = []
@ -87,7 +87,7 @@ def connect(ssh_cmd, rhostport, python):
s1.close()
debug2('executing: %r\n' % argv)
p = ssubprocess.Popen(argv, stdin=s1a, stdout=s1b, preexec_fn=setup,
close_fds=True)
close_fds=True, stderr=stderr)
os.close(s1a)
os.close(s1b)
s2.sendall(content)

View File

@ -1,6 +1,6 @@
% sshuttle(8) Sshuttle 0.42
% sshuttle(8) Sshuttle 0.44
% Avery Pennarun <apenwarr@gmail.com>
% 2010-11-09
% 2010-12-31
# NAME
@ -41,7 +41,13 @@ entire subnet to the VPN.
-l, --listen=*[ip:]port*
: use this ip address and port number as the transparent
proxy port. By default sshuttle finds an available
port automatically, so you don't need to override it.
port automatically and listens on IP 127.0.0.1
(localhost), so you don't need to override it, and
connections are only proxied from the local machine,
not from outside machines. If you want to accept
connections from other machines on your network (ie. to
run sshuttle on a router) try enabling IP Forwarding in
your kernel, then using `--listen 0.0.0.0:0`.
-H, --auto-hosts
: scan for remote hostnames and update the local /etc/hosts
@ -102,6 +108,20 @@ entire subnet to the VPN.
for lists of local hostnames, but can speed things up
if you use this option to give it a few names to start
from.
-D, --daemon
: automatically fork into the background after connecting
to the remote server. Implies `--syslog`.
--syslog
: after connecting, send all log messages to the
`syslog`(3) service instead of stderr. This is
implicit if you use `--daemon`.
--pidfile=*pidfilename*
: when using `--daemon`, save sshuttle's pid to
*pidfilename*. The default is `sshuttle.pid` in the
current directory.
--server
: (internal use only) run the sshuttle server on
@ -139,8 +159,8 @@ Test locally by proxying all local connections, without using ssh:
s: 192.168.42.0/24
c : connected.
firewall manager: starting transproxy.
c : Accept: '192.168.42.106':50035 -> '192.168.42.121':139.
c : Accept: '192.168.42.121':47523 -> '77.141.99.22':443.
c : Accept: 192.168.42.106:50035 -> 192.168.42.121:139.
c : Accept: 192.168.42.121:47523 -> 77.141.99.22:443.
...etc...
^C
firewall manager: undoing changes.
@ -166,7 +186,7 @@ and subnet guessing:
hostwatch: Found: testbox1: 1.2.3.4
hostwatch: Found: mytest2: 5.6.7.8
hostwatch: Found: domaincontroller: 99.1.2.3
c : Accept: '192.168.42.121':60554 -> '77.141.99.22':22.
c : Accept: 192.168.42.121:60554 -> 77.141.99.22:22.
^C
firewall manager: undoing changes.
c : Keyboard interrupt: exiting.

View File

@ -15,7 +15,7 @@ CMD_EXIT = 0x4200
CMD_PING = 0x4201
CMD_PONG = 0x4202
CMD_CONNECT = 0x4203
# CMD_CLOSE = 0x4204 # never used - removed
CMD_STOP_SENDING = 0x4204
CMD_EOF = 0x4205
CMD_DATA = 0x4206
CMD_ROUTES = 0x4207
@ -27,6 +27,7 @@ cmd_to_name = {
CMD_PING: 'PING',
CMD_PONG: 'PONG',
CMD_CONNECT: 'CONNECT',
CMD_STOP_SENDING: 'STOP_SENDING',
CMD_EOF: 'EOF',
CMD_DATA: 'DATA',
CMD_ROUTES: 'ROUTES',
@ -106,6 +107,8 @@ class SockWrapper:
def seterr(self, e):
if not self.exc:
self.exc = e
self.nowrite()
self.noread()
def try_connect(self):
if self.connect_to and self.shut_write:
@ -148,7 +151,7 @@ class SockWrapper:
try:
self.wsock.shutdown(SHUT_WR)
except socket.error, e:
self.seterr(e)
self.seterr('nowrite: %s' % e)
def too_full(self):
return False # fullness is determined by the socket's select() state
@ -160,11 +163,14 @@ class SockWrapper:
try:
return _nb_clean(os.write, self.wsock.fileno(), buf)
except OSError, e:
# unexpected error... stream is dead
self.seterr(e)
self.nowrite()
self.noread()
return 0
if e.errno == errno.EPIPE:
debug1('%r: uwrite: got EPIPE\n' % self)
self.nowrite()
return 0
else:
# unexpected error... stream is dead
self.seterr('uwrite: %s' % e)
return 0
def write(self, buf):
assert(buf)
@ -179,7 +185,7 @@ class SockWrapper:
try:
return _nb_clean(os.read, self.rsock.fileno(), 65536)
except OSError, e:
self.seterr(e)
self.seterr('uread: %s' % e)
return '' # unexpected error... we'll call it EOF
def fill(self):
@ -231,6 +237,9 @@ class Proxy(Handler):
self.wrap2 = wrap2
def pre_select(self, r, w, x):
if self.wrap1.shut_write: self.wrap2.noread()
if self.wrap2.shut_write: self.wrap1.noread()
if self.wrap1.connect_to:
_add(w, self.wrap1.rsock)
elif self.wrap1.buf:
@ -430,6 +439,7 @@ class MuxWrapper(SockWrapper):
def noread(self):
if not self.shut_read:
self.shut_read = True
self.mux.send(self.channel, CMD_STOP_SENDING, '')
self.maybe_close()
def nowrite(self):
@ -464,6 +474,8 @@ class MuxWrapper(SockWrapper):
def got_packet(self, cmd, data):
if cmd == CMD_EOF:
self.noread()
elif cmd == CMD_STOP_SENDING:
self.nowrite()
elif cmd == CMD_DATA:
self.buf.append(data)
else:

16
ssyslog.py Normal file
View File

@ -0,0 +1,16 @@
import sys, os
from compat import ssubprocess
_p = None
def start_syslog():
global _p
_p = ssubprocess.Popen(['logger',
'-p', 'daemon.notice',
'-t', 'sshuttle'], stdin=ssubprocess.PIPE)
def stderr_to_syslog():
sys.stdout.flush()
sys.stderr.flush()
os.dup2(_p.stdin.fileno(), 2)

8
ui-macos/.gitignore vendored Normal file
View File

@ -0,0 +1,8 @@
*.pyc
*~
/*.nib
/debug.app
/sources.list
/Sshuttle VPN.app
/*.tar.gz
/*.zip

40
ui-macos/Info.plist Normal file
View File

@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>English</string>
<key>CFBundleDisplayName</key>
<string>Sshuttle VPN</string>
<key>CFBundleExecutable</key>
<string>Sshuttle</string>
<key>CFBundleIconFile</key>
<string>app.icns</string>
<key>CFBundleIdentifier</key>
<string>ca.apenwarr.Sshuttle</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>Sshuttle VPN</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>0.0.0</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
<string>0.0.0</string>
<key>LSUIElement</key>
<string>1</string>
<key>LSHasLocalizedDisplayName</key>
<false/>
<key>NSAppleScriptEnabled</key>
<false/>
<key>NSHumanReadableCopyright</key>
<string>GNU LGPL Version 2</string>
<key>NSMainNibFile</key>
<string>MainMenu</string>
<key>NSPrincipalClass</key>
<string>NSApplication</string>
</dict>
</plist>

2340
ui-macos/MainMenu.xib Normal file

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>startAtLogin</key>
<false/>
<key>autoReconnect</key>
<true/>
</dict>
</plist>

1
ui-macos/all.do Normal file
View File

@ -0,0 +1 @@
redo-ifchange debug.app dist

BIN
ui-macos/app.icns Normal file

Binary file not shown.

28
ui-macos/askpass.py Normal file
View File

@ -0,0 +1,28 @@
import sys, os, re, subprocess
def askpass(prompt):
prompt = prompt.replace('"', "'")
if 'yes/no' in prompt:
return "yes"
script="""
tell application "Finder"
activate
display dialog "%s" \
with title "Sshuttle SSH Connection" \
default answer "" \
with icon caution \
with hidden answer
end tell
""" % prompt
p = subprocess.Popen(['osascript', '-e', script], stdout=subprocess.PIPE)
out = p.stdout.read()
rv = p.wait()
if rv:
return None
g = re.match("text returned:(.*), button returned:.*", out)
if not g:
return None
return g.group(1)

1
ui-macos/bits/.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/runpython

1
ui-macos/bits/PkgInfo Normal file
View File

@ -0,0 +1 @@
APPL????

23
ui-macos/bits/runpython.c Normal file
View File

@ -0,0 +1,23 @@
/*
* This rather pointless program acts like the python interpreter, except
* it's intended to sit inside a MacOS .app package, so that its argv[0]
* will point inside the package.
*
* NSApplicationMain() looks for Info.plist using the path in argv[0], which
* goes wrong if your interpreter is /usr/bin/python.
*/
#include <Python.h>
#include <string.h>
#include <unistd.h>
int main(int argc, char **argv)
{
char *path = strdup(argv[0]), *cptr;
char *args[] = {argv[0], "../Resources/main.py", NULL};
cptr = strrchr(path, '/');
if (cptr)
*cptr = 0;
chdir(path);
free(path);
return Py_Main(2, args);
}

View File

@ -0,0 +1,5 @@
exec >&2
redo-ifchange runpython.c
gcc -Wall -o $3 runpython.c \
-I/usr/include/python2.5 \
-lpython2.5

Binary file not shown.

After

Width:  |  Height:  |  Size: 821 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 789 B

BIN
ui-macos/chicken-tiny.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 810 B

4
ui-macos/clean.do Normal file
View File

@ -0,0 +1,4 @@
exec >&2
find -name '*~' | xargs rm -f
rm -rf *.app *.zip *.tar.gz
rm -f bits/runpython *.nib sources.list

15
ui-macos/debug.app.do Normal file
View File

@ -0,0 +1,15 @@
redo-ifchange bits/runpython MainMenu.nib
rm -rf debug.app
mkdir debug.app debug.app/Contents
cd debug.app/Contents
ln -s ../.. Resources
ln -s ../.. English.lproj
ln -s ../../Info.plist .
ln -s ../../app.icns .
mkdir MacOS
cd MacOS
ln -s ../../../bits/runpython Sshuttle
cd ../../..
redo-ifchange $(find debug.app -type f)

28
ui-macos/default.app.do Normal file
View File

@ -0,0 +1,28 @@
TOP=$PWD
redo-ifchange sources.list
redo-ifchange Info.plist bits/runpython \
$(while read name newname; do echo "$name"; done <sources.list)
rm -rf "$1.app"
mkdir "$1.app" "$1.app/Contents"
cd "$1.app/Contents"
cp "$TOP/Info.plist" .
mkdir MacOS
cp "$TOP/bits/runpython" MacOS/Sshuttle
mkdir Resources
cd "$TOP"
while read name newname; do
[ -z "$name" ] && continue
: "${newname:=$name}"
outname=$1.app/Contents/Resources/$newname
outdir=$(dirname "$outname")
[ -d "$outdir" ] || mkdir "$outdir"
cp "${name-$newname}" "$outname"
done <sources.list
cd "$1.app"
redo-ifchange $(find . -type f)

View File

@ -0,0 +1,5 @@
exec >&2
IFS="
"
redo-ifchange $1.app
tar -czf $3 $1.app/

View File

@ -0,0 +1,5 @@
exec >&2
IFS="
"
redo-ifchange $1.app
zip -q -r $3 $1.app/

2
ui-macos/default.nib.do Normal file
View File

@ -0,0 +1,2 @@
redo-ifchange $1.xib
ibtool --compile $3 $1.xib

1
ui-macos/dist.do Normal file
View File

@ -0,0 +1 @@
redo-ifchange "Sshuttle VPN.app.zip" "Sshuttle VPN.app.tar.gz"

19
ui-macos/git-export.do Normal file
View File

@ -0,0 +1,19 @@
# update a local branch with pregenerated output files, so people can download
# the completed tarballs from github. Since we don't have any real binaries,
# our final distribution package contains mostly blobs from the source code,
# so this doesn't cost us much extra space in the repo.
BRANCH=dist/macos
redo-ifchange 'Sshuttle VPN.app'
git update-ref refs/heads/$BRANCH origin/$BRANCH '' 2>/dev/null || true
export GIT_INDEX_FILE=$PWD/gitindex.tmp
rm -f "$GIT_INDEX_FILE"
git add -f 'Sshuttle VPN.app'
MSG="MacOS precompiled app package for $(git describe)"
TREE=$(git write-tree --prefix=ui-macos)
git show-ref refs/heads/$BRANCH >/dev/null && PARENT="-p refs/heads/$BRANCH"
COMMITID=$(echo "$MSG" | git commit-tree $TREE $PARENT)
git update-ref refs/heads/$BRANCH $COMMITID
rm -f "$GIT_INDEX_FILE"

352
ui-macos/main.py Normal file
View File

@ -0,0 +1,352 @@
import sys, os, pty
from AppKit import *
import my, models, askpass
def sshuttle_args(host, auto_nets, auto_hosts, nets, debug):
argv = [my.bundle_path('sshuttle/sshuttle', ''), '-r', host]
assert(argv[0])
if debug:
argv.append('-v')
if auto_nets:
argv.append('--auto-nets')
if auto_hosts:
argv.append('--auto-hosts')
argv += nets
return argv
class _Callback(NSObject):
def initWithFunc_(self, func):
self = super(_Callback, self).init()
self.func = func
return self
def func_(self, obj):
return self.func(obj)
class Callback:
def __init__(self, func):
self.obj = _Callback.alloc().initWithFunc_(func)
self.sel = self.obj.func_
class Runner:
def __init__(self, argv, logfunc, promptfunc, serverobj):
print 'in __init__'
self.id = argv
self.rv = None
self.pid = None
self.fd = None
self.logfunc = logfunc
self.promptfunc = promptfunc
self.serverobj = serverobj
self.buf = ''
self.logfunc('\nConnecting to %s.\n' % self.serverobj.host())
print 'will run: %r' % argv
self.serverobj.setConnected_(False)
pid,fd = pty.fork()
if pid == 0:
# child
try:
os.execvp(argv[0], argv)
except Exception, e:
sys.stderr.write('failed to start: %r\n' % e)
raise
finally:
os._exit(42)
# parent
self.pid = pid
self.file = NSFileHandle.alloc()\
.initWithFileDescriptor_closeOnDealloc_(fd, True)
self.cb = Callback(self.gotdata)
NSNotificationCenter.defaultCenter()\
.addObserver_selector_name_object_(self.cb.obj, self.cb.sel,
NSFileHandleDataAvailableNotification, self.file)
self.file.waitForDataInBackgroundAndNotify()
def __del__(self):
self.wait()
def _try_wait(self, options):
if self.rv == None and self.pid > 0:
pid,code = os.waitpid(self.pid, options)
if pid == self.pid:
if os.WIFEXITED(code):
self.rv = os.WEXITSTATUS(code)
else:
self.rv = -os.WSTOPSIG(code)
self.serverobj.setConnected_(False)
self.serverobj.setError_('VPN process died')
self.logfunc('Disconnected.\n')
print 'wait_result: %r' % self.rv
return self.rv
def wait(self):
return self._try_wait(0)
def poll(self):
return self._try_wait(os.WNOHANG)
def kill(self):
assert(self.pid > 0)
print 'killing: pid=%r rv=%r' % (self.pid, self.rv)
if self.rv == None:
self.logfunc('Disconnecting from %s.\n' % self.serverobj.host())
os.kill(self.pid, 15)
self.wait()
def gotdata(self, notification):
print 'gotdata!'
d = str(self.file.availableData())
if d:
self.logfunc(d)
self.buf = self.buf + d
if 'Connected.\r\n' in self.buf:
self.serverobj.setConnected_(True)
self.buf = self.buf[-4096:]
if self.buf.strip().endswith(':'):
lastline = self.buf.rstrip().split('\n')[-1]
resp = self.promptfunc(lastline)
add = ' (response)\n'
self.buf += add
self.logfunc(add)
self.file.writeData_(my.Data(resp + '\n'))
self.file.waitForDataInBackgroundAndNotify()
self.poll()
#print 'gotdata done!'
class SshuttleApp(NSObject):
def initialize(self):
d = my.PList('UserDefaults')
my.Defaults().registerDefaults_(d)
class SshuttleController(NSObject):
# Interface builder outlets
startAtLoginField = objc.IBOutlet()
autoReconnectField = objc.IBOutlet()
debugField = objc.IBOutlet()
routingField = objc.IBOutlet()
prefsWindow = objc.IBOutlet()
serversController = objc.IBOutlet()
logField = objc.IBOutlet()
servers = []
conns = {}
def _connect(self, server):
host = server.host()
print 'connecting %r' % host
self.fill_menu()
def logfunc(msg):
print 'log! (%d bytes)' % len(msg)
self.logField.textStorage()\
.appendAttributedString_(NSAttributedString.alloc()\
.initWithString_(msg))
self.logField.didChangeText()
def promptfunc(prompt):
print 'prompt! %r' % prompt
return askpass.askpass(prompt)
nets_mode = server.autoNets()
if nets_mode == models.NET_MANUAL:
manual_nets = ["%s/%d" % (i.subnet(), i.width())
for i in server.nets()]
elif nets_mode == models.NET_ALL:
manual_nets = ['0/0']
else:
manual_nets = []
conn = Runner(sshuttle_args(host,
auto_nets = nets_mode == models.NET_AUTO,
auto_hosts = server.autoHosts(),
nets = manual_nets,
debug = self.debugField.state()),
logfunc=logfunc, promptfunc=promptfunc,
serverobj=server)
self.conns[host] = conn
def _disconnect(self, server):
host = server.host()
print 'disconnecting %r' % host
conn = self.conns.get(host)
if conn:
conn.kill()
self.fill_menu()
self.logField.textStorage().setAttributedString_(
NSAttributedString.alloc().initWithString_(''))
@objc.IBAction
def cmd_connect(self, sender):
server = sender.representedObject()
server.setWantConnect_(True)
@objc.IBAction
def cmd_disconnect(self, sender):
server = sender.representedObject()
server.setWantConnect_(False)
@objc.IBAction
def cmd_show(self, sender):
self.prefsWindow.makeKeyAndOrderFront_(self)
NSApp.activateIgnoringOtherApps_(True)
@objc.IBAction
def cmd_quit(self, sender):
NSApp.performSelector_withObject_afterDelay_(NSApp.terminate_,
None, 0.0)
def fill_menu(self):
menu = self.menu
menu.removeAllItems()
def additem(name, func, obj):
it = menu.addItemWithTitle_action_keyEquivalent_(name, None, "")
it.setRepresentedObject_(obj)
it.setTarget_(self)
it.setAction_(func)
def addnote(name):
additem(name, None, None)
any_inprogress = None
any_conn = None
any_err = None
if len(self.servers):
for i in self.servers:
host = i.host()
want = i.wantConnect()
connected = i.connected()
numnets = len(list(i.nets()))
if not host:
additem('Connect Untitled', None, i)
elif i.autoNets() == models.NET_MANUAL and not numnets:
additem('Connect %s (no routes)' % host, None, i)
elif want:
any_conn = i
additem('Disconnect %s' % host, self.cmd_disconnect, i)
else:
additem('Connect %s' % host, self.cmd_connect, i)
if not want:
msg = 'Off'
elif i.error():
msg = 'ERROR - try reconnecting'
any_err = i
elif connected:
msg = 'Connected'
else:
msg = 'Connecting...'
any_inprogress = i
addnote(' State: %s' % msg)
if i.autoNets() == 0:
addnote(' Routes: All')
elif i.autoNets() == 2:
addnote(' Routes: Auto')
else:
addnote(' Routes: Custom')
else:
addnote('No servers defined yet')
menu.addItem_(NSMenuItem.separatorItem())
additem('Preferences...', self.cmd_show, None)
additem('Quit Sshuttle VPN', self.cmd_quit, None)
if any_err:
self.statusitem.setImage_(self.img_err)
self.statusitem.setTitle_('Error!')
elif any_conn:
self.statusitem.setImage_(self.img_running)
if any_inprogress:
self.statusitem.setTitle_('Connecting...')
else:
self.statusitem.setTitle_('')
else:
self.statusitem.setImage_(self.img_idle)
self.statusitem.setTitle_('')
def load_servers(self):
l = my.Defaults().arrayForKey_('servers') or []
sl = []
for s in l:
host = s.get('host', None)
if not host: continue
nets = s.get('nets', [])
nl = []
for n in nets:
subnet = n[0]
width = n[1]
net = models.SshuttleNet.alloc().init()
net.setSubnet_(subnet)
net.setWidth_(width)
nl.append(net)
autoNets = s.get('autoNets', 1)
autoHosts = s.get('autoHosts', 1)
srv = models.SshuttleServer.alloc().init()
srv.setHost_(host)
srv.setAutoNets_(autoNets)
srv.setAutoHosts_(autoHosts)
srv.setNets_(nl)
sl.append(srv)
self.serversController.addObjects_(sl)
self.serversController.setSelectionIndex_(0)
def save_servers(self):
l = []
for s in self.servers:
host = s.host()
if not host: continue
nets = []
for n in s.nets():
subnet = n.subnet()
if not subnet: continue
nets.append((subnet, n.width()))
d = dict(host=s.host(),
nets=nets,
autoNets=s.autoNets(),
autoHosts=s.autoHosts())
l.append(d)
my.Defaults().setObject_forKey_(l, 'servers')
self.fill_menu()
def awakeFromNib(self):
self.routingField.removeAllItems()
tf = self.routingField.addItemWithTitle_
tf('Send all traffic through this server')
tf('Determine automatically')
tf('Custom...')
# Hmm, even when I mark this as !enabled in the .nib, it still comes
# through as enabled. So let's just disable it here (since we don't
# support this feature yet).
self.startAtLoginField.setEnabled_(False)
self.startAtLoginField.setState_(False)
self.autoReconnectField.setEnabled_(False)
self.autoReconnectField.setState_(False)
self.load_servers()
# Initialize our menu item
self.menu = NSMenu.alloc().initWithTitle_('Sshuttle')
bar = NSStatusBar.systemStatusBar()
statusitem = bar.statusItemWithLength_(NSVariableStatusItemLength)
self.statusitem = statusitem
self.img_idle = my.Image('chicken-tiny-bw', 'png')
self.img_running = my.Image('chicken-tiny', 'png')
self.img_err = my.Image('chicken-tiny-err', 'png')
statusitem.setImage_(self.img_idle)
statusitem.setHighlightMode_(True)
statusitem.setMenu_(self.menu)
self.fill_menu()
models.configchange_callback = my.DelayedCallback(self.save_servers)
def sc(server):
if server.wantConnect():
self._connect(server)
else:
self._disconnect(server)
models.setconnect_callback = sc
# Note: NSApplicationMain calls sys.exit(), so this never returns.
NSApplicationMain(sys.argv)

131
ui-macos/models.py Normal file
View File

@ -0,0 +1,131 @@
from AppKit import *
import my
configchange_callback = setconnect_callback = None
def config_changed():
if configchange_callback:
configchange_callback()
def _validate_ip(v):
parts = v.split('.')[:4]
if len(parts) < 4:
parts += ['0'] * (4 - len(parts))
for i in range(4):
n = my.atoi(parts[i])
if n < 0:
n = 0
elif n > 255:
n = 255
parts[i] = str(n)
return '.'.join(parts)
def _validate_width(v):
n = my.atoi(v)
if n < 0:
n = 0
elif n > 32:
n = 32
return n
class SshuttleNet(NSObject):
def subnet(self):
return getattr(self, '_k_subnet', None)
def setSubnet_(self, v):
self._k_subnet = v
config_changed()
@objc.accessor
def validateSubnet_error_(self, value, error):
#print 'validateSubnet!'
return True, _validate_ip(value), error
def width(self):
return getattr(self, '_k_width', 24)
def setWidth_(self, v):
self._k_width = v
config_changed()
@objc.accessor
def validateWidth_error_(self, value, error):
#print 'validateWidth!'
return True, _validate_width(value), error
NET_ALL = 0
NET_AUTO = 1
NET_MANUAL = 2
class SshuttleServer(NSObject):
def init(self):
self = super(SshuttleServer, self).init()
config_changed()
return self
def wantConnect(self):
return getattr(self, '_k_wantconnect', False)
def setWantConnect_(self, v):
self._k_wantconnect = v
self.setError_(None)
config_changed()
if setconnect_callback: setconnect_callback(self)
def connected(self):
return getattr(self, '_k_connected', False)
def setConnected_(self, v):
print 'setConnected of %r to %r' % (self, v)
self._k_connected = v
if v: self.setError_(None) # connected ok, so no error
config_changed()
def error(self):
return getattr(self, '_k_error', None)
def setError_(self, v):
self._k_error = v
config_changed()
def isValid(self):
if not self.host():
return False
if self.autoNets() == NET_MANUAL and not len(list(self.nets())):
return False
return True
def host(self):
return getattr(self, '_k_host', None)
def setHost_(self, v):
self._k_host = v
config_changed()
@objc.accessor
def validateHost_error_(self, value, error):
#print 'validatehost! %r %r %r' % (self, value, error)
while value.startswith('-'):
value = value[1:]
return True, value, error
def nets(self):
return getattr(self, '_k_nets', [])
def setNets_(self, v):
self._k_nets = v
config_changed()
def netsHidden(self):
#print 'checking netsHidden'
return self.autoNets() != NET_MANUAL
def setNetsHidden_(self, v):
config_changed()
#print 'setting netsHidden to %r' % v
def autoNets(self):
return getattr(self, '_k_autoNets', NET_AUTO)
def setAutoNets_(self, v):
self._k_autoNets = v
self.setNetsHidden_(-1)
config_changed()
def autoHosts(self):
return getattr(self, '_k_autoHosts', True)
def setAutoHosts_(self, v):
self._k_autoHosts = v
config_changed()

62
ui-macos/my.py Normal file
View File

@ -0,0 +1,62 @@
import sys, os
from AppKit import *
import PyObjCTools.AppHelper
def bundle_path(name, typ):
if typ:
return NSBundle.mainBundle().pathForResource_ofType_(name, typ)
else:
return os.path.join(NSBundle.mainBundle().resourcePath(), name)
# Load an NSData using a python string
def Data(s):
return NSData.alloc().initWithBytes_length_(s, len(s))
# Load a property list from a file in the application bundle.
def PList(name):
path = bundle_path(name, 'plist')
return NSDictionary.dictionaryWithContentsOfFile_(path)
# Load an NSImage from a file in the application bundle.
def Image(name, ext):
bytes = open(bundle_path(name, ext)).read()
img = NSImage.alloc().initWithData_(Data(bytes))
return img
# Return the NSUserDefaults shared object.
def Defaults():
return NSUserDefaults.standardUserDefaults()
# Usage:
# f = DelayedCallback(func, args...)
# later:
# f()
#
# When you call f(), it will schedule a call to func() next time the
# ObjC event loop iterates. Multiple calls to f() in a single iteration
# will only result in one call to func().
#
def DelayedCallback(func, *args, **kwargs):
flag = [0]
def _go():
if flag[0]:
print 'running %r (flag=%r)' % (func, flag)
flag[0] = 0
func(*args, **kwargs)
def call():
flag[0] += 1
PyObjCTools.AppHelper.callAfter(_go)
return call
def atoi(s):
try:
return int(s)
except ValueError:
return 0

3
ui-macos/run.do Normal file
View File

@ -0,0 +1,3 @@
redo-ifchange debug.app
exec >&2
./debug.app/Contents/MacOS/run

14
ui-macos/sources.list.do Normal file
View File

@ -0,0 +1,14 @@
redo-always
exec >$3
cat <<-EOF
app.icns
MainMenu.nib English.lproj/MainMenu.nib
UserDefaults.plist
chicken-tiny.png
chicken-tiny-bw.png
chicken-tiny-err.png
EOF
for d in *.py sshuttle/*.py sshuttle/sshuttle sshuttle/compat/*.py; do
echo $d
done
redo-stamp <$3

1
ui-macos/sshuttle Symbolic link
View File

@ -0,0 +1 @@
..

14
ui-macos/stupid.py Normal file
View File

@ -0,0 +1,14 @@
import os
pid = os.fork()
if pid == 0:
# child
try:
os.setsid()
#os.execvp('sudo', ['sudo', 'SSH_ASKPASS=%s' % os.path.abspath('askpass.py'), 'ssh', 'afterlife', 'ls'])
os.execvp('ssh', ['ssh', 'afterlife', 'ls'])
finally:
os._exit(44)
else:
# parent
os.wait()