Skip to content

Commit a9ed339

Browse files
committed
Add UNIX socket support to notebook server.
1 parent 7de8df4 commit a9ed339

File tree

8 files changed

+305
-45
lines changed

8 files changed

+305
-45
lines changed

notebook/__init__.py

Lines changed: 2 additions & 0 deletions
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

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@
3232
import notebook
3333
from notebook._tz import utcnow
3434
from notebook.i18n import combine_translations
35-
from notebook.utils import is_hidden, url_path_join, url_is_absolute, url_escape
35+
from notebook.utils import is_hidden, url_path_join, url_is_absolute, url_escape, urldecode_unix_socket_path
3636
from notebook.services.security import csp_report_uri
3737

3838
#-----------------------------------------------------------------------------
@@ -471,13 +471,22 @@ def check_host(self):
471471
if host.startswith('[') and host.endswith(']'):
472472
host = host[1:-1]
473473

474-
try:
475-
addr = ipaddress.ip_address(host)
476-
except ValueError:
477-
# Not an IP address: check against hostnames
478-
allow = host in self.settings.get('local_hostnames', ['localhost'])
474+
if not PY3:
475+
# ip_address only accepts unicode on Python 2
476+
host = host.decode('utf8', 'replace')
477+
478+
# UNIX socket handling
479+
check_host = urldecode_unix_socket_path(host)
480+
if check_host.startswith('/') and os.path.exists(check_host):
481+
allow = True
479482
else:
480-
allow = addr.is_loopback
483+
try:
484+
addr = ipaddress.ip_address(host)
485+
except ValueError:
486+
# Not an IP address: check against hostnames
487+
allow = host in self.settings.get('local_hostnames', ['localhost'])
488+
else:
489+
allow = addr.is_loopback
481490

