Skip to content

Commit 71b2132

Browse files
committed
Add UNIX socket support to notebook server.
1 parent 20c2c66 commit 71b2132

8 files changed

+273
-49
lines changed

notebook/__init__.py

+2
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020
os.path.join(os.path.dirname(__file__), "templates"),
2121
]
2222

23+
DEFAULT_NOTEBOOK_PORT = 8888
24+
2325
del os
2426

2527
from .nbextensions import install_nbextension

notebook/base/handlers.py

+12-7
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@
4040
import notebook
4141
from notebook._tz import utcnow
4242
from notebook.i18n import combine_translations
43-
from notebook.utils import is_hidden, url_path_join, url_is_absolute, url_escape
43+
from notebook.utils import is_hidden, url_path_join, url_is_absolute, url_escape, urldecode_unix_socket_path
4444
from notebook.services.security import csp_report_uri
4545

4646
#-----------------------------------------------------------------------------
@@ -483,13 +483,18 @@ def check_host(self):
483483
# ip_address only accepts unicode on Python 2
484484
host = host.decode('utf8', 'replace')
485485

486-
try:
487-
addr = ipaddress.ip_address(host)
488-
except ValueError:
489-
# Not an IP address: check against hostnames
490-
allow = host in self.settings.get('local_hostnames', ['localhost'])
486+
# UNIX socket handling
487+
check_host = urldecode_unix_socket_path(host)
488+
if check_host.startswith('/') and os.path.exists(check_host):
489+
allow = True
491490
else:
492-
allow = addr.is_loopback
491+
try:
492+
addr = ipaddress.ip_address(host)
493+
except ValueError:
494+
# Not an IP address: check against hostnames
495+
allow = host in self.settings.get('local_hostnames', ['localhost'])
496+
else:
497+
allow = addr.is_loopback
493498

