labhubapp.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
  1. #!/usr/bin/env python
  2. import asyncio
  3. import os
  4. from urllib.parse import urlparse
  5. from traitlets import (
  6. Any,
  7. Bool,
  8. Bytes,
  9. Integer,
  10. Unicode,
  11. CUnicode,
  12. default,
  13. observe,
  14. validate,
  15. TraitError,
  16. )
  17. from jupyterhub.singleuser import (
  18. dedent, exponential_backoff, make_ssl_context, url_path_join, _check_version, _exclude_home,
  19. ChoiceLoader, HubOAuth, JupyterHubLoginHandler, JupyterHubLogoutHandler
  20. )
  21. from tornado.httpclient import AsyncHTTPClient
  22. from tornado.httpclient import HTTPRequest
  23. from .labapp import LabApp, aliases as lab_aliases, flags as lab_flags
  24. from ._version import __version__
  25. # register new hub related command-line aliases
  26. aliases = dict(lab_flags)
  27. aliases.update(
  28. {
  29. 'user': 'SingleUserNotebookApp.user',
  30. 'group': 'SingleUserNotebookApp.group',
  31. 'cookie-name': 'HubAuth.cookie_name',
  32. 'hub-prefix': 'SingleUserNotebookApp.hub_prefix',
  33. 'hub-host': 'SingleUserNotebookApp.hub_host',
  34. 'hub-api-url': 'SingleUserNotebookApp.hub_api_url',
  35. 'base-url': 'SingleUserNotebookApp.base_url',
  36. }
  37. )
  38. flags = dict(lab_flags)
  39. flags.update(
  40. {
  41. 'disable-user-config': (
  42. {'SingleUserNotebookApp': {'disable_user_config': True}},
  43. "Disable user-controlled configuration of the notebook server.",
  44. )
  45. }
  46. )
  47. # FIXME: Use from jupyterhub.mixins import make_singleuser_app when available
  48. class SingleUserNotebookApp(LabApp):
  49. """A Subclass of the regular LabApp that is aware of the parent multiuser context."""
  50. description = dedent(
  51. """
  52. Single-user server for JupyterHub. Extends the Jupyter Notebook server.
  53. Meant to be invoked by JupyterHub Spawners, and not directly.
  54. """
  55. )
  56. examples = ""
  57. subcommands = {}
  58. version = __version__
  59. classes = LabApp.classes + [HubOAuth]
  60. # disable single-user app's localhost checking
  61. allow_remote_access = True
  62. # don't store cookie secrets
  63. cookie_secret_file = ''
  64. # always generate a new cookie secret on launch
  65. # ensures that each spawn clears any cookies from previous session,
  66. # triggering OAuth again
  67. cookie_secret = Bytes()
  68. def _cookie_secret_default(self):
  69. return os.urandom(32)
  70. user = CUnicode().tag(config=True)
  71. group = CUnicode().tag(config=True)
  72. @default('user')
  73. def _default_user(self):
  74. return os.environ.get('JUPYTERHUB_USER') or ''
  75. @default('group')
  76. def _default_group(self):
  77. return os.environ.get('JUPYTERHUB_GROUP') or ''
  78. @observe('user')
  79. def _user_changed(self, change):
  80. self.log.name = change.new
  81. hub_host = Unicode().tag(config=True)
  82. hub_prefix = Unicode('/hub/').tag(config=True)
  83. @default('keyfile')
  84. def _keyfile_default(self):
  85. return os.environ.get('JUPYTERHUB_SSL_KEYFILE') or ''
  86. @default('certfile')
  87. def _certfile_default(self):
  88. return os.environ.get('JUPYTERHUB_SSL_CERTFILE') or ''
  89. @default('client_ca')
  90. def _client_ca_default(self):
  91. return os.environ.get('JUPYTERHUB_SSL_CLIENT_CA') or ''
  92. @default('hub_prefix')
  93. def _hub_prefix_default(self):
  94. base_url = os.environ.get('JUPYTERHUB_BASE_URL') or '/'
  95. return base_url + 'hub/'
  96. hub_api_url = Unicode().tag(config=True)
  97. @default('hub_api_url')
  98. def _hub_api_url_default(self):
  99. return os.environ.get('JUPYTERHUB_API_URL') or 'http://127.0.0.1:8081/hub/api'
  100. # defaults for some configurables that may come from service env variables:
  101. @default('base_url')
  102. def _base_url_default(self):
  103. return os.environ.get('JUPYTERHUB_SERVICE_PREFIX') or '/'
  104. # Note: this may be removed if notebook module is >= 5.0.0b1
  105. @validate('base_url')
  106. def _validate_base_url(self, proposal):
  107. """ensure base_url starts and ends with /"""
  108. value = proposal.value
  109. if not value.startswith('/'):
  110. value = '/' + value
  111. if not value.endswith('/'):
  112. value = value + '/'
  113. return value
  114. @default('port')
  115. def _port_default(self):
  116. if os.environ.get('JUPYTERHUB_SERVICE_URL'):
  117. url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
  118. if url.port:
  119. return url.port
  120. elif url.scheme == 'http':
  121. return 80
  122. elif url.scheme == 'https':
  123. return 443
  124. return 8888
  125. @default('ip')
  126. def _ip_default(self):
  127. if os.environ.get('JUPYTERHUB_SERVICE_URL'):
  128. url = urlparse(os.environ['JUPYTERHUB_SERVICE_URL'])
  129. if url.hostname:
  130. return url.hostname
  131. return '127.0.0.1'
  132. aliases = aliases
  133. flags = flags
  134. # disble some single-user configurables
  135. token = ''
  136. open_browser = False
  137. quit_button = False
  138. trust_xheaders = True
  139. login_handler_class = JupyterHubLoginHandler
  140. logout_handler_class = JupyterHubLogoutHandler
  141. port_retries = (
  142. 0 # disable port-retries, since the Spawner will tell us what port to use
  143. )
  144. disable_user_config = Bool(
  145. False,
  146. help="""Disable user configuration of single-user server.
  147. Prevents user-writable files that normally configure the single-user server
  148. from being loaded, ensuring admins have full control of configuration.
  149. """,
  150. ).tag(config=True)
  151. @validate('notebook_dir')
  152. def _notebook_dir_validate(self, proposal):
  153. value = os.path.expanduser(proposal['value'])
  154. # Strip any trailing slashes
  155. # *except* if it's root
  156. _, path = os.path.splitdrive(value)
  157. if path == os.sep:
  158. return value
  159. value = value.rstrip(os.sep)
  160. if not os.path.isabs(value):
  161. # If we receive a non-absolute path, make it absolute.
  162. value = os.path.abspath(value)
  163. if not os.path.isdir(value):
  164. raise TraitError("No such notebook dir: %r" % value)
  165. return value
  166. @default('log_datefmt')
  167. def _log_datefmt_default(self):
  168. """Exclude date from default date format"""
  169. return "%Y-%m-%d %H:%M:%S"
  170. @default('log_format')
  171. def _log_format_default(self):
  172. """override default log format to include time"""
  173. return "%(color)s[%(levelname)1.1s %(asctime)s.%(msecs).03d %(name)s %(module)s:%(lineno)d]%(end_color)s %(message)s"
  174. def _confirm_exit(self):
  175. # disable the exit confirmation for background notebook processes
  176. self.io_loop.add_callback_from_signal(self.io_loop.stop)
  177. def migrate_config(self):
  178. if self.disable_user_config:
  179. # disable config-migration when user config is disabled
  180. return
  181. else:
  182. super(SingleUserNotebookApp, self).migrate_config()
  183. @property
  184. def config_file_paths(self):
  185. path = super(SingleUserNotebookApp, self).config_file_paths
  186. if self.disable_user_config:
  187. # filter out user-writable config dirs if user config is disabled
  188. path = list(_exclude_home(path))
  189. return path
  190. @property
  191. def nbextensions_path(self):
  192. path = super(SingleUserNotebookApp, self).nbextensions_path
  193. if self.disable_user_config:
  194. path = list(_exclude_home(path))
  195. return path
  196. @validate('static_custom_path')
  197. def _validate_static_custom_path(self, proposal):
  198. path = proposal['value']
  199. if self.disable_user_config:
  200. path = list(_exclude_home(path))
  201. return path
  202. # create dynamic default http client,
  203. # configured with any relevant ssl config
  204. hub_http_client = Any()
  205. @default('hub_http_client')
  206. def _default_client(self):
  207. ssl_context = make_ssl_context(
  208. self.keyfile, self.certfile, cafile=self.client_ca
  209. )
  210. AsyncHTTPClient.configure(None, defaults={"ssl_options": ssl_context})
  211. return AsyncHTTPClient()
  212. async def check_hub_version(self):
  213. """Test a connection to my Hub
  214. - exit if I can't connect at all
  215. - check version and warn on sufficient mismatch
  216. """
  217. client = self.hub_http_client
  218. RETRIES = 5
  219. for i in range(1, RETRIES + 1):
  220. try:
  221. resp = await client.fetch(self.hub_api_url)
  222. except Exception:
  223. self.log.exception(
  224. "Failed to connect to my Hub at %s (attempt %i/%i). Is it running?",
  225. self.hub_api_url,
  226. i,
  227. RETRIES,
  228. )
  229. await gen.sleep(min(2 ** i, 16))
  230. else:
  231. break
  232. else:
  233. self.exit(1)
  234. hub_version = resp.headers.get('X-JupyterHub-Version')
  235. _check_version(hub_version, __version__, self.log)
  236. server_name = Unicode()
  237. @default('server_name')
  238. def _server_name_default(self):
  239. return os.environ.get('JUPYTERHUB_SERVER_NAME', '')
  240. hub_activity_url = Unicode(
  241. config=True, help="URL for sending JupyterHub activity updates"
  242. )
  243. @default('hub_activity_url')
  244. def _default_activity_url(self):
  245. return os.environ.get('JUPYTERHUB_ACTIVITY_URL', '')
  246. hub_activity_interval = Integer(
  247. 300,
  248. config=True,
  249. help="""
  250. Interval (in seconds) on which to update the Hub
  251. with our latest activity.
  252. """,
  253. )
  254. @default('hub_activity_interval')
  255. def _default_activity_interval(self):
  256. env_value = os.environ.get('JUPYTERHUB_ACTIVITY_INTERVAL')
  257. if env_value:
  258. return int(env_value)
  259. else:
  260. return 300
  261. _last_activity_sent = Any(allow_none=True)
  262. async def notify_activity(self):
  263. """Notify jupyterhub of activity"""
  264. client = self.hub_http_client
  265. last_activity = self.web_app.last_activity()
  266. if not last_activity:
  267. self.log.debug("No activity to send to the Hub")
  268. return
  269. if last_activity:
  270. # protect against mixed timezone comparisons
  271. if not last_activity.tzinfo:
  272. # assume naive timestamps are utc
  273. self.log.warning("last activity is using naive timestamps")
  274. last_activity = last_activity.replace(tzinfo=timezone.utc)
  275. if self._last_activity_sent and last_activity < self._last_activity_sent:
  276. self.log.debug("No activity since %s", self._last_activity_sent)
  277. return
  278. last_activity_timestamp = isoformat(last_activity)
  279. async def notify():
  280. self.log.debug("Notifying Hub of activity %s", last_activity_timestamp)
  281. req = HTTPRequest(
  282. url=self.hub_activity_url,
  283. method='POST',
  284. headers={
  285. "Authorization": "token {}".format(self.hub_auth.api_token),
  286. "Content-Type": "application/json",
  287. },
  288. body=json.dumps(
  289. {
  290. 'servers': {
  291. self.server_name: {'last_activity': last_activity_timestamp}
  292. },
  293. 'last_activity': last_activity_timestamp,
  294. }
  295. ),
  296. )
  297. try:
  298. await client.fetch(req)
  299. except Exception:
  300. self.log.exception("Error notifying Hub of activity")
  301. return False
  302. else:
  303. return True
  304. await exponential_backoff(
  305. notify,
  306. fail_message="Failed to notify Hub of activity",
  307. start_wait=1,
  308. max_wait=15,
  309. timeout=60,
  310. )
  311. self._last_activity_sent = last_activity
  312. async def keep_activity_updated(self):
  313. if not self.hub_activity_url or not self.hub_activity_interval:
  314. self.log.warning("Activity events disabled")
  315. return
  316. self.log.info(
  317. "Updating Hub with activity every %s seconds", self.hub_activity_interval
  318. )
  319. while True:
  320. try:
  321. await self.notify_activity()
  322. except Exception as e:
  323. self.log.exception("Error notifying Hub of activity")
  324. # add 20% jitter to the interval to avoid alignment
  325. # of lots of requests from user servers
  326. t = self.hub_activity_interval * (1 + 0.2 * (random.random() - 0.5))
  327. await asyncio.sleep(t)
  328. def initialize(self, argv=None):
  329. # disable trash by default
  330. # this can be re-enabled by config
  331. self.config.FileContentsManager.delete_to_trash = False
  332. return super().initialize(argv)
  333. def start(self):
  334. self.log.info("Starting jupyterhub-singleuser server version %s", __version__)
  335. # start by hitting Hub to check version
  336. ioloop.IOLoop.current().run_sync(self.check_hub_version)
  337. ioloop.IOLoop.current().add_callback(self.keep_activity_updated)
  338. super(SingleUserNotebookApp, self).start()
  339. def init_hub_auth(self):
  340. api_token = None
  341. if os.getenv('JPY_API_TOKEN'):
  342. # Deprecated env variable (as of 0.7.2)
  343. api_token = os.environ['JPY_API_TOKEN']
  344. if os.getenv('JUPYTERHUB_API_TOKEN'):
  345. api_token = os.environ['JUPYTERHUB_API_TOKEN']
  346. if not api_token:
  347. self.exit(
  348. "JUPYTERHUB_API_TOKEN env is required to run jupyterhub-singleuser. Did you launch it manually?"
  349. )
  350. self.hub_auth = HubOAuth(
  351. parent=self,
  352. api_token=api_token,
  353. api_url=self.hub_api_url,
  354. hub_prefix=self.hub_prefix,
  355. base_url=self.base_url,
  356. keyfile=self.keyfile,
  357. certfile=self.certfile,
  358. client_ca=self.client_ca,
  359. )
  360. # smoke check
  361. if not self.hub_auth.oauth_client_id:
  362. raise ValueError("Missing OAuth client ID")
  363. def init_webapp(self):
  364. # load the hub-related settings into the tornado settings dict
  365. self.init_hub_auth()
  366. s = self.tornado_settings
  367. s['log_function'] = log_request
  368. s['user'] = self.user
  369. s['group'] = self.group
  370. s['hub_prefix'] = self.hub_prefix
  371. s['hub_host'] = self.hub_host
  372. s['hub_auth'] = self.hub_auth
  373. csp_report_uri = s['csp_report_uri'] = self.hub_host + url_path_join(
  374. self.hub_prefix, 'security/csp-report'
  375. )
  376. headers = s.setdefault('headers', {})
  377. headers['X-JupyterHub-Version'] = __version__
  378. # set CSP header directly to workaround bugs in jupyter/notebook 5.0
  379. headers.setdefault(
  380. 'Content-Security-Policy',
  381. ';'.join(["frame-ancestors 'self'", "report-uri " + csp_report_uri]),
  382. )
  383. super(SingleUserNotebookApp, self).init_webapp()
  384. # add OAuth callback
  385. self.web_app.add_handlers(
  386. r".*$",
  387. [(urlparse(self.hub_auth.oauth_redirect_uri).path, OAuthCallbackHandler)],
  388. )
  389. # apply X-JupyterHub-Version to *all* request handlers (even redirects)
  390. self.patch_default_headers()
  391. self.patch_templates()
  392. def patch_default_headers(self):
  393. if hasattr(RequestHandler, '_orig_set_default_headers'):
  394. return
  395. RequestHandler._orig_set_default_headers = RequestHandler.set_default_headers
  396. def set_jupyterhub_header(self):
  397. self._orig_set_default_headers()
  398. self.set_header('X-JupyterHub-Version', __version__)
  399. RequestHandler.set_default_headers = set_jupyterhub_header
  400. def patch_templates(self):
  401. """Patch page templates to add Hub-related buttons"""
  402. self.jinja_template_vars['logo_url'] = self.hub_host + url_path_join(
  403. self.hub_prefix, 'logo'
  404. )
  405. self.jinja_template_vars['hub_host'] = self.hub_host
  406. self.jinja_template_vars['hub_prefix'] = self.hub_prefix
  407. env = self.web_app.settings['jinja2_env']
  408. env.globals['hub_control_panel_url'] = self.hub_host + url_path_join(
  409. self.hub_prefix, 'home'
  410. )
  411. # patch jinja env loading to modify page template
  412. def get_page(name):
  413. if name == 'page.html':
  414. return page_template
  415. orig_loader = env.loader
  416. env.loader = ChoiceLoader([FunctionLoader(get_page), orig_loader])
  417. def main(argv=None):
  418. return SingleUserNotebookApp.launch_instance(argv)
  419. if __name__ == "__main__":
  420. main()