CAT-SOOP is a flexible, programmable learning management system based on the Python programming language. https://catsoop.mit.edu
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 

606 lines
19 KiB

  1. #!/usr/bin/env python3
  2. # This file is part of CAT-SOOP
  3. # Copyright (c) 2011-2020 by The CAT-SOOP Developers <catsoop-dev@mit.edu>
  4. #
  5. # This program is free software: you can redistribute it and/or modify it under
  6. # the terms of the GNU Affero General Public License as published by the Free
  7. # Software Foundation, either version 3 of the License, or (at your option) any
  8. # later version.
  9. #
  10. # This program is distributed in the hope that it will be useful, but WITHOUT
  11. # ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
  12. # FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
  13. # details.
  14. #
  15. # You should have received a copy of the GNU Affero General Public License
  16. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  17. import os
  18. import sys
  19. import math
  20. import getpass
  21. import hashlib
  22. def style(txt, sty):
  23. return sty + txt + "\x1B[0m"
  24. def OKAY(txt):
  25. return style(txt, "\x1B[1;32m") # bold green
  26. def WARNING(txt):
  27. return style(txt, "\x1B[1;31m") # bold red
  28. def ERROR(txt):
  29. return style(txt, "\x1B[1;31m") # bold red
  30. def DIR(txt):
  31. return style(txt, "\x1B[1;33m") # bold yellow
  32. def FILE(txt):
  33. return style(txt, "\x1B[1;35m") # bold magenta
  34. def QUESTION(txt):
  35. return style(txt, "\x1B[1;36m") # bold cyan
  36. def ask(prompt, default="", transform=lambda x: x, check_ok=lambda x: None):
  37. out = None
  38. while (not out) or (not check_ok(out)):
  39. defstr = ("\n[default: %s]" % default) if default else ""
  40. try:
  41. out = input("%s%s\n> " % (prompt, defstr)).strip()
  42. except EOFError:
  43. print()
  44. print(ERROR("Caught EOF. Exiting."))
  45. sys.exit(1)
  46. except KeyboardInterrupt:
  47. print()
  48. out = None
  49. continue
  50. if out == "" and default:
  51. out = default
  52. out = transform(out)
  53. check_res = check_ok(out)
  54. if check_res is None:
  55. break
  56. else:
  57. print(ERROR("ERROR: %s" % check_res))
  58. out = None
  59. print()
  60. return out
  61. def yesno(question, default="Y"):
  62. res = ask(
  63. question,
  64. default,
  65. lambda x: x.lower(),
  66. lambda x: None
  67. if x in {"y", "n", "yes", "no"}
  68. else WARNING("Please answer Yes or No"),
  69. )
  70. if res.startswith("y"):
  71. return True
  72. else:
  73. return False
  74. def password(prompt):
  75. out = None
  76. while not out:
  77. try:
  78. out = getpass.getpass(prompt)
  79. except EOFError:
  80. print()
  81. print(ERROR("Caught EOF. Exiting."))
  82. sys.exit(1)
  83. except KeyboardInterrupt:
  84. print()
  85. out = None
  86. continue
  87. return out
  88. def is_catsoop_installation(x):
  89. # this isn't really a great check, but it's likely okay
  90. if not os.path.isdir(x):
  91. return ERROR("No such directory:") + " " + DIR(x)
  92. elif not os.path.exists(os.path.join(x, "base_context.py")):
  93. return DIR(x) + " " + ERROR("does not seem to contain a CAT-SOOP installation.")
  94. cs_logo = r"""
  95. \
  96. / /\__/\
  97. \__=( o_O )=
  98. (__________)
  99. |_ |_ |_ |_"""
  100. default_config_location = os.environ.get(
  101. "XDG_CONFIG_HOME", os.path.expanduser(os.path.join("~", ".config"))
  102. )
  103. default_storage_location = os.environ.get(
  104. "XDG_DATA_HOME", os.path.expanduser(os.path.join("~", ".local", "share"))
  105. )
  106. def main():
  107. # print welcome message
  108. print(cs_logo)
  109. print("Welcome to the CAT-SOOP setup wizard.")
  110. print("Answer the questions below to get started.")
  111. print("To accept the default values, hit enter.")
  112. print("To exit, hit Crtl+d.")
  113. print()
  114. def _server_transform(x):
  115. x = x.strip().lower().replace(" ", "")
  116. if x in {"1", "localcopy", "local"}:
  117. return False
  118. elif x in {"2", "productioninstance", "production"}:
  119. return True
  120. is_production = ask(
  121. QUESTION("Are you setting up a production CAT-SOOP instance, or a local copy?")
  122. + "\n\n1. Local Copy\n2. Production Instance",
  123. default="1",
  124. transform=_server_transform,
  125. check_ok=lambda x: None if x is not None else ERROR("Invalid entry: %s" % x),
  126. )
  127. if is_production:
  128. configure_production()
  129. else:
  130. configure_local()
  131. # -----------------------------------------------------------------------------
  132. def configure_local():
  133. scripts_dir = os.path.abspath(os.path.dirname(__file__))
  134. base_dir = os.path.abspath(os.path.join(scripts_dir, ".."))
  135. if is_catsoop_installation(base_dir) is not None:
  136. print(ERROR("This does not appear to be a CAT-SOOP source tree. Exiting."))
  137. sys.exit(1)
  138. print("Setting up for CAT-SOOP using installation at %s" % DIR(base_dir))
  139. cs_fs_root = base_dir
  140. config_loc = os.path.abspath(
  141. os.path.join(default_config_location, "catsoop", "config.py")
  142. )
  143. if os.path.isfile(config_loc):
  144. res = yesno(
  145. (
  146. "CAT-SOOP configuration at %s already exists.\n"
  147. "Continuing will overwrite it.\n" % FILE(config_loc)
  148. )
  149. + QUESTION("Do you wish to continue?"),
  150. default="N",
  151. )
  152. if not res:
  153. print("Okay. Exiting.")
  154. sys.exit(1)
  155. # determine cs_data_root and logging info (encryption, etc)
  156. default_log_dir = os.path.abspath(os.path.join(default_storage_location, "catsoop"))
  157. cs_data_root = ask(
  158. QUESTION("Where should CAT-SOOP store its logs?")
  159. + "\n(this directory will be created if it does not exist)",
  160. transform=lambda x: os.path.abspath(os.path.expanduser(x)),
  161. default=default_log_dir,
  162. )
  163. # Authentication
  164. print(
  165. 'Some courses set up local copies to use "dummy" authentication that always logs you in with a particular username.'
  166. )
  167. cs_dummy_username = ask(
  168. QUESTION(
  169. 'For courses that use "dummy" authentication, what username should be used?'
  170. ),
  171. default="",
  172. check_ok=lambda x: None if x else WARNING("Please enter a username."),
  173. )
  174. # write config file
  175. config_file_content = """cs_data_root = %r
  176. cs_dummy_username = %r
  177. """ % (
  178. cs_data_root,
  179. cs_dummy_username,
  180. )
  181. while True:
  182. config_loc = ask(
  183. QUESTION("Where should this configuration be saved?"), default=config_loc
  184. )
  185. requested_path = os.path.realpath(config_loc)
  186. split_data_path = os.path.realpath(os.path.join(cs_data_root, "courses")).split(
  187. os.sep
  188. )
  189. conflict = False
  190. ancestor = ""
  191. for d in split_data_path:
  192. ancestor = os.path.join(ancestor, d)
  193. if requested_path == ancestor:
  194. conflict = True
  195. if os.path.isdir(config_loc) or conflict:
  196. proposed_file = os.path.join(config_loc, "config.py")
  197. if yesno(
  198. DIR(config_loc)
  199. + " is a directory. "
  200. + QUESTION("OK to save the configuration as ")
  201. + FILE(proposed_file)
  202. + QUESTION("?")
  203. ):
  204. config_loc = proposed_file
  205. break
  206. else:
  207. break
  208. if yesno(
  209. "This configuration will be written to "
  210. + FILE(config_loc)
  211. + ". "
  212. + QUESTION("OK?")
  213. ):
  214. os.makedirs(os.path.join(cs_data_root, "courses"), exist_ok=True)
  215. os.makedirs(os.path.dirname(config_loc), exist_ok=True)
  216. with open(config_loc, "w") as f:
  217. f.write(config_file_content)
  218. _enc_salt_file = os.path.join(os.path.dirname(config_loc), "encryption_salt")
  219. _enc_hash_file = os.path.join(
  220. os.path.dirname(config_loc), "encryption_passphrase_hash"
  221. )
  222. try:
  223. os.unlink(_enc_salt_file)
  224. except:
  225. pass
  226. try:
  227. os.unlink(_enc_hash_file)
  228. except:
  229. pass
  230. print()
  231. print("Configuration written to " + FILE(config_loc))
  232. print(
  233. "You can check that this configuration information by opening this file in a text editor."
  234. )
  235. print(cs_logo)
  236. print(
  237. OKAY("Setup is complete.")
  238. + " You can now start CAT-SOOP by running:\n catsoop start"
  239. )
  240. else:
  241. print(WARNING("Configuration not written. Exiting."))
  242. # -----------------------------------------------------------------------------
  243. def configure_production():
  244. scripts_dir = os.path.abspath(os.path.dirname(__file__))
  245. base_dir = os.path.abspath(os.path.join(scripts_dir, ".."))
  246. if is_catsoop_installation(base_dir) is not None:
  247. print(ERROR("This does not appear to be a CAT-SOOP source tree. Exiting."))
  248. sys.exit(1)
  249. print("Setting up for CAT-SOOP at %s" % DIR(base_dir))
  250. cs_fs_root = base_dir
  251. config_loc = os.path.abspath(
  252. os.path.join(default_config_location, "catsoop", "config.py")
  253. )
  254. if os.path.isfile(config_loc):
  255. res = yesno(
  256. (
  257. "CAT-SOOP configuration at %s already exists.\n"
  258. "Continuing will overwrite it.\n" % FILE(config_loc)
  259. )
  260. + QUESTION("Do you wish to continue?"),
  261. default="N",
  262. )
  263. if not res:
  264. print("Okay. Exiting.")
  265. sys.exit(1)
  266. # determine cs_data_root and logging info (encryption, etc)
  267. default_log_dir = os.path.abspath(os.path.join(default_storage_location, "catsoop"))
  268. cs_data_root = ask(
  269. QUESTION("Where should CAT-SOOP store its logs?")
  270. + "\n(this directory will be created if it does not exist)",
  271. transform=lambda x: os.path.abspath(os.path.expanduser(x)),
  272. default=default_log_dir,
  273. )
  274. print()
  275. print("By default, CAT-SOOP logs are not encrypted.")
  276. print(
  277. "Encryption can improve the privacy of people using CAT-SOOP by making"
  278. " the logs difficult to read for anyone without a particular passphrase."
  279. " Encrypted logs also mitigate the risks associated with backing up"
  280. " CAT-SOOP logs to servers you don't control. Encryption comes with the"
  281. " downside that encrypted logs are not human readable, and that reading"
  282. " and writing encrypted logs is slower."
  283. )
  284. should_encrypt = yesno(
  285. "Since CAT-SOOP logs can store personally identifiable "
  286. "student information, you are strongly encouraged to "
  287. "encrypt the logs if you are running CAT-SOOP on a "
  288. "machine where logs are not already encrypted through "
  289. "some other means.\n" + QUESTION("Should CAT-SOOP encrypt its logs?"),
  290. default="N",
  291. )
  292. if should_encrypt:
  293. is_restore = yesno(
  294. QUESTION(
  295. "Will this instance use an encryption password/salt from a previous installation?"
  296. ),
  297. default="N",
  298. )
  299. if is_restore:
  300. restore_salt_str = input(
  301. "Please enter the salt used for the encrypted logs: "
  302. )
  303. cs_log_encryption_salt = bytes.fromhex(restore_salt_str)
  304. restore_passphrase_hash_str = input(
  305. "Please enter the password hash used for the encrypted logs: "
  306. )
  307. restore_passphrase_hash = bytes.fromhex(restore_passphrase_hash_str)
  308. should_encrypt = True
  309. # choose encryption passphrase
  310. print(
  311. "Files are encrypted using a passphrase of your choosing. You will "
  312. "need to enter this passphrase whenever you start the CAT-SOOP "
  313. "server."
  314. )
  315. if is_restore:
  316. print("Please verify that your encryption passphrase is valid.")
  317. while True:
  318. cs_log_encryption_passphrase = password(
  319. "Enter %sencryption passphrase: " % ("" if is_restore else "an ")
  320. )
  321. if is_restore:
  322. passphrase_hash = hashlib.pbkdf2_hmac(
  323. "sha512",
  324. cs_log_encryption_passphrase.encode("utf8"),
  325. cs_log_encryption_salt,
  326. 100000,
  327. )
  328. if restore_passphrase_hash != passphrase_hash:
  329. print(
  330. WARNING("Passphrase is not valid for this backup; try again.")
  331. )
  332. print()
  333. else:
  334. break
  335. else:
  336. cs_log_encryption_passphrase_2 = password(
  337. "Confirm encryption passphrase: "
  338. )
  339. if cs_log_encryption_passphrase != cs_log_encryption_passphrase_2:
  340. print(WARNING("Passphrases do not match; try again."))
  341. print()
  342. else:
  343. break
  344. if not is_restore:
  345. cs_log_encryption_salt = os.urandom(32)
  346. cs_log_encryption_salt_printable = cs_log_encryption_salt.hex()
  347. cs_log_encryption_passphrase_hash = hashlib.pbkdf2_hmac(
  348. "sha512",
  349. cs_log_encryption_passphrase.encode("utf8"),
  350. cs_log_encryption_salt,
  351. 100000,
  352. )
  353. cs_log_encryption_passphrase_hash_printable = (
  354. cs_log_encryption_passphrase_hash.hex()
  355. )
  356. print()
  357. print("By default, CAT-SOOP logs are not compressed.")
  358. print(
  359. "Compression can save disk space, with the downside that reading and"
  360. " writing compressed logs is slower."
  361. )
  362. should_compress = yesno(
  363. QUESTION("Should CAT-SOOP compress its logs?"),
  364. default="Y" if should_encrypt else "N",
  365. )
  366. # Web Stuff
  367. cs_url_root = ask(
  368. QUESTION("What is the root public-facing URL associated with this instance?"),
  369. default="http://localhost:6010",
  370. transform=lambda x: x.rstrip("/"),
  371. )
  372. cs_checker_websocket = ask(
  373. QUESTION(
  374. "What is the public-facing URL associated with the checker's websocket connection?"
  375. ),
  376. default="ws://localhost:6011",
  377. transform=lambda x: x.rstrip("/"),
  378. )
  379. ncpus = os.cpu_count() or 1
  380. guess_proc_min = math.ceil(ncpus / 2)
  381. guess_proc_max = max(guess_proc_min, math.floor(ncpus * 3 / 4))
  382. guess_nchecks = math.floor(max((ncpus - guess_proc_max) / 2, 1))
  383. def _transform_int(x):
  384. try:
  385. return int(x)
  386. except:
  387. return x
  388. def _check_int(x):
  389. return (
  390. None
  391. if isinstance(x, int) and x > 0
  392. else WARNING("Please enter a positive integer.")
  393. )
  394. cs_wsgi_server_min_processes = ask(
  395. QUESTION(
  396. "What is the minimum number of processes catsoop should use for the web server?"
  397. ),
  398. transform=_transform_int,
  399. check_ok=_check_int,
  400. default=guess_proc_min,
  401. )
  402. cs_wsgi_server_max_processes = ask(
  403. QUESTION(
  404. "What is the maximum number of processes catsoop should use for the web server?"
  405. ),
  406. transform=_transform_int,
  407. check_ok=_check_int,
  408. default=guess_proc_max,
  409. )
  410. cs_checker_parallel_checks = ask(
  411. QUESTION("How many submissions should be checked in parallel?"),
  412. transform=_transform_int,
  413. check_ok=_check_int,
  414. default=guess_nchecks,
  415. )
  416. # write config file
  417. config_file_content = """cs_data_root = %r
  418. cs_log_compression = %r
  419. cs_log_encryption = %r
  420. cs_url_root = %r
  421. cs_checker_websocket = %r
  422. cs_wsgi_server = "uwsgi"
  423. cs_wsgi_server_min_processes = %d
  424. cs_wsgi_server_max_processes = %d
  425. cs_checker_parallel_checks = %d
  426. """ % (
  427. cs_data_root,
  428. should_compress,
  429. should_encrypt,
  430. cs_url_root,
  431. cs_checker_websocket,
  432. cs_wsgi_server_min_processes,
  433. cs_wsgi_server_max_processes,
  434. cs_checker_parallel_checks,
  435. )
  436. while True:
  437. config_loc = ask(
  438. QUESTION("Where should this configuration be saved?"), default=config_loc
  439. )
  440. requested_path = os.path.realpath(config_loc)
  441. split_data_path = os.path.realpath(os.path.join(cs_data_root, "courses")).split(
  442. os.sep
  443. )
  444. conflict = False
  445. ancestor = ""
  446. for d in split_data_path:
  447. ancestor = os.path.join(ancestor, d)
  448. if requested_path == ancestor:
  449. conflict = True
  450. if os.path.isdir(config_loc) or conflict:
  451. proposed_file = os.path.join(config_loc, "config.py")
  452. if yesno(
  453. DIR(config_loc)
  454. + " is a directory. "
  455. + QUESTION("OK to save the configuration as ")
  456. + FILE(proposed_file)
  457. + QUESTION("?")
  458. ):
  459. config_loc = proposed_file
  460. break
  461. else:
  462. break
  463. if yesno(
  464. "This configuration will be written to "
  465. + FILE(config_loc)
  466. + ". "
  467. + QUESTION("OK?")
  468. ):
  469. os.makedirs(os.path.join(cs_data_root, "courses"), exist_ok=True)
  470. os.makedirs(os.path.dirname(config_loc), exist_ok=True)
  471. with open(config_loc, "w") as f:
  472. f.write(config_file_content)
  473. _enc_salt_file = os.path.join(os.path.dirname(config_loc), "encryption_salt")
  474. _enc_hash_file = os.path.join(
  475. os.path.dirname(config_loc), "encryption_passphrase_hash"
  476. )
  477. if should_encrypt:
  478. with open(_enc_salt_file, "wb") as f:
  479. f.write(cs_log_encryption_salt)
  480. with open(_enc_hash_file, "wb") as f:
  481. f.write(cs_log_encryption_passphrase_hash)
  482. else:
  483. try:
  484. os.unlink(_enc_salt_file)
  485. except:
  486. pass
  487. try:
  488. os.unlink(_enc_hash_file)
  489. except:
  490. pass
  491. print()
  492. print("Configuration written to " + FILE(config_loc))
  493. print(
  494. "You can check that this configuration information by opening this file in a text editor."
  495. )
  496. print(cs_logo)
  497. print(
  498. OKAY("Setup is complete.")
  499. + " You can now start CAT-SOOP by running:\n catsoop start"
  500. )
  501. print()
  502. if should_encrypt and not is_restore:
  503. print(
  504. WARNING(
  505. "Please save the following two pieces of information, which are necessary in case you need another CAT-SOOP instance to read logs encrypted by this instance."
  506. )
  507. )
  508. print()
  509. print("Encryption salt:", cs_log_encryption_salt_printable)
  510. print(
  511. "Encryption passphrase hash:",
  512. cs_log_encryption_passphrase_hash_printable,
  513. )
  514. else:
  515. print(WARNING("Configuration not written. Exiting."))
  516. # -----------------------------------------------------------------------------
  517. if __name__ == "__main__":
  518. main()