494499
if not allow:
495500
self.log.warning(

notebook/notebookapp.py

+156-35
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,11 @@
6363
from tornado import web
6464
from tornado.httputil import url_concat
6565
from tornado.log import LogFormatter, app_log, access_log, gen_log
66+
if not sys.platform.startswith('win'):
67+
from tornado.netutil import bind_unix_socket
6668

6769
from notebook import (
70+
DEFAULT_NOTEBOOK_PORT,
6871
DEFAULT_STATIC_FILES_PATH,
6972
DEFAULT_TEMPLATE_PATH_LIST,
7073
__version__,
@@ -109,7 +112,16 @@
109112
from notebook._sysinfo import get_sys_info
110113

111114
from ._tz import utcnow, utcfromtimestamp
112-
from .utils import url_path_join, check_pid, url_escape, urljoin, pathname2url
115+
from .utils import (
116+
check_pid,
117+
pathname2url,
118+
url_escape,
119+
url_path_join,
120+
urldecode_unix_socket_path,
121+
urlencode_unix_socket,
122+
urlencode_unix_socket_path,
123+
urljoin,
124+
)
113125

114126
#-----------------------------------------------------------------------------
115127
# Module globals
@@ -213,7 +225,7 @@ def init_settings(self, jupyter_app, kernel_manager, contents_manager,
213225
warnings.warn(_("The `ignore_minified_js` flag is deprecated and will be removed in Notebook 6.0"), DeprecationWarning)
214226

215227
now = utcnow()
216-
228+
217229
root_dir = contents_manager.root_dir
218230
home = py3compat.str_to_unicode(os.path.expanduser('~'), encoding=sys.getfilesystemencoding())
219231
if root_dir.startswith(home + os.path.sep):
@@ -398,6 +410,7 @@ def start(self):
398410
set_password(config_file=self.config_file)
399411
self.log.info("Wrote hashed password to %s" % self.config_file)
400412

413+
401414
def shutdown_server(server_info, timeout=5, log=None):
402415
"""Shutdown a notebook server in a separate process.
403416
@@ -410,14 +423,39 @@ def shutdown_server(server_info, timeout=5, log=None):
410423
Returns True if the server was stopped by any means, False if stopping it
411424
failed (on Windows).
412425
"""
413-
from tornado.httpclient import HTTPClient, HTTPRequest
426+
from tornado import gen
427+
from tornado.httpclient import AsyncHTTPClient, HTTPClient, HTTPRequest
428+
from tornado.netutil import bind_unix_socket, Resolver
414429
url = server_info['url']
415430
pid = server_info['pid']
431+
resolver = None
432+
433+
# UNIX Socket handling.
434+
if url.startswith('http+unix://'):
435+
# This library doesn't understand our URI form, but it's just HTTP.
436+
url = url.replace('http+unix://', 'http://')
437+
438+
class UnixSocketResolver(Resolver):
439+
def initialize(self, resolver):
440+
self.resolver = resolver
441+
442+
def close(self):
443+
self.resolver.close()
444+
445+
@gen.coroutine
446+
def resolve(self, host, port, *args, **kwargs):
447+
raise gen.Return([
448+
(socket.AF_UNIX, urldecode_unix_socket_path(host))
449+
])
450+
451+
resolver = UnixSocketResolver(resolver=Resolver())
452+
416453
req = HTTPRequest(url + 'api/shutdown', method='POST', body=b'', headers={
417454
'Authorization': 'token ' + server_info['token']
418455
})
419456
if log: log.debug("POST request to %sapi/shutdown", url)
420-
HTTPClient().fetch(req)
457+
AsyncHTTPClient.configure(None, resolver=resolver)
458+
HTTPClient(AsyncHTTPClient).fetch(req)
421459

422460
# Poll to see if it shut down.
423461
for _ in range(timeout*10):
@@ -448,13 +486,20 @@ class NbserverStopApp(JupyterApp):
448486
version = __version__
449487
description="Stop currently running notebook server for a given port"
450488

451-
port = Integer(8888, config=True,
452-
help="Port of the server to be killed. Default 8888")
489+
port = Integer(DEFAULT_NOTEBOOK_PORT, config=True,
490+
help="Port of the server to be killed. Default %s" % DEFAULT_NOTEBOOK_PORT)
491+
492+
sock = Unicode(u'', config=True,
493+
help="UNIX socket of the server to be killed.")
453494

454495
def parse_command_line(self, argv=None):
455496
super(NbserverStopApp, self).parse_command_line(argv)
456497
if self.extra_args:
457-
self.port=int(self.extra_args[0])
498+
try:
499+
self.port = int(self.extra_args[0])
500+
except ValueError:
501+
# self.extra_args[0] was not an int, so it must be a string (unix socket).
502+
self.sock = self.extra_args[0]
458503

459504
def shutdown_server(self, server):
460505
return shutdown_server(server, log=self.log)
@@ -464,16 +509,16 @@ def start(self):
464509
if not servers:
465510
self.exit("There are no running servers")
466511
for server in servers:
467-
if server['port'] == self.port:
468-
print("Shutting down server on port", self.port, "...")
512+
if server.get('sock') == self.sock or server['port'] == self.port:
513+
print("Shutting down server on %s..." % self.sock or self.port)
469514
if not self.shutdown_server(server):
470515
sys.exit("Could not stop server")
471516
return
472517
else:
473518
print("There is currently no server running on port {}".format(self.port), file=sys.stderr)
474-
print("Ports currently in use:", file=sys.stderr)
519+
print("Ports/sockets currently in use:", file=sys.stderr)
475520
for server in servers:
476-
print(" - {}".format(server['port']), file=sys.stderr)
521+
print(" - {}".format(server.get('sock', server['port'])), file=sys.stderr)
477522
self.exit(1)
478523

479524

@@ -553,6 +598,8 @@ def start(self):
553598
'ip': 'NotebookApp.ip',
554599
'port': 'NotebookApp.port',
555600
'port-retries': 'NotebookApp.port_retries',
601+
'sock': 'NotebookApp.sock',
602+
'sock-umask': 'NotebookApp.sock_umask',
556603
'transport': 'KernelManager.transport',
557604
'keyfile': 'NotebookApp.keyfile',
558605
'certfile': 'NotebookApp.certfile',
@@ -692,10 +739,18 @@ def _valdate_ip(self, proposal):
692739
or containerized setups for example).""")
693740
)
694741

695-
port = Integer(8888, config=True,
742+
port = Integer(DEFAULT_NOTEBOOK_PORT, config=True,
696743
help=_("The port the notebook server will listen on.")
697744
)
698745

746+
sock = Unicode(u'', config=True,
747+
help=_("The UNIX socket the notebook server will listen on.")
748+
)
749+
750+
sock_umask = Unicode(u'0600', config=True,
751+
help=_("The UNIX socket umask to set on creation (default: 0600).")
752+
)
753+
699754
port_retries = Integer(50, config=True,
700755
help=_("The number of additional ports to try if the specified port is not available.")
701756
)
@@ -1400,6 +1455,27 @@ def init_webapp(self):
14001455
self.log.critical(_("\t$ python -m notebook.auth password"))
14011456
sys.exit(1)
14021457

1458+
# Socket options validation.
1459+
if self.sock:
1460+
if self.port != DEFAULT_NOTEBOOK_PORT:
1461+
self.log.critical(
1462+
_('Options --port and --sock are mutually exclusive. Aborting.'),
1463+
)
1464+
sys.exit(1)
1465+
1466+
if self.open_browser:
1467+
# If we're bound to a UNIX socket, we can't reliably connect from a browser.
1468+
self.log.critical(
1469+
_('Options --open-browser and --sock are mutually exclusive. Aborting.'),
1470+
)
1471+
sys.exit(1)
1472+
1473+
if sys.platform.startswith('win'):
1474+
self.log.critical(
1475+
_('Option --sock is not supported on Windows, but got value of %s. Aborting.' % self.sock),
1476+
)
1477+
sys.exit(1)
1478+
14031479
self.web_app = NotebookWebApplication(
14041480
self, self.kernel_manager, self.contents_manager,
14051481
self.session_manager, self.kernel_spec_manager,
@@ -1436,6 +1512,32 @@ def init_webapp(self):
14361512
max_body_size=self.max_body_size,
14371513
max_buffer_size=self.max_buffer_size)
14381514

1515+
success = self._bind_http_server()
1516+
if not success:
1517+
self.log.critical(_('ERROR: the notebook server could not be started because '
1518+
'no available port could be found.'))
1519+
self.exit(1)
1520+
1521+
def _bind_http_server(self):
1522+
return self._bind_http_server_unix() if self.sock else self._bind_http_server_tcp()
1523+
1524+
def _bind_http_server_unix(self):
1525+
try:
1526+
sock = bind_unix_socket(self.sock, mode=int(self.sock_umask.encode(), 8))
1527+
self.http_server.add_socket(sock)
1528+
except socket.error as e:
1529+
if e.errno == errno.EADDRINUSE:
1530+
self.log.info(_('The socket %s is already in use.') % self.sock)
1531+
return False
1532+
elif e.errno in (errno.EACCES, getattr(errno, 'WSAEACCES', errno.EACCES)):
1533+
self.log.warning(_("Permission to listen on sock %s denied") % self.sock)
1534+
return False
1535+
else:
1536+
raise
1537+
else:
1538+
return True
1539+
1540+
def _bind_http_server_tcp(self):
14391541
success = None
14401542
for port in random_ports(self.port, self.port_retries+1):
14411543
try:
@@ -1453,39 +1555,45 @@ def init_webapp(self):
14531555
self.port = port
14541556
success = True
14551557
break
1456-
if not success:
1457-
self.log.critical(_('ERROR: the notebook server could not be started because '
1458-
'no available port could be found.'))
1459-
self.exit(1)
1558+
return success
1559+
1560+
def _concat_token(self, url):
1561+
token = self.token if self._token_generated else '...'
1562+
return url_concat(url, {'token': token})
14601563

14611564
@property
14621565
def display_url(self):
14631566
if self.custom_display_url:
14641567
url = self.custom_display_url
14651568
if not url.endswith('/'):
14661569
url += '/'
1570+
elif self.sock:
1571+
url = self._unix_sock_url()
14671572
else:
14681573
if self.ip in ('', '0.0.0.0'):
14691574
ip = "%s" % socket.gethostname()
14701575
else:
14711576
ip = self.ip
1472-
url = self._url(ip)
1473-
if self.token:
1474-
# Don't log full token if it came from config
1475-
token = self.token if self._token_generated else '...'
1476-
url = (url_concat(url, {'token': token})
1477-
+ '\n or '
1478-
+ url_concat(self._url('127.0.0.1'), {'token': token}))
1577+
url = self._tcp_url(ip)
1578+
if self.token and not self.sock:
1579+
url = self._concat_token(url)
1580+
url += '\n or %s' % self._concat_token(self._tcp_url('127.0.0.1'))
14791581
return url
14801582

14811583
@property
14821584
def connection_url(self):
1483-
ip = self.ip if self.ip else 'localhost'
1484-
return self._url(ip)
1585+
if self.sock:
1586+
return self._unix_sock_url()
1587+
else:
1588+
ip = self.ip if self.ip else 'localhost'
1589+
return self._tcp_url(ip)
14851590

1486-
def _url(self, ip):
1591+
def _unix_sock_url(self, token=None):
1592+
return '%s%s' % (urlencode_unix_socket(self.sock), self.base_url)
1593+
1594+
def _tcp_url(self, ip, port=None):
14871595
proto = 'https' if self.certfile else 'http'
1488-
return "%s://%s:%i%s" % (proto, ip, self.port, self.base_url)
1596+
return "%s://%s:%i%s" % (proto, ip, port or self.port, self.base_url)
14891597

14901598
def init_terminals(self):
14911599
if not self.terminals_enabled:
@@ -1713,6 +1821,7 @@ def server_info(self):
17131821
return {'url': self.connection_url,
17141822
'hostname': self.ip if self.ip else 'localhost',
17151823
'port': self.port,
1824+
'sock': self.sock,
17161825
'secure': bool(self.certfile),
17171826
'base_url': self.base_url,
17181827
'token': self.token,
@@ -1833,19 +1942,31 @@ def start(self):
18331942
self.write_server_info_file()
18341943
self.write_browser_open_file()
18351944

1836-
if self.open_browser or self.file_to_run:
1945+
if (self.open_browser or self.file_to_run) and not self.sock:
18371946
self.launch_browser()
18381947

18391948
if self.token and self._token_generated:
18401949
# log full URL with generated token, so there's a copy/pasteable link
18411950
# with auth info.
1842-
self.log.critical('\n'.join([
1843-
'\n',
1844-
'To access the notebook, open this file in a browser:',
1845-
' %s' % urljoin('file:', pathname2url(self.browser_open_file)),
1846-
'Or copy and paste one of these URLs:',
1847-
' %s' % self.display_url,
1848-
]))
1951+
if self.sock:
1952+
self.log.critical('\n'.join([
1953+
'\n',
1954+
'Notebook is listening on %s' % self.display_url,
1955+
'',
1956+
(
1957+
'UNIX sockets are not browser-connectable, but you can tunnel to '
1958+
'the instance via e.g.`ssh -L 8888:%s -N user@this_host` and then '
1959+
'opening e.g. %s in a browser.'
1960+
) % (self.sock, self._concat_token(self._tcp_url('localhost', 8888)))
1961+
]))
1962+
else:
1963+
self.log.critical('\n'.join([
1964+
'\n',
1965+
'To access the notebook, open this file in a browser:',
1966+
' %s' % urljoin('file:', pathname2url(self.browser_open_file)),
1967+
'Or copy and paste one of these URLs:',
1968+
' %s' % self.display_url,
1969+
]))
18491970

18501971
self.io_loop = ioloop.IOLoop.current()
18511972
if sys.platform.startswith('win'):

0 commit comments

Comments
 (0)