482491
if not allow:
483492
self.log.warning(

notebook/notebookapp.py

Lines changed: 158 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -66,8 +66,11 @@
6666
from tornado import web
6767
from tornado.httputil import url_concat
6868
from tornado.log import LogFormatter, app_log, access_log, gen_log
69+
if not sys.platform.startswith('win'):
70+
from tornado.netutil import bind_unix_socket
6971

7072
from notebook import (
73+
DEFAULT_NOTEBOOK_PORT,
7174
DEFAULT_STATIC_FILES_PATH,
7275
DEFAULT_TEMPLATE_PATH_LIST,
7376
__version__,
@@ -107,7 +110,17 @@
107110
from notebook._sysinfo import get_sys_info
108111

109112
from ._tz import utcnow, utcfromtimestamp
110-
from .utils import url_path_join, check_pid, url_escape, urljoin, pathname2url, run_sync
113+
from .utils import (
114+
check_pid,
115+
pathname2url,
116+
run_sync,
117+
url_escape,
118+
url_path_join,
119+
urldecode_unix_socket_path,
120+
urlencode_unix_socket,
121+
urlencode_unix_socket_path,
122+
urljoin,
123+
)
111124

112125
# Check if we can use async kernel management
113126
try:
@@ -218,7 +231,7 @@ def init_settings(self, jupyter_app, kernel_manager, contents_manager,
218231
warnings.warn(_("The `ignore_minified_js` flag is deprecated and will be removed in Notebook 6.0"), DeprecationWarning)
219232

220233
now = utcnow()
221-
234+
222235
root_dir = contents_manager.root_dir
223236
home = py3compat.str_to_unicode(os.path.expanduser('~'), encoding=sys.getfilesystemencoding())
224237
if root_dir.startswith(home + os.path.sep):
@@ -403,6 +416,7 @@ def start(self):
403416
set_password(config_file=self.config_file)
404417
self.log.info("Wrote hashed password to %s" % self.config_file)
405418

419+
406420
def shutdown_server(server_info, timeout=5, log=None):
407421
"""Shutdown a notebook server in a separate process.
408422
@@ -415,14 +429,39 @@ def shutdown_server(server_info, timeout=5, log=None):
415429
Returns True if the server was stopped by any means, False if stopping it
416430
failed (on Windows).
417431
"""
418-
from tornado.httpclient import HTTPClient, HTTPRequest
432+
from tornado import gen
433+
from tornado.httpclient import AsyncHTTPClient, HTTPClient, HTTPRequest
434+
from tornado.netutil import bind_unix_socket, Resolver
419435
url = server_info['url']
420436
pid = server_info['pid']
437+
resolver = None
438+
439+
# UNIX Socket handling.
440+
if url.startswith('http+unix://'):
441+
# This library doesn't understand our URI form, but it's just HTTP.
442+
url = url.replace('http+unix://', 'http://')
443+
444+
class UnixSocketResolver(Resolver):
445+
def initialize(self, resolver):
446+
self.resolver = resolver
447+
448+
def close(self):
449+
self.resolver.close()
450+
451+
@gen.coroutine
452+
def resolve(self, host, port, *args, **kwargs):
453+
raise gen.Return([
454+
(socket.AF_UNIX, urldecode_unix_socket_path(host))
455+
])
456+
457+
resolver = UnixSocketResolver(resolver=Resolver())
458+
421459
req = HTTPRequest(url + 'api/shutdown', method='POST', body=b'', headers={
422460
'Authorization': 'token ' + server_info['token']
423461
})
424462
if log: log.debug("POST request to %sapi/shutdown", url)
425-
HTTPClient().fetch(req)
463+
AsyncHTTPClient.configure(None, resolver=resolver)
464+
HTTPClient(AsyncHTTPClient).fetch(req)
426465

427466
# Poll to see if it shut down.
428467
for _ in range(timeout*10):
@@ -453,13 +492,20 @@ class NbserverStopApp(JupyterApp):
453492
version = __version__
454493
description="Stop currently running notebook server for a given port"
455494

456-
port = Integer(8888, config=True,
457-
help="Port of the server to be killed. Default 8888")
495+
port = Integer(DEFAULT_NOTEBOOK_PORT, config=True,
496+
help="Port of the server to be killed. Default %s" % DEFAULT_NOTEBOOK_PORT)
497+
498+
sock = Unicode(u'', config=True,
499+
help="UNIX socket of the server to be killed.")
458500

459501
def parse_command_line(self, argv=None):
460502
super(NbserverStopApp, self).parse_command_line(argv)
461503
if self.extra_args:
462-
self.port=int(self.extra_args[0])
504+
try:
505+
self.port = int(self.extra_args[0])
506+
except ValueError:
507+
# self.extra_args[0] was not an int, so it must be a string (unix socket).
508+
self.sock = self.extra_args[0]
463509

464510
def shutdown_server(self, server):
465511
return shutdown_server(server, log=self.log)
@@ -469,16 +515,16 @@ def start(self):
469515
if not servers:
470516
self.exit("There are no running servers")
471517
for server in servers:
472-
if server['port'] == self.port:
473-
print("Shutting down server on port", self.port, "...")
518+
if server.get('sock') == self.sock or server['port'] == self.port:
519+
print("Shutting down server on %s..." % self.sock or self.port)
474520
if not self.shutdown_server(server):
475521
sys.exit("Could not stop server")
476522
return
477523
else:
478524
print("There is currently no server running on port {}".format(self.port), file=sys.stderr)
479-
print("Ports currently in use:", file=sys.stderr)
525+
print("Ports/sockets currently in use:", file=sys.stderr)
480526
for server in servers:
481-
print(" - {}".format(server['port']), file=sys.stderr)
527+
print(" - {}".format(server.get('sock', server['port'])), file=sys.stderr)
482528
self.exit(1)
483529

484530

@@ -558,6 +604,8 @@ def start(self):
558604
'ip': 'NotebookApp.ip',
559605
'port': 'NotebookApp.port',
560606
'port-retries': 'NotebookApp.port_retries',
607+
'sock': 'NotebookApp.sock',
608+
'sock-umask': 'NotebookApp.sock_umask',
561609
'transport': 'KernelManager.transport',
562610
'keyfile': 'NotebookApp.keyfile',
563611
'certfile': 'NotebookApp.certfile',
@@ -715,10 +763,18 @@ def _valdate_ip(self, proposal):
715763
or containerized setups for example).""")
716764
)
717765

718-
port = Integer(8888, config=True,
766+
port = Integer(DEFAULT_NOTEBOOK_PORT, config=True,
719767
help=_("The port the notebook server will listen on.")
720768
)
721769

770+
sock = Unicode(u'', config=True,
771+
help=_("The UNIX socket the notebook server will listen on.")
772+
)
773+
774+
sock_umask = Unicode(u'0600', config=True,
775+
help=_("The UNIX socket umask to set on creation (default: 0600).")
776+
)
777+
722778
port_retries = Integer(50, config=True,
723779
help=_("The number of additional ports to try if the specified port is not available.")
724780
)
@@ -1469,6 +1525,27 @@ def init_webapp(self):
14691525
self.log.critical(_("\t$ python -m notebook.auth password"))
14701526
sys.exit(1)
14711527

1528+
# Socket options validation.
1529+
if self.sock:
1530+
if self.port != DEFAULT_NOTEBOOK_PORT:
1531+
self.log.critical(
1532+
_('Options --port and --sock are mutually exclusive. Aborting.'),
1533+
)
1534+
sys.exit(1)
1535+
1536+
if self.open_browser:
1537+
# If we're bound to a UNIX socket, we can't reliably connect from a browser.
1538+
self.log.critical(
1539+
_('Options --open-browser and --sock are mutually exclusive. Aborting.'),
1540+
)
1541+
sys.exit(1)
1542+
1543+
if sys.platform.startswith('win'):
1544+
self.log.critical(
1545+
_('Option --sock is not supported on Windows, but got value of %s. Aborting.' % self.sock),
1546+
)
1547+
sys.exit(1)
1548+
14721549
self.web_app = NotebookWebApplication(
14731550
self, self.kernel_manager, self.contents_manager,
14741551
self.session_manager, self.kernel_spec_manager,
@@ -1505,6 +1582,32 @@ def init_webapp(self):
15051582
max_body_size=self.max_body_size,
15061583
max_buffer_size=self.max_buffer_size)
15071584

1585+
success = self._bind_http_server()
1586+
if not success:
1587+
self.log.critical(_('ERROR: the notebook server could not be started because '
1588+
'no available port could be found.'))
1589+
self.exit(1)
1590+
1591+
def _bind_http_server(self):
1592+
return self._bind_http_server_unix() if self.sock else self._bind_http_server_tcp()
1593+
1594+
def _bind_http_server_unix(self):
1595+
try:
1596+
sock = bind_unix_socket(self.sock, mode=int(self.sock_umask.encode(), 8))
1597+
self.http_server.add_socket(sock)
1598+
except socket.error as e:
1599+
if e.errno == errno.EADDRINUSE:
1600+
self.log.info(_('The socket %s is already in use.') % self.sock)
1601+
return False
1602+
elif e.errno in (errno.EACCES, getattr(errno, 'WSAEACCES', errno.EACCES)):
1603+
self.log.warning(_("Permission to listen on sock %s denied") % self.sock)
1604+
return False
1605+
else:
1606+
raise
1607+
else:
1608+
return True
1609+
1610+
def _bind_http_server_tcp(self):
15081611
success = None
15091612
for port in random_ports(self.port, self.port_retries+1):
15101613
try:
@@ -1533,35 +1636,45 @@ def init_webapp(self):
15331636
self.log.critical(_('ERROR: the notebook server could not be started because '
15341637
'port %i is not available.') % port)
15351638
self.exit(1)
1536-
1639+
return success
1640+
1641+
def _concat_token(self, url):
1642+
token = self.token if self._token_generated else '...'
1643+
return url_concat(url, {'token': token})
1644+
15371645
@property
15381646
def display_url(self):
15391647
if self.custom_display_url:
15401648
url = self.custom_display_url
15411649
if not url.endswith('/'):
15421650
url += '/'
1651+
elif self.sock:
1652+
url = self._unix_sock_url()
15431653
else:
15441654
if self.ip in ('', '0.0.0.0'):
15451655
ip = "%s" % socket.gethostname()
15461656
else:
15471657
ip = self.ip
1548-
url = self._url(ip)
1549-
if self.token:
1550-
# Don't log full token if it came from config
1551-
token = self.token if self._token_generated else '...'
1552-
url = (url_concat(url, {'token': token})
1553-
+ '\n or '
1554-
+ url_concat(self._url('127.0.0.1'), {'token': token}))
1658+
url = self._tcp_url(ip)
1659+
if self.token and not self.sock:
1660+
url = self._concat_token(url)
1661+
url += '\n or %s' % self._concat_token(self._tcp_url('127.0.0.1'))
15551662
return url
15561663

15571664
@property
15581665
def connection_url(self):
1559-
ip = self.ip if self.ip else 'localhost'
1560-
return self._url(ip)
1666+
if self.sock:
1667+
return self._unix_sock_url()
1668+
else:
1669+
ip = self.ip if self.ip else 'localhost'
1670+
return self._tcp_url(ip)
15611671

1562-
def _url(self, ip):
1672+
def _unix_sock_url(self, token=None):
1673+
return '%s%s' % (urlencode_unix_socket(self.sock), self.base_url)
1674+
1675+
def _tcp_url(self, ip, port=None):
15631676
proto = 'https' if self.certfile else 'http'
1564-
return "%s://%s:%i%s" % (proto, ip, self.port, self.base_url)
1677+
return "%s://%s:%i%s" % (proto, ip, port or self.port, self.base_url)
15651678

15661679
def init_terminals(self):
15671680
if not self.terminals_enabled:
@@ -1825,6 +1938,7 @@ def server_info(self):
18251938
return {'url': self.connection_url,
18261939
'hostname': self.ip if self.ip else 'localhost',
18271940
'port': self.port,
1941+
'sock': self.sock,
18281942
'secure': bool(self.certfile),
18291943
'base_url': self.base_url,
18301944
'token': self.token,
@@ -1954,19 +2068,31 @@ def start(self):
19542068
self.write_server_info_file()
19552069
self.write_browser_open_file()
19562070

1957-
if self.open_browser or self.file_to_run:
2071+
if (self.open_browser or self.file_to_run) and not self.sock:
19582072
self.launch_browser()
19592073

19602074
if self.token and self._token_generated:
19612075
# log full URL with generated token, so there's a copy/pasteable link
19622076
# with auth info.
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-
]))
2077+
if self.sock:
2078+
self.log.critical('\n'.join([
2079+
'\n',
2080+
'Notebook is listening on %s' % self.display_url,
2081+
'',
2082+
(
2083+
'UNIX sockets are not browser-connectable, but you can tunnel to '
2084+
'the instance via e.g.`ssh -L 8888:%s -N user@this_host` and then '
2085+
'opening e.g. %s in a browser.'
2086+
) % (self.sock, self._concat_token(self._tcp_url('localhost', 8888)))
2087+
]))
2088+
else:
2089+
self.log.critical('\n'.join([
2090+
'\n',
2091+
'To access the notebook, open this file in a browser:',
2092+
' %s' % urljoin('file:', pathname2url(self.browser_open_file)),
2093+
'Or copy and paste one of these URLs:',
2094+
' %s' % self.display_url,
2095+
]))
19702096

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

0 commit comments

Comments
 (0)