colloq 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (224) hide show
  1. colloq/__init__.py +12 -0
  2. colloq/__main__.py +315 -0
  3. colloq/_app/.colloq-dist.json +66 -0
  4. colloq/_app/LICENSE +21 -0
  5. colloq/_app/THIRD_PARTY_NOTICES.md +106 -0
  6. colloq/_app/cli/colloq.mjs +2517 -0
  7. colloq/_app/cli/launch.mjs +1572 -0
  8. colloq/_app/cli/package.json +5 -0
  9. colloq/_app/cli/public-url-lease.mjs +198 -0
  10. colloq/_app/kernel/Dockerfile +93 -0
  11. colloq/_app/kernel/environments/base-gpu.txt +21 -0
  12. colloq/_app/kernel/environments/base.txt +12 -0
  13. colloq/_app/kernel/environments/cv.txt +15 -0
  14. colloq/_app/kernel/environments/gpu.txt +17 -0
  15. colloq/_app/kernel/environments/kaggle-base.txt +20 -0
  16. colloq/_app/kernel/environments/pvz-geo.txt +6 -0
  17. colloq/_app/kernel/requirements.txt +18 -0
  18. colloq/_app/package.json +20 -0
  19. colloq/_app/scripts/backup-local.sh +165 -0
  20. colloq/_app/scripts/backup.sh +34 -0
  21. colloq/_app/scripts/cluster.sh +492 -0
  22. colloq/_app/scripts/dns.sh +251 -0
  23. colloq/_app/scripts/host.sh +1514 -0
  24. colloq/_app/scripts/lib.sh +62 -0
  25. colloq/_app/scripts/restore.sh +338 -0
  26. colloq/_app/scripts/runtime-backup.py +373 -0
  27. colloq/_app/scripts/state-lock.py +85 -0
  28. colloq/_app/server/assets/fonts/HSESans-Black.otf +0 -0
  29. colloq/_app/server/assets/fonts/HSESans-Bold.otf +0 -0
  30. colloq/_app/server/assets/fonts/HSESans-Regular.otf +0 -0
  31. colloq/_app/server/assets/fonts/JetBrainsMono-Medium.ttf +0 -0
  32. colloq/_app/server/assets/fonts/JetBrainsMono-OFL.txt +93 -0
  33. colloq/_app/server/dist/local-cleanup.js +15987 -0
  34. colloq/_app/server/dist/runtime-smoke.js +17026 -0
  35. colloq/_app/server/dist/server.js +54333 -0
  36. colloq/_app/server/package.json +6 -0
  37. colloq/_app/web/dist/assets/AdminScreen-C-cg0GlJ.css +1 -0
  38. colloq/_app/web/dist/assets/AdminScreen-C-cg0GlJ.css.br +0 -0
  39. colloq/_app/web/dist/assets/AdminScreen-C-cg0GlJ.css.gz +0 -0
  40. colloq/_app/web/dist/assets/AdminScreen-fu4cB6Ra.js +105 -0
  41. colloq/_app/web/dist/assets/AdminScreen-fu4cB6Ra.js.br +0 -0
  42. colloq/_app/web/dist/assets/AdminScreen-fu4cB6Ra.js.gz +0 -0
  43. colloq/_app/web/dist/assets/App-CMXNqOjp.css +1 -0
  44. colloq/_app/web/dist/assets/App-CMXNqOjp.css.br +0 -0
  45. colloq/_app/web/dist/assets/App-CMXNqOjp.css.gz +0 -0
  46. colloq/_app/web/dist/assets/App-DvCPzIRO.js +26 -0
  47. colloq/_app/web/dist/assets/App-DvCPzIRO.js.br +0 -0
  48. colloq/_app/web/dist/assets/App-DvCPzIRO.js.gz +0 -0
  49. colloq/_app/web/dist/assets/CellOutputs-Dl-AZL45.js +42 -0
  50. colloq/_app/web/dist/assets/CellOutputs-Dl-AZL45.js.br +0 -0
  51. colloq/_app/web/dist/assets/CellOutputs-Dl-AZL45.js.gz +0 -0
  52. colloq/_app/web/dist/assets/CommandPalette-DhBGho7_.js +3 -0
  53. colloq/_app/web/dist/assets/CommandPalette-DhBGho7_.js.br +0 -0
  54. colloq/_app/web/dist/assets/CommandPalette-DhBGho7_.js.gz +0 -0
  55. colloq/_app/web/dist/assets/CompetitionsScreen-Vu3RpHmf.js +3 -0
  56. colloq/_app/web/dist/assets/CompetitionsScreen-Vu3RpHmf.js.br +0 -0
  57. colloq/_app/web/dist/assets/CompetitionsScreen-Vu3RpHmf.js.gz +0 -0
  58. colloq/_app/web/dist/assets/ConsoleView-B2uyYvFh.js +1 -0
  59. colloq/_app/web/dist/assets/ConsoleView-B2uyYvFh.js.br +0 -0
  60. colloq/_app/web/dist/assets/ConsoleView-B2uyYvFh.js.gz +0 -0
  61. colloq/_app/web/dist/assets/ConsoleView-DLbp0Jul.css +1 -0
  62. colloq/_app/web/dist/assets/DependencyBundleCard-B392XMVZ.js +4 -0
  63. colloq/_app/web/dist/assets/DependencyBundleCard-B392XMVZ.js.br +0 -0
  64. colloq/_app/web/dist/assets/DependencyBundleCard-B392XMVZ.js.gz +0 -0
  65. colloq/_app/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  66. colloq/_app/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  67. colloq/_app/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  68. colloq/_app/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  69. colloq/_app/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  70. colloq/_app/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  71. colloq/_app/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  72. colloq/_app/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  73. colloq/_app/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  74. colloq/_app/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  75. colloq/_app/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  76. colloq/_app/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  77. colloq/_app/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  78. colloq/_app/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  79. colloq/_app/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  80. colloq/_app/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  81. colloq/_app/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  82. colloq/_app/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  83. colloq/_app/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  84. colloq/_app/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  85. colloq/_app/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  86. colloq/_app/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  87. colloq/_app/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  88. colloq/_app/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  89. colloq/_app/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  90. colloq/_app/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  91. colloq/_app/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  92. colloq/_app/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  93. colloq/_app/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  94. colloq/_app/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  95. colloq/_app/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  96. colloq/_app/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  97. colloq/_app/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  98. colloq/_app/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  99. colloq/_app/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  100. colloq/_app/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  101. colloq/_app/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  102. colloq/_app/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  103. colloq/_app/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  104. colloq/_app/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  105. colloq/_app/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  106. colloq/_app/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  107. colloq/_app/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  108. colloq/_app/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  109. colloq/_app/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  110. colloq/_app/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  111. colloq/_app/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  112. colloq/_app/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  113. colloq/_app/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  114. colloq/_app/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  115. colloq/_app/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  116. colloq/_app/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  117. colloq/_app/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  118. colloq/_app/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  119. colloq/_app/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  120. colloq/_app/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  121. colloq/_app/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  122. colloq/_app/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  123. colloq/_app/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  124. colloq/_app/web/dist/assets/PultWindow-B3cc8537.css +1 -0
  125. colloq/_app/web/dist/assets/PultWindow-B3cc8537.css.br +0 -0
  126. colloq/_app/web/dist/assets/PultWindow-B3cc8537.css.gz +0 -0
  127. colloq/_app/web/dist/assets/PultWindow-DPOUhSqt.js +8 -0
  128. colloq/_app/web/dist/assets/PultWindow-DPOUhSqt.js.br +0 -0
  129. colloq/_app/web/dist/assets/PultWindow-DPOUhSqt.js.gz +0 -0
  130. colloq/_app/web/dist/assets/ReaderScreen-CV_0VAvW.js +9 -0
  131. colloq/_app/web/dist/assets/ReaderScreen-CV_0VAvW.js.br +0 -0
  132. colloq/_app/web/dist/assets/ReaderScreen-CV_0VAvW.js.gz +0 -0
  133. colloq/_app/web/dist/assets/SessionScreen-BrcB6CLH.js +132 -0
  134. colloq/_app/web/dist/assets/SessionScreen-BrcB6CLH.js.br +0 -0
  135. colloq/_app/web/dist/assets/SessionScreen-BrcB6CLH.js.gz +0 -0
  136. colloq/_app/web/dist/assets/SessionScreen-Dek9dK3C.css +1 -0
  137. colloq/_app/web/dist/assets/SessionScreen-Dek9dK3C.css.br +0 -0
  138. colloq/_app/web/dist/assets/SessionScreen-Dek9dK3C.css.gz +0 -0
  139. colloq/_app/web/dist/assets/actions-DLPyolgo.js +1 -0
  140. colloq/_app/web/dist/assets/admin-en-BHmR7Co0.js +9 -0
  141. colloq/_app/web/dist/assets/admin-en-BHmR7Co0.js.br +0 -0
  142. colloq/_app/web/dist/assets/admin-en-BHmR7Co0.js.gz +0 -0
  143. colloq/_app/web/dist/assets/admin-ru-Dr6Z36Zi.js +11 -0
  144. colloq/_app/web/dist/assets/admin-ru-Dr6Z36Zi.js.br +0 -0
  145. colloq/_app/web/dist/assets/admin-ru-Dr6Z36Zi.js.gz +0 -0
  146. colloq/_app/web/dist/assets/codemirror-6LUh0q1l.js +32 -0
  147. colloq/_app/web/dist/assets/codemirror-6LUh0q1l.js.br +0 -0
  148. colloq/_app/web/dist/assets/codemirror-6LUh0q1l.js.gz +0 -0
  149. colloq/_app/web/dist/assets/competitions-en-zw57uAGL.js +5 -0
  150. colloq/_app/web/dist/assets/competitions-en-zw57uAGL.js.br +0 -0
  151. colloq/_app/web/dist/assets/competitions-en-zw57uAGL.js.gz +0 -0
  152. colloq/_app/web/dist/assets/competitions-ru-BBQUjAx0.js +5 -0
  153. colloq/_app/web/dist/assets/competitions-ru-BBQUjAx0.js.br +0 -0
  154. colloq/_app/web/dist/assets/competitions-ru-BBQUjAx0.js.gz +0 -0
  155. colloq/_app/web/dist/assets/editor-theme-CVYtpZDy.js +1 -0
  156. colloq/_app/web/dist/assets/editor-theme-CVYtpZDy.js.br +0 -0
  157. colloq/_app/web/dist/assets/editor-theme-CVYtpZDy.js.gz +0 -0
  158. colloq/_app/web/dist/assets/index-CwbYK31L.css +1 -0
  159. colloq/_app/web/dist/assets/index-CwbYK31L.css.br +0 -0
  160. colloq/_app/web/dist/assets/index-CwbYK31L.css.gz +0 -0
  161. colloq/_app/web/dist/assets/index-Di16xdmz.js +2 -0
  162. colloq/_app/web/dist/assets/index-Di16xdmz.js.br +0 -0
  163. colloq/_app/web/dist/assets/index-Di16xdmz.js.gz +0 -0
  164. colloq/_app/web/dist/assets/katex-CS_ezkZp.css +1 -0
  165. colloq/_app/web/dist/assets/katex-CS_ezkZp.css.br +0 -0
  166. colloq/_app/web/dist/assets/katex-CS_ezkZp.css.gz +0 -0
  167. colloq/_app/web/dist/assets/katex-DLorvT8M.js +257 -0
  168. colloq/_app/web/dist/assets/katex-DLorvT8M.js.br +0 -0
  169. colloq/_app/web/dist/assets/katex-DLorvT8M.js.gz +0 -0
  170. colloq/_app/web/dist/assets/pdf-DEp6sk-T.js +55 -0
  171. colloq/_app/web/dist/assets/pdf-DEp6sk-T.js.br +0 -0
  172. colloq/_app/web/dist/assets/pdf-DEp6sk-T.js.gz +0 -0
  173. colloq/_app/web/dist/assets/persistence.svelte-vcuCBcT5.js +1 -0
  174. colloq/_app/web/dist/assets/persistence.svelte-vcuCBcT5.js.br +0 -0
  175. colloq/_app/web/dist/assets/persistence.svelte-vcuCBcT5.js.gz +0 -0
  176. colloq/_app/web/dist/assets/reader-en-UCGE3U6V.js +3 -0
  177. colloq/_app/web/dist/assets/reader-en-UCGE3U6V.js.br +0 -0
  178. colloq/_app/web/dist/assets/reader-en-UCGE3U6V.js.gz +0 -0
  179. colloq/_app/web/dist/assets/reader-ru-CaiNj0K8.js +3 -0
  180. colloq/_app/web/dist/assets/reader-ru-CaiNj0K8.js.br +0 -0
  181. colloq/_app/web/dist/assets/reader-ru-CaiNj0K8.js.gz +0 -0
  182. colloq/_app/web/dist/assets/render-CjP3RuRT.js +155 -0
  183. colloq/_app/web/dist/assets/render-CjP3RuRT.js.br +0 -0
  184. colloq/_app/web/dist/assets/render-CjP3RuRT.js.gz +0 -0
  185. colloq/_app/web/dist/assets/render.svelte-B4_hAJmX.js +0 -0
  186. colloq/_app/web/dist/assets/render.svelte-B4_hAJmX.js.br +0 -0
  187. colloq/_app/web/dist/assets/render.svelte-B4_hAJmX.js.gz +0 -0
  188. colloq/_app/web/dist/assets/room-en-Bh8L6NcO.js +3 -0
  189. colloq/_app/web/dist/assets/room-en-Bh8L6NcO.js.br +0 -0
  190. colloq/_app/web/dist/assets/room-en-Bh8L6NcO.js.gz +0 -0
  191. colloq/_app/web/dist/assets/room-ru-Bar2GgS0.js +3 -0
  192. colloq/_app/web/dist/assets/room-ru-Bar2GgS0.js.br +0 -0
  193. colloq/_app/web/dist/assets/room-ru-Bar2GgS0.js.gz +0 -0
  194. colloq/_app/web/dist/assets/seminar-link-CRaZl9Ku.js +1 -0
  195. colloq/_app/web/dist/assets/seminar-link-CRaZl9Ku.js.br +0 -0
  196. colloq/_app/web/dist/assets/seminar-link-CRaZl9Ku.js.gz +0 -0
  197. colloq/_app/web/dist/assets/seminar-link-DoTugxxA.css +1 -0
  198. colloq/_app/web/dist/assets/seminar-link-DoTugxxA.css.br +0 -0
  199. colloq/_app/web/dist/assets/seminar-link-DoTugxxA.css.gz +0 -0
  200. colloq/_app/web/dist/assets/yjs-rjB3hdnL.js +6 -0
  201. colloq/_app/web/dist/assets/yjs-rjB3hdnL.js.br +0 -0
  202. colloq/_app/web/dist/assets/yjs-rjB3hdnL.js.gz +0 -0
  203. colloq/_app/web/dist/fonts/JetBrainsMono-OFL.txt +93 -0
  204. colloq/_app/web/dist/fonts/hse-sans-400.woff2 +0 -0
  205. colloq/_app/web/dist/fonts/hse-sans-600.woff2 +0 -0
  206. colloq/_app/web/dist/fonts/hse-sans-700.woff2 +0 -0
  207. colloq/_app/web/dist/fonts/hse-sans-900.woff2 +0 -0
  208. colloq/_app/web/dist/fonts/jetbrains-mono-cyrillic-ext.woff2 +0 -0
  209. colloq/_app/web/dist/fonts/jetbrains-mono-cyrillic.woff2 +0 -0
  210. colloq/_app/web/dist/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
  211. colloq/_app/web/dist/fonts/jetbrains-mono-latin.woff2 +0 -0
  212. colloq/_app/web/dist/index.html +268 -0
  213. colloq/_app/web/dist/og/colloq.png +0 -0
  214. colloq/_app/web/dist/pdf/pdf.worker.min.mjs +29 -0
  215. colloq/_app/web/dist/pdf/pdf.worker.min.mjs.br +0 -0
  216. colloq/_app/web/dist/pdf/pdf.worker.min.mjs.gz +0 -0
  217. colloq/_app/web/dist/plotly/plotly.min.js +3620 -0
  218. colloq/_app/web/dist/plotly/plotly.min.js.br +0 -0
  219. colloq/_app/web/dist/plotly/plotly.min.js.gz +0 -0
  220. colloq/_version.py +5 -0
  221. colloq-0.2.0.dist-info/METADATA +124 -0
  222. colloq-0.2.0.dist-info/RECORD +224 -0
  223. colloq-0.2.0.dist-info/WHEEL +4 -0
  224. colloq-0.2.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,1514 @@
1
+ #!/usr/bin/env bash
2
+ #
3
+ # Выставить семинар наружу и получить ссылку для аудитории.
4
+ #
5
+ # Главное, ради чего этот скрипт вообще существует: PUBLIC_URL. Сервер кладёт
6
+ # его в ссылку, которую вы копируете и раздаёте. Если оставить localhost, ссылка
7
+ # будет открываться только у вас, а вся аудитория получит «сайт недоступен» —
8
+ # и это выяснится ровно в тот момент, когда тридцать человек уже сидят в классе.
9
+ # Временный адрес публикуется без перезапуска приложения.
10
+ #
11
+ # Транспортов четыре:
12
+ #
13
+ # ./scripts/host.sh быстрый туннель Cloudflare, адрес
14
+ # случайный и живёт до Ctrl+C
15
+ # COLLOQ_HOSTNAME=seminar.sleep3r.ru именованный туннель Cloudflare
16
+ # COLLOQ_HOSTNAME=hse.colloq.ru свой ретранслятор (frpc → frps)
17
+ # COLLOQ_DIRECT=1 COLLOQ_HOSTNAME=… прямо с этой машины, без посредника
18
+ #
19
+ # Первые три — исходящее соединение отсюда наружу: не нужны ни белый IP, ни
20
+ # проброс портов на роутере. Четвёртый — противоположность: 80 и 443 на этой
21
+ # машине принимают студентов сами, caddy снимает TLS и отдаёт запрос colloq на
22
+ # localhost. Ему нужен настоящий публичный адрес, и он единственный, у кого на
23
+ # пути пары нет ни одного чужого узла.
24
+ #
25
+ # Почему появился четвёртый. Туннель Cloudflare всегда выходит на пограничные
26
+ # адреса Cloudflare, снять с них проксирование нельзя (запись
27
+ # *.cfargotunnel.com имеет смысл только для их прокси), а из России эти адреса
28
+ # не открываются — поэтому под colloq.ru стоит свой ретранслятор. Но и он не
29
+ # бесплатен: измерено на живой паре, двести студентов в один вечер положили
30
+ # VPS на 951 МБ памяти — ядро тридцать шесть раз убивало то frps (209 МБ), то
31
+ # caddy (186 МБ на двух сотнях сокетов), и каждое убийство рвало все туннели
32
+ # разом, а зал уходил в переподключение. Ретранслятор — общая точка отказа для
33
+ # всех имён под ним, и трафик пары не должен через него ходить, когда у машины
34
+ # есть свой публичный адрес.
35
+ #
36
+ # Что ставится: frpc (brew install frpc) для ретранслятора, cloudflared для
37
+ # Cloudflare, caddy для прямого режима — последний скрипт поставит сам. Нет
38
+ # cloudflared в PATH — colloq скачивает закреплённый выпуск со сверкой суммы
39
+ # в <состояние>/bin (cli/src/launch-cloudflared.ts); COLLOQ_CLOUDFLARED=/путь
40
+ # называет файл явно.
41
+ #
42
+ # Замок: наружу уходит только сервер, у которого ядро каждой комнаты в своём
43
+ # контейнере — так он сам отвечает полем isolation в /api/health (docker или
44
+ # broker). Ссылка — это дверь: кто её получил, тот запускает код на этой
45
+ # машине, и общее ядро за такой дверью не публикуется никаким транспортом.
46
+ #
47
+ set -euo pipefail
48
+
49
+ cd "$(dirname "$0")/.."
50
+
51
+ RED=$'\033[31m'; DIM=$'\033[2m'; BOLD=$'\033[1m'; CYAN=$'\033[36m'; OFF=$'\033[0m'
52
+ say() { printf '%s\n' "$*"; }
53
+ die() { printf '%s%s%s\n' "$RED" "$*" "$OFF" >&2; exit 1; }
54
+
55
+ # docker нужен не каждой форме, а только той, с которой скрипт говорит через
56
+ # него, — контейнеру `make up`: узнать его, пересоздать с новым PUBLIC_URL,
57
+ # достать токен установки из тома (см. «Кто именно держит порт» ниже).
58
+ #
59
+ # Здесь стоял безусловный отказ «docker is not installed», и он ронял
60
+ # k3s-машину на первой же строке: под релизом сервер и ядра живут в k3s, докера
61
+ # на арендованной VM может не быть вовсе, и `make vast-up HOST=…` оставлял
62
+ # семинар без адреса — tmux-сессия с этим скриптом умирала через секунду.
63
+ # Локальной сессии, службе и `make run` докер от этого скрипта тоже не нужен:
64
+ # ядра — забота сервера, и если они не встают, об этом скажет /api/health на
65
+ # первом шаге. Без докера просто не бывает формы «контейнер».
66
+ HAVE_DOCKER=""
67
+ if command -v docker >/dev/null 2>&1; then HAVE_DOCKER=1; fi
68
+
69
+ # Настройки ретранслятора живут в .env рядом со всем остальным. Пусто — значит
70
+ # этот инстанс им не пользуется, и остаётся Cloudflare.
71
+ #
72
+ # read_env общий для всех скриптов — scripts/lib.sh. Своя копия была в трёх
73
+ # файлах и во всех трёх вырезала пробелы внутри значений.
74
+ . ./scripts/lib.sh
75
+
76
+ # Состояние — не там, где приложение. Каталог над scripts/ — это приложение
77
+ # (web/dist, kernel/, эти скрипты), а .env, расписка занятия и .colloq.pid
78
+ # лежат в каталоге СОСТОЯНИЯ: он и есть COLLOQ_STATE_ROOT из lib.sh. В
79
+ # репозитории это один и тот же каталог, у поставленного пакета — разные, и
80
+ # все пути ниже считаются от состояния, а не от «себя».
81
+ SESSION_RECEIPT="$COLLOQ_STATE_ROOT/.colloq/local-session.json"
82
+ PIDFILE="${PIDFILE:-$COLLOQ_STATE_ROOT/.colloq.pid}"
83
+
84
+ # Чем звать расписку внешнего адреса (scripts/public-url-lease.mts).
85
+ #
86
+ # В репозитории — исходником через tsx: его правят рядом с сервером, и сборка
87
+ # между правкой и проверкой ни к чему. В дистрибутиве нет ни исходника, ни
88
+ # tsx: `node --import tsx` падает там ещё до первой строки («Cannot find
89
+ # package 'tsx'»), а вместе с ним падал и весь скрипт — локальная сессия
90
+ # оставалась без адреса на ровном месте. Поэтому scripts/pack.mts кладёт рядом
91
+ # с cli/launch.mjs собранный cli/public-url-lease.mjs, и если он на месте,
92
+ # зовём голый node. Та же развилка и по той же причине стоит в CLI —
93
+ # cli/src/commands/local.ts · launcher.
94
+ #
95
+ # Массивом, а не функцией: сторож адреса запускается в фон, и в $! должен
96
+ # оказаться номер самого node. У функции там был бы номер подоболочки, а
97
+ # cleanup убивал бы её, оставляя сторожа сиротой — он ещё три секунды продлевал
98
+ # бы аренду адреса, который мы только что отдали.
99
+ if [ -f cli/public-url-lease.mjs ]; then
100
+ LEASE=(node cli/public-url-lease.mjs)
101
+ else
102
+ LEASE=(node --import tsx scripts/public-url-lease.mts)
103
+ fi
104
+ # Супервизор занятия — тем же выбором: собранный в пакете, исходник рядом с
105
+ # репозиторием. Отсюда он нужен одним словом, cloudflared: найти или скачать
106
+ # (со сверкой суммы) и назвать путь. Правило поиска живёт там одно на всех —
107
+ # и для `colloq start --share`, и для `colloq host`, и для `make host`.
108
+ if [ -f cli/launch.mjs ]; then
109
+ LAUNCHER=(node cli/launch.mjs)
110
+ else
111
+ LAUNCHER=(node --import tsx cli/src/launch.ts)
112
+ fi
113
+
114
+ RELAY_DOMAIN="$(read_env RELAY_DOMAIN)"
115
+ RELAY_ADDR="$(read_env RELAY_ADDR)"
116
+ RELAY_PORT="$(read_env RELAY_PORT)"; RELAY_PORT="${RELAY_PORT:-7000}"
117
+ RELAY_TOKEN="$(read_env RELAY_TOKEN)"
118
+
119
+ # Между туннелями транспорт выбирается именем, а не отдельным флагом: имя под
120
+ # нашей зоной может обслужить только наш ретранслятор, а любое другое — только
121
+ # Cloudflare. Флаг здесь был бы третьим способом сказать то, что уже сказано
122
+ # адресом.
123
+ #
124
+ # А вот прямой режим именем не выражается вовсе, и это не оплошность: hse.colloq.ru
125
+ # может обслужить и ретранслятор, и эта машина — разница не в имени, а в том,
126
+ # есть ли у машины белый адрес. Спросить об этом можно только явно, поэтому у
127
+ # прямого режима есть свой выключатель: COLLOQ_DIRECT=1 (make host-direct HOST=…).
128
+ VIA="cloudflare"
129
+ if [ "${COLLOQ_DIRECT:-}" = "1" ] && [ "${COLLOQ_LOCAL_SESSION:-}" != 1 ]; then
130
+ VIA="direct"
131
+ elif [ -n "${COLLOQ_HOSTNAME:-}" ] && [ -n "$RELAY_DOMAIN" ] \
132
+ && [ "${COLLOQ_HOSTNAME%".$RELAY_DOMAIN"}" != "$COLLOQ_HOSTNAME" ]; then
133
+ VIA="relay"
134
+ fi
135
+
136
+ case "$VIA" in
137
+ relay)
138
+ command -v frpc >/dev/null 2>&1 || die \
139
+ "frpc is not installed. brew install frpc — then run this again."
140
+ [ -n "$RELAY_ADDR" ] || die "no RELAY_ADDR in .env — the address of the relay."
141
+ [ -n "$RELAY_TOKEN" ] || die "no RELAY_TOKEN in .env — the shared secret of the relay."
142
+ ;;
143
+ direct)
144
+ # Всё, что нужно проверить до первого действия. Отказ здесь дешёвый:
145
+ # ни записи в DNS, ни поставленного caddy ещё нет.
146
+ #
147
+ # Имя обязательно и обязательно полное. Прямому режиму неоткуда взять
148
+ # умолчание: у ретранслятора короткое имя достраивается до RELAY_DOMAIN,
149
+ # у быстрого туннеля имя выдаёт Cloudflare, а здесь имя — это то, на что
150
+ # выпишут сертификат, и придумать его за человека нельзя.
151
+ [ -n "${COLLOQ_HOSTNAME:-}" ] || die \
152
+ "direct mode needs a name: make host-direct HOST=hse.colloq.ru"
153
+ case "$COLLOQ_HOSTNAME" in
154
+ *.*) : ;;
155
+ *) die "the name must be a full one, with a dot: make host-direct HOST=hse.colloq.ru
156
+ Only the relay completes a short name up to RELAY_DOMAIN." ;;
157
+ esac
158
+ printf '%s' "$COLLOQ_HOSTNAME" | grep -qE '^[a-z0-9]([a-z0-9.-]*[a-z0-9])?$' || die \
159
+ "the name ${COLLOQ_HOSTNAME} has something other than a-z, digits, dots and hyphens."
160
+
161
+ # systemd — не придирка. Прямой режим оставляет после себя службу caddy,
162
+ # которая живёт дольше этого окна; без systemd оставить её было бы негде,
163
+ # и семинар умирал бы вместе с закрытым терминалом, ничего об этом не сказав.
164
+ # На ноутбуке (macOS) это и не нужно: у него нет белого адреса, ему туннель.
165
+ command -v systemctl >/dev/null 2>&1 || die \
166
+ "direct mode is for a machine with systemd and a public address (a Linux server).
167
+ There is no systemd here: from a laptop a class goes out through a tunnel —
168
+ make host HOST=<name>."
169
+
170
+ # 80 и 443 — привилегированные порты, и службу в systemd тоже ставят от root.
171
+ [ "$(id -u)" = "0" ] || die \
172
+ "direct mode installs the caddy service and takes 80 and 443 — root is needed.
173
+ Try again: sudo make host-direct HOST=${COLLOQ_HOSTNAME}"
174
+
175
+ # python3 нужен и здесь (проверка портов), и в scripts/dns.sh, который
176
+ # пишет запись.
177
+ command -v python3 >/dev/null 2>&1 || die \
178
+ "no python3 — it is what checks the ports and writes the DNS record (scripts/dns.sh)."
179
+ command -v curl >/dev/null 2>&1 || die "no curl."
180
+ [ -x ./scripts/dns.sh ] || die "no scripts/dns.sh — it is what sets the A record for the name."
181
+ ;;
182
+ *)
183
+ # Названный явно (супервизор передаёт уже найденный и сверенный) — берём
184
+ # как есть. Поставленный в PATH — тоже: быстрый путь без node. Иначе
185
+ # спрашиваем супервизор: он сверит свою копию в <состояние>/bin или
186
+ # скачает закреплённый выпуск, говоря об этом в stderr, а путь отдаст в
187
+ # stdout. Проверка суммы — там; своей копии без неё отсюда не запускаем.
188
+ CLOUDFLARED="${COLLOQ_CLOUDFLARED:-$(read_env COLLOQ_CLOUDFLARED)}"
189
+ if [ -z "$CLOUDFLARED" ]; then CLOUDFLARED="$(command -v cloudflared 2>/dev/null || true)"; fi
190
+ if [ -z "$CLOUDFLARED" ]; then
191
+ CLOUDFLARED="$("${LAUNCHER[@]}" cloudflared)" || die \
192
+ "there is no cloudflared, and colloq could not fetch it (the reason is above).
193
+ Install it yourself (brew install cloudflared) or name the file: COLLOQ_CLOUDFLARED=/path/to/cloudflared"
194
+ fi
195
+ [ -x "$CLOUDFLARED" ] || die "cloudflared at ${CLOUDFLARED} is not an executable file."
196
+ ;;
197
+ esac
198
+
199
+ # PORT нужен до старта туннеля: на него cloudflared и будет светить.
200
+ PORT="${PORT:-$(read_env PORT)}"
201
+ PORT="${PORT:-3000}"
202
+ CLUSTER="${COLLOQ_CLUSTER:-$(read_env COLLOQ_CLUSTER)}"
203
+ if [ "${COLLOQ_LOCAL_SESSION:-}" = 1 ]; then CLUSTER=0; fi
204
+ if [ "$CLUSTER" = 1 ]; then PORT=30080; fi
205
+ LOCAL="http://127.0.0.1:${PORT}"
206
+ TUNNEL_PORT="$PORT"
207
+ LOCAL_RUN_ID=""
208
+ LEASE_FILE=""
209
+ LEASE_OWNER=""
210
+ LEASE_PID=""
211
+ if [ "$CLUSTER" != 1 ] && [ "$VIA" != direct ] && { [ "${COLLOQ_LOCAL_SESSION:-}" = 1 ] || [ -f "$SESSION_RECEIPT" ]; }; then
212
+ if DISCOVERY="$("${LEASE[@]}" discover "$SESSION_RECEIPT")"; then
213
+ IFS=$'\t' read -r LOCAL_RUN_ID LEASE_FILE LOCAL PORT DATA_DIR TUNNEL_PORT <<< "$DISCOVERY"
214
+ else
215
+ discovery_code=$?
216
+ [ "$discovery_code" = 2 ] || die "Could not check the local session."
217
+ fi
218
+ fi
219
+ HEALTH_LOCAL="http://127.0.0.1:${PORT}"
220
+ # Only the local origin sees this rewrite; the public Host still routes at the relay.
221
+ # Vite retains its Host protection instead of accepting every external hostname.
222
+ #
223
+ # Раскрывается ниже как ${ORIGIN_ARGS[@]+"${ORIGIN_ARGS[@]}"}, а не голым
224
+ # "${ORIGIN_ARGS[@]}": пустой массив под set -u в bash до 4.4 — «unbound
225
+ # variable», а это штатный /bin/bash macOS (3.2). Туннель Cloudflare без
226
+ # локальной сессии (служба, `make run`, k3s) падал там, не успев стартовать.
227
+ ORIGIN_ARGS=()
228
+ if [ -n "$LOCAL_RUN_ID" ]; then ORIGIN_ARGS=(--http-host-header "127.0.0.1:${TUNNEL_PORT}"); fi
229
+
230
+ # Шаблон с иксами, а не просто имя: BSD mktemp дописывает случайный хвост
231
+ # сам, а GNU требует «XXXXXX» в шаблоне и без них падает с «too few X's».
232
+ # Скрипт живёт на обеих системах — на ноутбуке преподавателя и на арендованной
233
+ # машине, — и там, где он падал, семинар оставался без адреса.
234
+ LOG="$(mktemp -t colloq-tunnel.XXXXXX)"
235
+ TUNNEL_PID=""
236
+ # Временный слушатель 80 и 443 в прямом режиме — см. проверку портов.
237
+ LISTEN_PID=""
238
+ LISTEN_LOG=""
239
+ # Ставили ли мы PUBLIC_URL сами — см. cleanup.
240
+ TOUCHED_ENV=""
241
+ # Какой адрес мы отдали кластеру k3s (cluster.sh public-url) — его и только
242
+ # его cleanup снимает на выходе, см. там.
243
+ CLUSTER_PUBLISHED=""
244
+ # Где k3s держит данные приложения — тот же каталог состояния и то же
245
+ # умолчание, что у scripts/cluster.sh, backup.sh и restore.sh.
246
+ CLUSTER_STATE="${COLLOQ_STATE_DIR:-/var/lib/colloq}"
247
+
248
+ # Шагов у транспортов разное число, а нумерация нужна везде: человек по ней
249
+ # понимает, где скрипт застрял. Поэтому счётчик, а не вписанные руками «1/4»,
250
+ # которые в прямом режиме врали бы на два шага.
251
+ STEPS=4
252
+ if [ "$VIA" = direct ]; then STEPS=6; fi
253
+ STEP=0
254
+ step() { STEP=$((STEP + 1)); say "${BOLD}${STEP}/${STEPS}${OFF} $*"; }
255
+
256
+ cleanup() {
257
+ local code=$?
258
+ trap - EXIT INT TERM
259
+ # Уборка идёт до конца, а не до первого отказа, и закрытое окно её не
260
+ # обрывает. На арендованной машине скрипт живёт в tmux как `host.sh | tee`:
261
+ # Ctrl+C убивает и tee, и первая же строка уборки получала бы SIGPIPE —
262
+ # оболочка умерла бы, не вернув адрес; закрытая сессия (SIGHUP) рвала бы на
263
+ # полпути cluster.sh, который как раз перезапускает приложение. Всё ниже и
264
+ # так лучшее усилие с `|| true`; set -e здесь только обрывал бы на выводе.
265
+ set +e
266
+ trap '' HUP PIPE
267
+ [ -n "$LEASE_PID" ] && kill "$LEASE_PID" 2>/dev/null || true
268
+ if [ -n "$LEASE_OWNER" ]; then
269
+ "${LEASE[@]}" release "$LEASE_FILE" "$LOCAL_RUN_ID" "$LEASE_OWNER" || true
270
+ fi
271
+ [ -n "$TUNNEL_PID" ] && kill "$TUNNEL_PID" 2>/dev/null || true
272
+ # Слушатель занимает ровно те порты, которые нужны caddy. Не убрать его —
273
+ # значит уронить выдачу сертификата, причём молча.
274
+ [ -n "$LISTEN_PID" ] && kill "$LISTEN_PID" 2>/dev/null || true
275
+ [ -n "$LISTEN_LOG" ] && rm -f "$LISTEN_LOG" || true
276
+ # Прямой режим не возвращает PUBLIC_URL назад, и это не забывчивость.
277
+ # Ссылка там живёт не в этом окне, а в службе caddy: она переживает и Ctrl+C,
278
+ # и закрытый ssh. Вернуть адрес на localhost значило бы, что после выхода из
279
+ # скрипта работающий семинар начинает раздавать ссылки на localhost.
280
+ if [ "$VIA" = direct ]; then rm -f "$LOG"; exit $code; fi
281
+ # k3s. Адрес здесь живёт не в .env, а в конфиге кластера: `cluster.sh
282
+ # public-url` пишет его в <состояние>/config.env и перезапускает приложение,
283
+ # которое читает PUBLIC_URL один раз, при старте. Уборка раньше обходила
284
+ # кластер вовсе, и после Ctrl+C панель так и раздавала ссылки на туннель,
285
+ # которого больше нет, а `make vast-status` пересказывал мёртвый адрес как
286
+ # живой.
287
+ #
288
+ # Снимаем ровно то, что ставили, — как локальная сессия отпускает только
289
+ # свою аренду адреса (release с владельцем, выше): --if-current сверяет под
290
+ # замком состояния, что в кластере всё ещё наш адрес, и чужой не трогает.
291
+ # Цена — перезапуск приложения, но зал к этому моменту и так отрезан:
292
+ # туннель уже закрыт строками выше.
293
+ #
294
+ # Причину отказа показываем: «не смог» без неё отправляло бы человека гадать
295
+ # между замком состояния (идёт vast-sync), не-root и таймаутом перезапуска —
296
+ # а в tmux эти строки и есть единственный след в host.log.
297
+ if [ -n "$CLUSTER_PUBLISHED" ]; then
298
+ say "${DIM}taking ${CLUSTER_PUBLISHED} off the cluster — the app restarts, up to 3 minutes${OFF}"
299
+ local why rc
300
+ why="$(bash scripts/cluster.sh public-url "$LOCAL" --if-current "$CLUSTER_PUBLISHED" 2>&1 >/dev/null)"
301
+ rc=$?
302
+ case $rc in
303
+ 0) say "${DIM}PUBLIC_URL is back at ${LOCAL}${OFF}" ;;
304
+ 4) say "${DIM}the cluster's PUBLIC_URL is not ${CLUSTER_PUBLISHED} any more — left as it is${OFF}" ;;
305
+ *) say "${RED}could not take ${CLUSTER_PUBLISHED} off the cluster (exit ${rc}):${OFF}"
306
+ [ -z "$why" ] || printf '%s\n' "$why" | tail -3 | sed 's/^/ /'
307
+ say "${DIM}By hand, as root:${OFF} bash scripts/cluster.sh public-url ${LOCAL}"
308
+ # Кластер так и раздаёт мёртвый адрес — значит, и запись в .env должна
309
+ # его называть: по ней vast-status проверит адрес снаружи и скажет
310
+ # «не отвечает», а не «наружу не выставлен» при живых ссылках на туннель.
311
+ TOUCHED_ENV="" ;;
312
+ esac
313
+ fi
314
+ # Ссылка мертва вместе с туннелем. Оставить её в .env значит, что следующий
315
+ # `make up` без туннеля раздаст студентам адрес, который никуда не ведёт. На
316
+ # k3s .env приложение не читает, но по нему `make vast-status` узнаёт, какой
317
+ # адрес машина обслуживает, — и мёртвый там был бы той же неправдой.
318
+ #
319
+ # Только если её ставили мы: отказ на первом шаге — «инстанс нездоров»,
320
+ # «ретранслятор отказал» — не повод переписывать чужую настройку, к которой
321
+ # мы ещё не прикасались. И только если в файле всё ещё НАШ адрес: второй
322
+ # `make host`, поднятый поверх, переписал его своим, и вернуть localhost
323
+ # поверх живого чужого туннеля значило бы сломать ему ссылки.
324
+ if [ -n "$TOUCHED_ENV" ] && [ -f "$ENV_FILE" ] && [ "$(read_env PUBLIC_URL)" = "${PUBLIC:-}" ]; then
325
+ restore_public_url
326
+ # Вернуть строку в .env мало тому, кто читает её один раз, при запуске.
327
+ #
328
+ # Контейнеру: файла внутри нет вовсе, PUBLIC_URL запечён в окружение на
329
+ # `docker compose up -d app` предпоследним шагом. Без пересоздания в панели
330
+ # так и раздаются ссылки на туннель, которого больше нет, — и видно это
331
+ # только тому, кому её отправили.
332
+ #
333
+ # Службе — ничего: сервер на хосте перечитывает .env сам, не реже раза в
334
+ # две секунды (readPublicUrl в server/src/config.ts), и файл для него
335
+ # главнее окружения из EnvironmentFile. Перезапуск здесь только рвал бы
336
+ # сокеты всей комнаты — в том числе на Ctrl+C, которым туннель закрывают
337
+ # штатно.
338
+ #
339
+ # Сервер, запущенный через `make run`, тоже не трогаем: его подняли руками,
340
+ # и снимать его молча, за спиной, нельзя. Он перечитает адрес оттуда же.
341
+ #
342
+ # Кластеру — уже сказано выше: .env для него только запись, и о снятом
343
+ # адресе строкой выше отчитался cluster.sh.
344
+ case "${WHO:-}" in
345
+ container) PUBLIC_URL="$LOCAL" docker compose up -d app >/dev/null 2>&1 || true ;;
346
+ esac
347
+ [ "${WHO:-}" = cluster ] || say "${DIM}PUBLIC_URL is back at ${LOCAL}${OFF}"
348
+ fi
349
+ rm -f "$LOG"
350
+ exit $code
351
+ }
352
+ trap cleanup EXIT INT TERM
353
+
354
+ # sed -i несовместим между macOS и GNU, поэтому пишем через временный файл.
355
+ set_public_url() {
356
+ local url="$1" tmp
357
+ tmp="$(mktemp)"
358
+ if grep -qE '^PUBLIC_URL=' "$ENV_FILE" 2>/dev/null; then
359
+ grep -vE '^PUBLIC_URL=' "$ENV_FILE" > "$tmp"
360
+ else
361
+ cat "$ENV_FILE" > "$tmp" 2>/dev/null || true
362
+ fi
363
+ printf 'PUBLIC_URL=%s\n' "$url" >> "$tmp"
364
+ # Содержимое переливается в существующий .env, а не `mv` поверх него.
365
+ # Разница видна там, где скрипт запускают под sudo (прямой режим и любая
366
+ # выделенная машина): mv из /tmp сделал бы .env файлом root с правами 600,
367
+ # и следующий `make run` от преподавателя не смог бы его ни переписать, ни
368
+ # прочитать. Файл при этом всё равно готовится целиком заранее — на месте
369
+ # он не редактируется ни секунды.
370
+ cat "$tmp" > "$ENV_FILE"
371
+ rm -f "$tmp"
372
+ }
373
+ restore_public_url() { set_public_url "$LOCAL"; }
374
+
375
+ # Спрашиваем несколько резолверов: на корпоративных сетях запросы к 1.1.1.1
376
+ # режутся через раз — измерено, три подряд dig дали ответ, пусто, пусто.
377
+ #
378
+ # Имя приходит аргументом, а не из глобальной переменной: спрашивают отсюда
379
+ # двое — проверка «отвечает ли адрес снаружи» в конце и прямой режим, которому
380
+ # надо дождаться, пока новая A-запись доедет до публичных резолверов.
381
+ resolve_any() {
382
+ local name="$1" r ip
383
+ for r in 1.1.1.1 8.8.8.8 9.9.9.9; do
384
+ ip="$(dig +short +time=2 +tries=1 "$name" "@$r" 2>/dev/null | grep -E '^[0-9.]+$' | head -1 || true)"
385
+ [ -n "$ip" ] && { printf '%s' "$ip"; return 0; }
386
+ done
387
+ return 1
388
+ }
389
+
390
+ # ------------------------------------------------------------- статика наружу
391
+ #
392
+ # Три каталога, и все три — одно и то же у всех, кто пришёл на пару.
393
+ # assets/ Vite штампует хэшем содержимого (их и отдают с immutable на год),
394
+ # fonts/ и pdf/ меняются только с выкладкой. Именно они и зеркалятся на
395
+ # ретрансляторе, и именно они сжимаются заранее.
396
+ DIST_DIRS="assets fonts pdf"
397
+
398
+ # Сколько собранных файлов лежат БЕЗ сжатого соседа.
399
+ #
400
+ # Порог в килобайт — тот же, что у web/scripts/precompress.mjs: на меньшем
401
+ # обёртка формата съедает всё, что сжатие выигрывает, и .br там не бывает по
402
+ # замыслу, а не по забывчивости.
403
+ assets_uncompressed() {
404
+ local n=0 f
405
+ [ -d web/dist/assets ] || { printf 0; return 0; }
406
+ for f in web/dist/assets/*.js web/dist/assets/*.mjs web/dist/assets/*.css; do
407
+ [ -f "$f" ] || continue
408
+ [ "$(wc -c < "$f")" -lt 1024 ] && continue
409
+ [ -f "$f.br" ] || n=$((n + 1))
410
+ done
411
+ printf '%s' "$n"
412
+ }
413
+
414
+ # Архив для зеркала: ровно те каталоги, что есть, и ничего скрытого.
415
+ #
416
+ # COPYFILE_DISABLE — про macOS: без него tar кладёт рядом с каждым файлом
417
+ # ещё и `._имя` с расширенными атрибутами, и зеркало получает вдвое больше
418
+ # файлов, половина из которых ничего не значит.
419
+ assets_tar() {
420
+ local out="$1" dir dirs=""
421
+ for dir in $DIST_DIRS; do
422
+ [ -d "web/dist/$dir" ] && dirs="$dirs $dir"
423
+ done
424
+ [ -n "$dirs" ] || return 1
425
+ # shellcheck disable=SC2086
426
+ COPYFILE_DISABLE=1 tar -czf "$out" --exclude '.*' --exclude '*/.*' -C web/dist $dirs
427
+ }
428
+
429
+ # Положить статику на ретранслятор, чтобы он раздавал её сам.
430
+ #
431
+ # Без этого КАЖДЫЙ байт каждого /assets/*, /fonts/* и /pdf/* едет через этот
432
+ # ноутбук: 598 КБ сжатого на каждого пришедшего, по тому же туннелю, по
433
+ # которому в этот момент живут сокеты комнаты. Файлы одинаковые у всех, и
434
+ # ретранслятор вполне может отдать их сам (scripts/relay-assets.py).
435
+ #
436
+ # Отказ здесь — предупреждение, а не смерть: туннель работает и без зеркала,
437
+ # просто медленнее. Ретранслятор, поставленный до появления зеркала, отвечает
438
+ # на этот адрес чем угодно — про него и сказано отдельно.
439
+ upload_assets() {
440
+ local archive answer code files bytes
441
+ archive="$(mktemp -t colloq-assets.XXXXXX)"
442
+ answer="$(mktemp -t colloq-assets-out.XXXXXX)"
443
+ if ! assets_tar "$archive"; then
444
+ rm -f "$archive" "$answer"
445
+ return 0
446
+ fi
447
+ code="$(curl -s -o "$answer" -w '%{http_code}' --max-time 180 \
448
+ --upload-file "$archive" \
449
+ -H "Authorization: Bearer ${RELAY_TOKEN}" \
450
+ -H 'Content-Type: application/gzip' \
451
+ "https://${COLLOQ_HOSTNAME}/.relay/assets/${COLLOQ_HOSTNAME}" || true)"
452
+ if [ "$code" = "200" ]; then
453
+ files="$(sed -n 's/.*"files":[ ]*\([0-9]*\).*/\1/p' "$answer")"
454
+ bytes="$(sed -n 's/.*"bytes":[ ]*\([0-9]*\).*/\1/p' "$answer")"
455
+ say "${DIM} mirror on the relay: ${files:-?} files, $(( ${bytes:-0} / 1024 )) KB —${OFF}"
456
+ say "${DIM} students take the static files from it, not through this laptop${OFF}"
457
+ elif [ "$code" = "404" ] || [ "$code" = "000" ]; then
458
+ say "${DIM} the relay has no static mirror: it was installed before the mirror${OFF}"
459
+ say "${DIM} appeared. Update it: make relay-setup WHERE=root@<address>${OFF}"
460
+ say "${DIM} The class runs anyway — every static file goes through this laptop.${OFF}"
461
+ else
462
+ say "${RED} could not upload the static files to the relay (HTTP ${code})${OFF}"
463
+ head -c 200 "$answer" >&2 2>/dev/null || true
464
+ printf '\n' >&2
465
+ say "${DIM} Not fatal: the files go through the tunnel, as before.${OFF}"
466
+ fi
467
+ rm -f "$archive" "$answer"
468
+ }
469
+
470
+ # ---------------------------------------------------------------- запуск
471
+
472
+ step "checking colloq at ${LOCAL}"
473
+ #
474
+ # Ничего не поднимаем, если оно уже поднято. Это не бережливость, а исправление:
475
+ # `docker compose up -d` пересоздаёт и ядро тоже — по основному compose-файлу,
476
+ # без dev-override. Override публикует 8888 на хост и монтирует ./workspace, и
477
+ # ровно этим живёт сервер, запущенный руками. Одна такая пересборка — и ядро
478
+ # перестаёт стартовать, а терминал молчит, хотя контейнер «healthy».
479
+ #
480
+ #
481
+ # /api/health отвечает 200 только когда и база читается, и Jupyter отзывается,
482
+ # поэтому «не отвечает» здесь значит и «никого нет», и «есть, но семинар вести
483
+ # нельзя» — второе curl -sf тоже считает отказом, и правильно делает.
484
+ #
485
+ # Тело ответа не выбрасываем: в нём поле isolation, по которому ниже решается,
486
+ # можно ли вообще открывать дверь.
487
+ HEALTH_BODY=""
488
+ if HEALTH_BODY="$(curl -sf --max-time 5 "$HEALTH_LOCAL/api/health" 2>/dev/null)"; then
489
+ say "${DIM} already running — touching nothing${OFF}"
490
+ elif [ "$CLUSTER" = 1 ]; then
491
+ die "k3s application is not ready at $LOCAL. Inspect: bash scripts/cluster.sh status; bash scripts/cluster.sh logs"
492
+ elif { command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet colloq 2>/dev/null; } \
493
+ || { [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; }; then
494
+ #
495
+ # Сервер на хосте жив — службой или руками, — но здоровым себя не считает.
496
+ # Почти всегда это выключенный Docker или ни разу не собранное окружение
497
+ # ядра. Поднимать здесь `docker compose up` нельзя: он поднимет и app, и на
498
+ # порту окажется второй Colloq с другой базой поверх первого. Это и был
499
+ # «третий переход»: три разных инстанса за одно утро.
500
+ #
501
+ die "colloq at ${LOCAL} is running, but is not ready to hold a class — usually that
502
+ is Docker switched off or a kernel environment that was never built
503
+ (make env-build NAME=<environment>).
504
+ Check: curl -s ${LOCAL}/api/health"
505
+ else
506
+ #
507
+ # Ничего не поднимаем сами — и это исправление, купленное дорого.
508
+ #
509
+ # Здесь стоял `docker compose up -d`, и он поднимал ВЕСЬ стек, включая `app`.
510
+ # Достаточно было, чтобы health на секунду ответил отказом — например, потому
511
+ # что выключили ядро, — и рядом с уже работающим сервером на хосте вставал
512
+ # второй Colloq в контейнере. Оба — полноправные Yjs-авторитеты, оба пишут в
513
+ # одну базу (после перехода на bind-монты — буквально в ту же), и браузер
514
+ # попадает то в один, то в другой: тетрадь то полупустая, то с задвоенными
515
+ # ячейками, и сокет не переставая переподключается.
516
+ #
517
+ # Туннель — это про то, чтобы показать наружу уже работающий инстанс. Решать
518
+ # за человека, какой из двух способов запуска ему нужен, он не должен.
519
+ #
520
+ say "${RED} nobody answers at ${LOCAL}${OFF}"
521
+ # Совет обязан быть выполнимым на той машине, где его прочитали.
522
+ #
523
+ # Здесь стояло «make up / make run / make service-install» — три цели Makefile,
524
+ # которого у поставленного через pip colloq нет вовсе. А срабатывает эта
525
+ # строка именно на `colloq host`: на команде, которой класс получает ссылку.
526
+ # Человек читал совет, набирал make и получал «command not found» вторым
527
+ # отказом подряд. Цели остаются — но только там, где есть Makefile, и это
528
+ # видно по тому же признаку, по которому скрипт уже выбирает каталоги.
529
+ if [ "${COLLOQ_STATE_ROOT}" = "." ] || [ -f "$PWD/Makefile" ]; then
530
+ die "start the class first — colloq start (or make up for everything in
531
+ docker, make service-install on a dedicated machine), then try again."
532
+ fi
533
+ die "start the class first — colloq start — then try again."
534
+ fi
535
+
536
+ # Замок публикации — до первого действия наружу, одинаковый для всех форм.
537
+ #
538
+ # Решение автора: в интернет уходит только занятие, где ядро каждой комнаты
539
+ # сидит в своём контейнере. Кто получил ссылку, тот запускает код на этой
540
+ # машине; общее ядро за такой дверью — это чужой код рядом с тетрадями всего
541
+ # класса, и никакой транспорт это не лечит. Судим не по .env (у локального
542
+ # занятия супервизор всё равно ставит docker сам), а по ответу сервера: поле
543
+ # isolation он ставит, только когда проверка ядра прошла — демон docker или
544
+ # брокер ответили (server/src/app.ts · roomIsolation). Нет поля — значит, это
545
+ # сервер старше этой проверки или бэкенд без изоляции, и в обоих случаях
546
+ # публиковать нечего.
547
+ #
548
+ # Строгое сравнение по строке, без jq: JSON.stringify пишет без пробелов, а
549
+ # этот скрипт обходится тем, что есть на голой машине.
550
+ case "$HEALTH_BODY" in
551
+ *'"isolation":"docker"'* | *'"isolation":"broker"'*) : ;;
552
+ *) die "not publishing: the server at ${LOCAL} does not confirm that every room
553
+ runs in a container of its own (the isolation field of /api/health).
554
+ A public link lets anyone who has it run code on this machine, so a class
555
+ goes online only with room kernels in Docker, one container per room
556
+ (KERNEL_BACKEND=docker and Docker running), or behind the runtime broker.
557
+ Check: curl -s ${LOCAL}/api/health — restart or update colloq if the field is missing." ;;
558
+ esac
559
+
560
+ # Кто именно держит порт. Случаев четыре, и все четыре настоящие:
561
+ #
562
+ # служба — systemd на выделенной машине: сервер на хосте, в docker
563
+ # только ядра комнат. Так стоит арендованная машина и любая,
564
+ # где идут занятия;
565
+ # контейнер — `make up`, приложение целиком в docker;
566
+ # хост — `make run`, сервер на машине, ядро в docker. Так проект
567
+ # запускают, когда правят код, и .colloq.pid — его расписка;
568
+ # чужой — что-то другое. Туннель встанет и на него, но PUBLIC_URL ему
569
+ # никто не поправит, и об этом придётся сказать вслух.
570
+ #
571
+ # Порядок разбора — не алфавитный, а по надёжности признака. Служба спрашивается
572
+ # первой: на машине под службой рядом валяется и .colloq.pid от давнего `make
573
+ # run`, и остановленный контейнер app, — а перезапускать надо ту форму, которая
574
+ # сейчас держит порт. Расписка `make run` идёт последней ровно поэтому: pid-файл
575
+ # переживает и перезагрузку, и смену формы, и говорит о прошлом, а не о
576
+ # настоящем.
577
+ #
578
+ # Различать обязательно: ссылки на семинары строятся из PUBLIC_URL, и семинар,
579
+ # розданный со ссылкой на localhost, — это семинар, на который никто не зашёл.
580
+ WHO="other"
581
+ if [ -n "$LOCAL_RUN_ID" ]; then
582
+ WHO="local"
583
+ elif [ "$CLUSTER" = 1 ]; then
584
+ WHO="cluster"
585
+ elif command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet colloq 2>/dev/null; then
586
+ WHO="service"
587
+ elif [ -n "$HAVE_DOCKER" ] && docker compose ps app --format '{{.State}}' 2>/dev/null | grep -q running; then
588
+ WHO="container"
589
+ elif [ -f "$PIDFILE" ] && kill -0 "$(cat "$PIDFILE" 2>/dev/null)" 2>/dev/null; then
590
+ WHO="host"
591
+ fi
592
+
593
+ # ------------------------------------------------- прямой режим: без посредника
594
+ #
595
+ # Всё, что ниже, работает только при COLLOQ_DIRECT=1. Смысл режима в одной
596
+ # фразе: на пути от студента до этой машины нет никого. Ни ретранслятора,
597
+ # который на 951 МБ памяти ложился от двухсот сокетов, ни Cloudflare, чьи
598
+ # адреса из России не открываются. Цена — машине нужен настоящий публичный
599
+ # адрес и настоящие 80 и 443 на нём.
600
+
601
+ DIRECT_IP=""
602
+
603
+ # Адрес этой машины, каким его видит внешний мир.
604
+ #
605
+ # Спрашиваем несколько служб подряд: любая из них может быть недоступна именно
606
+ # из этой сети, а ошибиться адресом нельзя — запись в DNS уведёт всю аудиторию
607
+ # не туда. Cloudflare среди них нет намеренно: из России их адреса не
608
+ # открываются, и «не смог узнать свой адрес» было бы неправдой о машине.
609
+ public_ip() {
610
+ local url addr
611
+ for url in https://api.ipify.org https://checkip.amazonaws.com https://ident.me https://ipinfo.io/ip; do
612
+ addr="$(curl -s --max-time 6 "$url" 2>/dev/null | tr -d ' \r\n' || true)"
613
+ if printf '%s' "$addr" | grep -qE '^([0-9]{1,3}\.){3}[0-9]{1,3}$'; then
614
+ printf '%s' "$addr"
615
+ return 0
616
+ fi
617
+ done
618
+ return 1
619
+ }
620
+
621
+ # Висит ли этот адрес прямо на интерфейсе машины.
622
+ #
623
+ # Ответ «нет» сам по себе ничего не решает и отказом не является: у облачных
624
+ # машин (AWS, GCP) публичный адрес живёт на пограничном маршрутизаторе, на
625
+ # интерфейсе только частный, и прямой режим там работает прекрасно. Но это же
626
+ # «нет» бывает и у машины за пробросом портов, где 80 и 443 не открыты вовсе.
627
+ # Поэтому признак используется только как подсказка к вердикту проверки
628
+ # снаружи — она и есть настоящий ответ.
629
+ ip_is_local() {
630
+ local want="$1"
631
+ if command -v ip >/dev/null 2>&1; then
632
+ ip -4 -o addr show 2>/dev/null | awk '{print $4}' | cut -d/ -f1 | grep -qx "$want"
633
+ elif command -v ifconfig >/dev/null 2>&1; then
634
+ ifconfig 2>/dev/null | awk '/inet /{print $2}' | grep -qx "$want"
635
+ else
636
+ return 1
637
+ fi
638
+ }
639
+
640
+ # Временно занять 80 и 443, чтобы снаружи было к чему подключаться.
641
+ #
642
+ # Проверять «открыт ли порт», не слушая его, невозможно: закрытый фаерволом и
643
+ # никем не занятый порт выглядят снаружи одинаково. Поэтому на время проверки
644
+ # порты держит крошечный слушатель на python3 — он же доказывает, что порты
645
+ # вообще свободны и что их разрешено занимать.
646
+ listener_start() {
647
+ LISTEN_LOG="$(mktemp -t colloq-ports.XXXXXX)"
648
+ python3 - 80 443 >"$LISTEN_LOG" 2>&1 <<'PY' &
649
+ import socket, sys, threading
650
+
651
+ socks = []
652
+ for port in (int(a) for a in sys.argv[1:]):
653
+ s = socket.socket()
654
+ s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
655
+ s.bind(("", port))
656
+ s.listen(16)
657
+ socks.append(s)
658
+
659
+ # Слово печатается только после того, как оба порта заняты: ждущая сторона
660
+ # по нему и понимает, что спрашивать снаружи уже есть смысл.
661
+ print("READY", flush=True)
662
+
663
+
664
+ def serve(sock):
665
+ while True:
666
+ try:
667
+ conn, _ = sock.accept()
668
+ except OSError:
669
+ return
670
+ # Проверяющий узел снаружи смотрит только на то, состоялось ли
671
+ # соединение, но ответить всё же вежливее: так в чужих журналах не
672
+ # остаётся оборванных запросов.
673
+ try:
674
+ conn.sendall(b"HTTP/1.1 204 No Content\r\nConnection: close\r\n\r\n")
675
+ except OSError:
676
+ pass
677
+ conn.close()
678
+
679
+
680
+ for sock in socks:
681
+ threading.Thread(target=serve, args=(sock,), daemon=True).start()
682
+ threading.Event().wait()
683
+ PY
684
+ LISTEN_PID=$!
685
+ local i
686
+ for i in $(seq 1 25); do
687
+ grep -q READY "$LISTEN_LOG" 2>/dev/null && return 0
688
+ kill -0 "$LISTEN_PID" 2>/dev/null || return 1
689
+ sleep 0.2
690
+ done
691
+ return 1
692
+ }
693
+
694
+ listener_stop() {
695
+ [ -n "$LISTEN_PID" ] && kill "$LISTEN_PID" 2>/dev/null || true
696
+ LISTEN_PID=""
697
+ }
698
+
699
+ # Спросить снаружи: пускает ли эта машина на такой-то порт. Печатает
700
+ # open, closed или unknown.
701
+ #
702
+ # Спрашиваем check-host.net: он бесплатен, без ключей, отвечает из нескольких
703
+ # стран и — в отличие от большинства подобных служб — открывается из России.
704
+ # Форма ответа выверена на живых запросах, а не по памяти: пока проверка идёт,
705
+ # значение узла равно null; удавшееся соединение приходит как
706
+ # [{"address": …, "time": …}], отказ — как [{"error": "Connection timed out"}].
707
+ ask_outside() {
708
+ local addr="$1" port="$2" id res verdict i
709
+ id="$(curl -s --max-time 10 -H 'Accept: application/json' \
710
+ "https://check-host.net/check-tcp?host=${addr}:${port}&max_nodes=3" 2>/dev/null \
711
+ | python3 -c 'import json,sys
712
+ try: print(json.load(sys.stdin).get("request_id") or "")
713
+ except Exception: print("")' 2>/dev/null || true)"
714
+ [ -n "$id" ] || { printf 'unknown'; return 0; }
715
+ for i in $(seq 1 12); do
716
+ sleep 2
717
+ res="$(curl -s --max-time 10 -H 'Accept: application/json' \
718
+ "https://check-host.net/check-result/${id}" 2>/dev/null || true)"
719
+ verdict="$(printf '%s' "$res" | python3 -c '
720
+ import json, sys
721
+ try:
722
+ rows = json.load(sys.stdin)
723
+ except Exception:
724
+ rows = None
725
+ if not isinstance(rows, dict) or not rows:
726
+ print("wait")
727
+ else:
728
+ vals = list(rows.values())
729
+ done = [v for v in vals if isinstance(v, list)]
730
+ # Достаточно одного узла, которому ответили: соединение снаружи состоялось,
731
+ # значит порт открыт. А вот «закрыт» — только когда высказались все.
732
+ if any(v and isinstance(v[0], dict) and v[0].get("address") for v in done):
733
+ print("open")
734
+ elif len(done) == len(vals):
735
+ print("closed")
736
+ else:
737
+ print("wait")' 2>/dev/null || printf 'wait')"
738
+ [ "$verdict" = wait ] || { printf '%s' "$verdict"; return 0; }
739
+ done
740
+ printf 'unknown'
741
+ }
742
+
743
+ # Спросить снаружи, что отвечает по адресу на 80. Печатает code:<код>,
744
+ # closed или unknown.
745
+ #
746
+ # Отдельно от ask_outside, потому что вопрос другой и он важнее. «Порт открыт»
747
+ # — это ещё не «порт открыт у нас»: измерено прямо на этой машине, где
748
+ # публичным адресом оказался выход VPN, и на его 80 и 443 честно отвечал чужой
749
+ # веб-сервер. TCP-проверка сказала бы «открыт», имя семинара уехало бы на
750
+ # чужую машину, а сертификат не выпустился бы никогда — и понять, почему,
751
+ # было бы не по чему. Поэтому пока порты держит наш слушатель, отдающий 204,
752
+ # мы спрашиваем именно код ответа: 204 значит «это мы».
753
+ #
754
+ # Форма ответа check-host для http-проверки, выверена на живом запросе:
755
+ # [[1, 0.13, "OK", "200", "8.47.69.0"]] — успех и код в четвёртом поле.
756
+ ask_outside_http() {
757
+ local url="$1" id res verdict i
758
+ id="$(curl -s --max-time 10 -H 'Accept: application/json' \
759
+ "https://check-host.net/check-http?host=${url}&max_nodes=3" 2>/dev/null \
760
+ | python3 -c 'import json,sys
761
+ try: print(json.load(sys.stdin).get("request_id") or "")
762
+ except Exception: print("")' 2>/dev/null || true)"
763
+ [ -n "$id" ] || { printf 'unknown'; return 0; }
764
+ for i in $(seq 1 12); do
765
+ sleep 2
766
+ res="$(curl -s --max-time 10 -H 'Accept: application/json' \
767
+ "https://check-host.net/check-result/${id}" 2>/dev/null || true)"
768
+ verdict="$(printf '%s' "$res" | python3 -c '
769
+ import json, sys
770
+ try:
771
+ rows = json.load(sys.stdin)
772
+ except Exception:
773
+ rows = None
774
+ if not isinstance(rows, dict) or not rows:
775
+ print("wait")
776
+ else:
777
+ vals = list(rows.values())
778
+ done = [v for v in vals if isinstance(v, list)]
779
+ codes = set()
780
+ for v in done:
781
+ row = v[0] if v and isinstance(v[0], list) else None
782
+ if row and row[0] and len(row) > 3 and row[3]:
783
+ codes.add(str(row[3]))
784
+ if codes:
785
+ print("code:" + sorted(codes)[0])
786
+ elif len(done) == len(vals):
787
+ print("closed")
788
+ else:
789
+ print("wait")' 2>/dev/null || printf 'wait')"
790
+ [ "$verdict" = wait ] || { printf '%s' "$verdict"; return 0; }
791
+ done
792
+ printf 'unknown'
793
+ }
794
+
795
+ # ---------------------------------------------------------- шаг: порты
796
+
797
+ direct_check_ports() {
798
+ step "checking that 80 and 443 on this machine are visible from outside"
799
+ #
800
+ # Это самая важная проверка прямого режима, и поэтому она первая — до записи
801
+ # в DNS и до установки caddy.
802
+ #
803
+ # Сертификат выдаётся по HTTP-01: Let's Encrypt приходит на 80 по имени и
804
+ # ждёт ответа именно этой машины. Нет 80 — нет сертификата; нет 443 — нет
805
+ # семинара. Узнать это на выдаче сертификата значит узнать поздно и невнятно:
806
+ # caddy не падает, он молча повторяет попытки, а у зала в браузере всё это
807
+ # время «не удалось установить соединение».
808
+ #
809
+ # Случай не выдуманный. На арендованной машине vast.ai наружу открыт только
810
+ # проброшенный ssh: у предложения есть direct_port_count, но настоящих 80 и
811
+ # 443 vast не даёт вовсе — проверено. Прямой режим там невозможен, и сказать
812
+ # об этом надо словами, а не падением на сертификате через десять минут.
813
+ #
814
+ # Colloq на 80 или 443 — это тот же порт, что просит caddy, и вдвоём они на
815
+ # нём не поместятся. Сказать это здесь дешевле, чем сломать уже работающий
816
+ # инстанс перезапуском caddy, который всё равно не встанет.
817
+ case "$PORT" in
818
+ 80|443) die "colloq listens on ${PORT} — and direct mode needs exactly that port for caddy.
819
+ Move the instance to another port (PORT in .env, then make run/service-restart)
820
+ and try again." ;;
821
+ esac
822
+
823
+ DIRECT_IP="${COLLOQ_PUBLIC_IP:-$(public_ip || true)}"
824
+ [ -n "$DIRECT_IP" ] || die \
825
+ "could not find out this machine's public address: none of the services answered.
826
+ Name it yourself: COLLOQ_PUBLIC_IP=<address> make host-direct HOST=${COLLOQ_HOSTNAME}"
827
+ say "${DIM} from outside this machine looks like ${DIRECT_IP}${OFF}"
828
+
829
+ local on_iface=""
830
+ if ip_is_local "$DIRECT_IP"; then
831
+ on_iface=1
832
+ else
833
+ say "${DIM} (it is not on an interface — this is NAT: on a cloud machine that${OFF}"
834
+ say "${DIM} is how it should be, behind port forwarding it is trouble)${OFF}"
835
+ fi
836
+
837
+ # caddy, уже занявший порты с прошлого запуска, — это не «занято», а «мы же
838
+ # их и заняли». Своего слушателя тогда не поднимаем: порты и так открыты, и
839
+ # спросить снаружи можно прямо так.
840
+ local held_by_caddy=""
841
+ if systemctl is-active --quiet caddy 2>/dev/null; then
842
+ held_by_caddy=1
843
+ say "${DIM} caddy is already running — checking straight through it${OFF}"
844
+ elif ! listener_start; then
845
+ tail -3 "$LISTEN_LOG" 2>/dev/null | sed 's/^/ /' >&2 || true
846
+ if grep -q 'Address already in use' "$LISTEN_LOG" 2>/dev/null; then
847
+ # Кто именно держит порт — это ответ, а «занято» — только половина.
848
+ command -v ss >/dev/null 2>&1 && \
849
+ ss -lntp 2>/dev/null | awk 'NR==1 || /:(80|443) /' >&2 || true
850
+ die "80 or 443 on this machine are already taken — and direct mode needs exactly them.
851
+ Stop the other web server (nginx, apache) or publish the class through a tunnel:
852
+ make host HOST=${COLLOQ_HOSTNAME}"
853
+ fi
854
+ die "could not take 80 and 443 on this machine — see the error above."
855
+ fi
856
+
857
+ local verdict closed="" unknown="" foreign=""
858
+
859
+ # 80 спрашиваем строже остальных: по нему придёт Let's Encrypt, и по нему же
860
+ # видно, наша ли это машина. Пока порт держит наш слушатель, ответ 204 —
861
+ # доказательство; любой другой код значит, что снаружи на этом адресе сидит
862
+ # кто-то другой. Когда порты держит уже поднятый caddy, спрашиваем только
863
+ # связность: он отвечает редиректом на https, а не нашим 204.
864
+ if [ -n "$held_by_caddy" ]; then
865
+ verdict="$(ask_outside "$DIRECT_IP" 80)"
866
+ else
867
+ case "$(ask_outside_http "http://${DIRECT_IP}/colloq-check")" in
868
+ code:204) verdict=open ;;
869
+ code:*) verdict=foreign ;;
870
+ closed) verdict=closed ;;
871
+ *) verdict=unknown ;;
872
+ esac
873
+ fi
874
+ case "$verdict" in
875
+ open) say "${DIM} 80 — open from outside, and this machine is what answers${OFF}" ;;
876
+ foreign) say "${RED} 80 — open, but it is NOT this machine that answers${OFF}"; foreign=1 ;;
877
+ closed) say "${RED} 80 — closed from outside${OFF}"; closed="${closed} 80" ;;
878
+ *) say "${DIM} 80 — could not ask from outside${OFF}"; unknown="${unknown} 80" ;;
879
+ esac
880
+
881
+ # 443 — только связность: TLS наш временный слушатель не умеет, и ничего
882
+ # умнее «соединение состоялось» про этот порт заранее не узнать.
883
+ case "$(ask_outside "$DIRECT_IP" 443)" in
884
+ open) say "${DIM} 443 — open from outside${OFF}" ;;
885
+ closed) say "${RED} 443 — closed from outside${OFF}"; closed="${closed} 443" ;;
886
+ *) say "${DIM} 443 — could not ask from outside${OFF}"; unknown="${unknown} 443" ;;
887
+ esac
888
+ [ -n "$held_by_caddy" ] || listener_stop
889
+
890
+ # Чужая машина на нашем «публичном» адресе — отдельный отказ, и обойти его
891
+ # флагом нельзя: тут не сомнение проверки, а прямой ответ, что адрес не наш.
892
+ if [ -n "$foreign" ]; then
893
+ die "the address ${DIRECT_IP} answers from outside, but it is not this machine.
894
+ That is what a VPN or a proxy looks like: traffic leaves through someone else's
895
+ box, and we took its address for ours. Pointing a class name at it means sending
896
+ the room to that box, and the certificate never arrives at all.
897
+
898
+ If the machine does have a public address, name it directly:
899
+ COLLOQ_PUBLIC_IP=<address> make host-direct HOST=${COLLOQ_HOSTNAME}
900
+ If it does not — this is a case for a tunnel: make host HOST=${COLLOQ_HOSTNAME}"
901
+ fi
902
+
903
+ # Отказ должен называть причину и давать выход, а не просто «нельзя».
904
+ #
905
+ # COLLOQ_DIRECT_FORCE=1 отказ снимает, и это не лазейка «на всякий случай»:
906
+ # проверяющая служба видит машину из Германии и Финляндии, а бывают фаерволы,
907
+ # закрытые для половины мира и открытые для университетской сети. Решение
908
+ # тогда за человеком — но он должен сказать это вслух, а не узнать, что
909
+ # скрипт молча пошёл дальше.
910
+ if [ -n "$closed" ] && [ "${COLLOQ_DIRECT_FORCE:-}" = "1" ]; then
911
+ say "${RED} ports${closed} are closed from outside, but COLLOQ_DIRECT_FORCE=1 — going on${OFF}"
912
+ say "${DIM} If the check is right, there will be no certificate: journalctl -u caddy -f${OFF}"
913
+ closed=""
914
+ fi
915
+ if [ -n "$closed" ]; then
916
+ die "ports${closed} on ${DIRECT_IP} are closed from outside — direct mode is impossible here.
917
+ That is how it is behind a provider firewall, behind a home router without
918
+ forwarding and always on a rented vast.ai machine: only the forwarded ssh is
919
+ open there, and 80 and 443 are not handed out at all.
920
+
921
+ Publish the class through a tunnel — it goes out as an outbound connection and
922
+ needs no open ports: make host HOST=${COLLOQ_HOSTNAME}
923
+
924
+ If you know for certain the ports are open and the check is lying: COLLOQ_DIRECT_FORCE=1"
925
+ fi
926
+
927
+ if [ -n "$unknown" ]; then
928
+ # Спросить снаружи не вышло — это не приговор портам, это молчание
929
+ # проверяющей службы. Но и «всё хорошо» сказать нельзя.
930
+ if [ -n "$on_iface" ]; then
931
+ say "${DIM} the checking service did not answer; the address belongs to this${OFF}"
932
+ say "${DIM} machine, the ports are free — going on. If 80 and 443 are closed${OFF}"
933
+ say "${DIM} after all, caddy gets no certificate: journalctl -u caddy -f${OFF}"
934
+ elif [ "${COLLOQ_DIRECT_FORCE:-}" != "1" ]; then
935
+ die "could not ask from outside, and the address ${DIRECT_IP} does not belong to this machine.
936
+ This is NAT, and it comes in two kinds: one to one on a cloud machine — then all
937
+ is well; port forwarding — then 80 and 443 are closed and there will be no
938
+ certificate. Nothing here can tell them apart.
939
+
940
+ A tunnel works in both cases: make host HOST=${COLLOQ_HOSTNAME}
941
+ Certain the ports are open: COLLOQ_DIRECT_FORCE=1 make host-direct HOST=${COLLOQ_HOSTNAME}"
942
+ fi
943
+ fi
944
+ }
945
+
946
+ # ------------------------------------------------------------- шаг: имя
947
+
948
+ direct_point_dns() {
949
+ step "pointing ${COLLOQ_HOSTNAME} at ${DIRECT_IP}"
950
+ #
951
+ # Запись пишет scripts/dns.sh — тот же код, что приводит в порядок всю зону.
952
+ # Своей копии здесь нет намеренно: правило «удалить лишнее, создать
953
+ # недостающее и НИКОГДА не оставлять проксирование» должно жить в одном
954
+ # месте и в одном месте чиниться.
955
+ #
956
+ # Проксирование (оранжевое облако) выключено, и это не мелочь настройки:
957
+ # включённое, оно уводит студентов на пограничные адреса Cloudflare, а они
958
+ # из России не открываются. Прямой режим ценен ровно тем, что между машиной
959
+ # и залом нет никого, — проксирование вернуло бы посредника, да ещё и того
960
+ # самого, от которого уходили.
961
+ #
962
+ ./scripts/dns.sh point "$COLLOQ_HOSTNAME" "$DIRECT_IP" || die \
963
+ "could not set the A record ${COLLOQ_HOSTNAME} → ${DIRECT_IP}.
964
+ CF_TOKEN (Zone:Read + DNS:Edit) is needed, and CF_ZONE in .env if the token
965
+ cannot list the zones."
966
+
967
+ # Ждём, пока имя начнёт разрешаться в наш адрес. Это не педантизм: пока
968
+ # публичные резолверы отдают старое, Let's Encrypt придёт по HTTP-01 на
969
+ # прежний адрес, получит отказ и уйдёт в паузу на несколько минут. Полминуты
970
+ # ожидания здесь дешевле, чем эта пауза на паре.
971
+ local seen="" i
972
+ for i in $(seq 1 30); do
973
+ [ "$(resolve_any "$COLLOQ_HOSTNAME" || true)" = "$DIRECT_IP" ] && { seen=1; break; }
974
+ sleep 2
975
+ done
976
+ if [ -n "$seen" ]; then
977
+ say "${DIM} the name already resolves to ${DIRECT_IP}${OFF}"
978
+ else
979
+ # Не отказ: TTL записи 300 секунд, и старый ответ может ещё лежать в
980
+ # кэшах. caddy повторит попытку сам, поэтому идём дальше, но вслух.
981
+ say "${DIM} the name does not resolve here yet — the record has a 300 second TTL.${OFF}"
982
+ say "${DIM} The certificate may be late by those five minutes.${OFF}"
983
+ fi
984
+ }
985
+
986
+ # ----------------------------------------------------------- шаг: caddy
987
+
988
+ direct_caddy() {
989
+ step "starting caddy on this machine"
990
+ #
991
+ # Приём тот же, что у ретранслятора (scripts/relay-setup.sh): один собранный
992
+ # бинарник вместо репозитория с ключом. Модули здесь не нужны — сертификат
993
+ # берётся по HTTP-01, — а один файл проще обновлять и понимать.
994
+ #
995
+ local arch caddy_bin
996
+ if ! command -v caddy >/dev/null 2>&1; then
997
+ case "$(uname -m)" in
998
+ x86_64|amd64) arch=amd64 ;;
999
+ aarch64|arm64) arch=arm64 ;;
1000
+ *) die "I do not know which caddy to take for $(uname -m) — install it yourself and try again." ;;
1001
+ esac
1002
+ say "${DIM} installing caddy (${arch})${OFF}"
1003
+ curl -fsSL -o /usr/local/bin/caddy \
1004
+ "https://caddyserver.com/api/download?os=linux&arch=${arch}" \
1005
+ || die "could not download caddy."
1006
+ chmod +x /usr/local/bin/caddy
1007
+ fi
1008
+ caddy_bin="$(command -v caddy)"
1009
+
1010
+ # Чужой конфиг не трогаем, и спрашиваем об этом до того, как заведём
1011
+ # пользователя и поменяем права на /etc/caddy. На машине, где caddy уже
1012
+ # что-то обслуживает, переписать Caddyfile значило бы молча выключить чей-то
1013
+ # сайт — и узнал бы об этом его владелец, а не мы.
1014
+ if [ -f /etc/caddy/Caddyfile ] && ! grep -q '^# colloq:' /etc/caddy/Caddyfile; then
1015
+ die "this machine already has a /etc/caddy/Caddyfile of its own — I will not overwrite it.
1016
+ Add the site to it by hand:
1017
+ ${COLLOQ_HOSTNAME} { reverse_proxy 127.0.0.1:${PORT} }
1018
+ and restart caddy."
1019
+ fi
1020
+
1021
+ id -u caddy >/dev/null 2>&1 || \
1022
+ useradd --system --home /var/lib/caddy --shell /usr/sbin/nologin caddy
1023
+ install -d -o caddy -g caddy -m 0750 /var/lib/caddy /etc/caddy
1024
+
1025
+ # Конфиг на одно имя, и больше ничего. Он переписывается на каждый запуск —
1026
+ # это и делает команду повторяемой: сменилось имя или порт, и файл просто
1027
+ # становится верным, без чистки руками.
1028
+ cat > /etc/caddy/Caddyfile <<CONF
1029
+ # colloq: прямой режим (scripts/host.sh). Файл переписывается на каждый запуск,
1030
+ # правки руками пропадут — правьте скрипт.
1031
+ #
1032
+ # Сертификат выпишется автоматически при первом обращении: caddy сам сходит в
1033
+ # Let's Encrypt по HTTP-01, для чего и нужны настоящие 80 и 443. Ключ от DNS на
1034
+ # машине не хранится и не нужен.
1035
+ ${COLLOQ_HOSTNAME} {
1036
+ # Вебсокеты (а Colloq это в первую очередь они: /collab и /control)
1037
+ # проходят через reverse_proxy сами, отдельной настройки не требуют.
1038
+ reverse_proxy 127.0.0.1:${PORT} {
1039
+ # Единственный заголовок, названный вслух. Ставит его caddy и без нас,
1040
+ # но от него зависит флаг Secure на куке персонала (server/src/admin/
1041
+ # auth.ts читает x-forwarded-proto), и молчаливое умолчание тут хуже
1042
+ # явной строки. X-Forwarded-Host не пишем: caddy передаёт его сам и на
1043
+ # лишнюю строку ругается предупреждением при каждой проверке конфига.
1044
+ header_up X-Forwarded-Proto https
1045
+ }
1046
+ }
1047
+ CONF
1048
+ "$caddy_bin" fmt --overwrite /etc/caddy/Caddyfile >/dev/null 2>&1 || true
1049
+ # Вывод проверки придерживаем и печатаем только при отказе: на успехе caddy
1050
+ # пишет несколько строк JSON про адаптацию конфига, и человек, запустивший
1051
+ # «выставь семинар наружу», читает их как ошибку.
1052
+ local report
1053
+ if ! report="$("$caddy_bin" validate --config /etc/caddy/Caddyfile --adapter caddyfile 2>&1)"; then
1054
+ printf '%s\n' "$report" >&2
1055
+ die "caddy did not accept the config — see the error above."
1056
+ fi
1057
+
1058
+ cat > /etc/systemd/system/caddy.service <<UNIT
1059
+ [Unit]
1060
+ Description=Caddy for colloq (direct mode)
1061
+ After=network-online.target
1062
+ Wants=network-online.target
1063
+
1064
+ [Service]
1065
+ User=caddy
1066
+ Group=caddy
1067
+ ExecStart=${caddy_bin} run --config /etc/caddy/Caddyfile --adapter caddyfile
1068
+ ExecReload=${caddy_bin} reload --config /etc/caddy/Caddyfile --adapter caddyfile --force
1069
+ Restart=on-abnormal
1070
+ RestartSec=3
1071
+ # Право слушать 80 и 443 без запуска от root. Сертификаты и ключи лежат в
1072
+ # /var/lib/caddy — это домашний каталог пользователя caddy.
1073
+ AmbientCapabilities=CAP_NET_BIND_SERVICE
1074
+ CapabilityBoundingSet=CAP_NET_BIND_SERVICE
1075
+ NoNewPrivileges=true
1076
+ ProtectSystem=strict
1077
+ ProtectHome=true
1078
+ PrivateTmp=true
1079
+ ReadWritePaths=/var/lib/caddy /etc/caddy
1080
+
1081
+ [Install]
1082
+ WantedBy=multi-user.target
1083
+ UNIT
1084
+
1085
+ systemctl daemon-reload
1086
+ systemctl enable caddy >/dev/null 2>&1 || true
1087
+ # enable --now не трогает уже запущенное, а команду задумано повторять:
1088
+ # без явного перезапуска второй прогон оставил бы caddy на старом конфиге —
1089
+ # то есть на прошлом имени семинара.
1090
+ systemctl restart caddy || {
1091
+ journalctl -u caddy -n 20 --no-pager 2>/dev/null >&2 || true
1092
+ die "caddy did not start."
1093
+ }
1094
+ sleep 2
1095
+ systemctl is-active --quiet caddy || {
1096
+ journalctl -u caddy -n 20 --no-pager 2>/dev/null >&2 || true
1097
+ die "caddy started and fell over at once — the log is above."
1098
+ }
1099
+ say "${DIM} caddy holds 443 and passes requests to ${LOCAL}${OFF}"
1100
+ }
1101
+
1102
+ direct_publish() {
1103
+ direct_check_ports
1104
+ direct_point_dns
1105
+ direct_caddy
1106
+ PUBLIC="https://${COLLOQ_HOSTNAME}"
1107
+ }
1108
+
1109
+ # ------------------------------------------------------------- открываем адрес
1110
+
1111
+ # Сжато ли то, что сейчас поедет в аудиторию.
1112
+ #
1113
+ # Сказать об этом надо ДО ссылки и до первого студента: без .br рядом с
1114
+ # assets/ сервер сжимает каждый файл на каждый запрос — измерено на самом
1115
+ # большом куске этой сборки, 11.6 мс процессорного времени и 219 751 байт
1116
+ # вместо 194 920 у заранее сжатого. Умножается это на число пришедших.
1117
+ #
1118
+ # Не отказ: пара важнее, и запретить её из-за режима сборки нельзя. Но и
1119
+ # промолчать нельзя — раньше молчали, и `make run` без OPTIMIZE=1 уходил в
1120
+ # аудиторию ровно так.
1121
+ UNCOMPRESSED="$(assets_uncompressed)"
1122
+ if [ "${UNCOMPRESSED:-0}" != 0 ]; then
1123
+ say "${RED}${UNCOMPRESSED} files in web/dist/assets have no compressed neighbour (.br)${OFF}"
1124
+ say "${DIM} The server compresses them again on EVERY request: 11.6 ms of CPU time${OFF}"
1125
+ say "${DIM} and an extra 25 KB per student, on the same machine where the room${OFF}"
1126
+ say "${DIM} kernels come up.${OFF}"
1127
+ say "${DIM} Rebuild: npm run build:optimized (make run does the same)${OFF}"
1128
+ printf '\n'
1129
+ fi
1130
+
1131
+ if [ "$VIA" = direct ]; then
1132
+ # Три шага вместо одного «открываю туннель»: проверить порты, направить имя,
1133
+ # поднять caddy. Туннеля здесь нет вовсе — наружу смотрит сама машина.
1134
+ direct_publish
1135
+ elif [ "$VIA" = relay ]; then
1136
+ step "opening the tunnel to the relay"
1137
+ # Поддомен — это всё, что инстанс просит у ретранслятора: frps выдаёт имена
1138
+ # только под своей зоной, поэтому попросить чужое имя нельзя даже с секретом.
1139
+ SUB="${COLLOQ_HOSTNAME%".$RELAY_DOMAIN"}"
1140
+ CONF="$(mktemp -t colloq-frpc.XXXXXX)"
1141
+ # Секрет уходит в файл, а не в аргументы: командная строка видна всей машине
1142
+ # через ps, и общий ключ ретранслятора там светиться не должен.
1143
+ cat > "$CONF" <<CONF
1144
+ serverAddr = "${RELAY_ADDR}"
1145
+ serverPort = ${RELAY_PORT}
1146
+ auth.method = "token"
1147
+ auth.token = "${RELAY_TOKEN}"
1148
+ log.to = "console"
1149
+ log.level = "info"
1150
+
1151
+ # Запас готовых соединений до ретранслятора.
1152
+ #
1153
+ # Без него каждый новый запрос студента ждёт, пока frpc установит соединение до
1154
+ # frps: лишний круг по сети ПЕРЕД первым байтом, и приходится он ровно на
1155
+ # звонок, когда вся группа открывает ссылку разом. Пять — это пять соединений,
1156
+ # которые висят готовыми; больше держать незачем, дальше работает мультиплекс
1157
+ # (tcpMux включён по умолчанию, и его мы не трогаем).
1158
+ transport.poolCount = 5
1159
+
1160
+ # useCompression здесь НЕ включается, и это решение, а не пропуск. Через этот
1161
+ # туннель едет то, что уже сжато: статику отдаёт сервер заранее сжатой (.br), а
1162
+ # после появления зеркала (см. upload_assets) она сюда и вовсе не заходит.
1163
+ # Сжимать сжатое — это процессорное время ноутбука за отрицательный выигрыш.
1164
+ # Мерить это стоит заново, когда зеркало поработает на живых парах.
1165
+
1166
+ [[proxies]]
1167
+ name = "${SUB}"
1168
+ type = "http"
1169
+ localIP = "127.0.0.1"
1170
+ localPort = ${TUNNEL_PORT}
1171
+ subdomain = "${SUB}"
1172
+ CONF
1173
+ if [ -n "$LOCAL_RUN_ID" ]; then
1174
+ printf 'hostHeaderRewrite = "127.0.0.1:%s"\n' "$TUNNEL_PORT" >> "$CONF"
1175
+ fi
1176
+ chmod 600 "$CONF"
1177
+ frpc -c "$CONF" >"$LOG" 2>&1 &
1178
+ TUNNEL_PID=$!
1179
+ PUBLIC="https://${COLLOQ_HOSTNAME}"
1180
+ # Ждём подтверждения от сервера, а не «прошло N секунд»: занятый кем-то
1181
+ # поддомен или неверный секрет — это отказ, который приходит сразу, и молча
1182
+ # пойти дальше значило бы раздать ссылку в никуда.
1183
+ #
1184
+ # Строки — те, что frpc печатает на самом деле. Прежний `proxy name .* already`
1185
+ # не совпадал ни с чем: frpc 0.71 пишет `start error: proxy [hse] already
1186
+ # exists`. Из-за этого занятый поддомен — самый частый отказ, когда семинар
1187
+ # уже поднят на другой машине, — распознавался не сразу, а через тридцать
1188
+ # секунд ожидания, и назывался «не дождался ответа».
1189
+ #
1190
+ # Проверено на живом ретрансляторе: второй frpc с тем же именем печатает
1191
+ # ровно эту строку через четверть секунды.
1192
+ #
1193
+ ok=""
1194
+ for _ in $(seq 1 30); do
1195
+ grep -q 'start proxy success' "$LOG" 2>/dev/null && { ok=1; break; }
1196
+ if grep -qE 'already exists' "$LOG" 2>/dev/null; then
1197
+ die "the subdomain ${COLLOQ_HOSTNAME} is taken — this class is open from another machine.
1198
+ Close it there or take another name: make host HOST=<name>.colloq.ru"
1199
+ fi
1200
+ if grep -qiE 'login to server failed|authorization failed|authentication failed|token in login doesn' "$LOG" 2>/dev/null; then
1201
+ die "the relay did not accept the secret. Check RELAY_TOKEN in .env."
1202
+ fi
1203
+ if grep -qiE 'start error|login to server failed' "$LOG" 2>/dev/null; then
1204
+ grep -iE 'start error|login to server failed' "$LOG" | head -3 >&2
1205
+ die "the relay refused."
1206
+ fi
1207
+ kill -0 "$TUNNEL_PID" 2>/dev/null || { cat "$LOG" >&2; die "frpc died without opening the tunnel."; }
1208
+ sleep 1
1209
+ done
1210
+ rm -f "$CONF"
1211
+ [ -n "$ok" ] || { cat "$LOG" >&2; die "no answer from the relay in 30 seconds."; }
1212
+ # Туннель есть — значит у имени есть и сертификат (ретранслятор выпускает
1213
+ # его только живому имени), и статику можно класть на место. Именно здесь, а
1214
+ # не в конце: зеркало должно быть полным к моменту, когда ссылка уйдёт в чат.
1215
+ upload_assets
1216
+ elif [ -n "${COLLOQ_HOSTNAME:-}" ]; then
1217
+ step "opening the named Cloudflare tunnel"
1218
+ # Именованный туннель: постоянный адрес, но его нужно один раз завести
1219
+ # (make tunnel-setup). Без этого cloudflared не знает, куда маршрутизировать.
1220
+ #
1221
+ # И говорим вслух, через что пошли: имя под своей зоной без RELAY_DOMAIN в
1222
+ # .env молча уезжало в Cloudflare, а его адреса из России не открываются —
1223
+ # выяснялось это уже в аудитории, где ссылка не открылась ни у кого.
1224
+ say "${DIM} through Cloudflare. Your own relay (Cloudflare addresses do not${OFF}"
1225
+ say "${DIM} open from Russia) — RELAY_* in .env, see make relay-setup${OFF}"
1226
+ "$CLOUDFLARED" tunnel --no-autoupdate run --url "$LOCAL" ${ORIGIN_ARGS[@]+"${ORIGIN_ARGS[@]}"} colloq >"$LOG" 2>&1 &
1227
+ TUNNEL_PID=$!
1228
+ PUBLIC="https://${COLLOQ_HOSTNAME}"
1229
+ else
1230
+ step "opening the quick Cloudflare tunnel"
1231
+ "$CLOUDFLARED" tunnel --no-autoupdate --url "$LOCAL" ${ORIGIN_ARGS[@]+"${ORIGIN_ARGS[@]}"} >"$LOG" 2>&1 &
1232
+ TUNNEL_PID=$!
1233
+ PUBLIC=""
1234
+ # Адрес приходит не сразу и не первой строкой — cloudflared сначала пишет
1235
+ # баннер. Ждём именно ссылку, а не «прошло N секунд».
1236
+ for _ in $(seq 1 60); do
1237
+ PUBLIC="$(grep -oE 'https://[a-z0-9-]+\.trycloudflare\.com' "$LOG" | head -1 || true)"
1238
+ [ -n "$PUBLIC" ] && break
1239
+ kill -0 "$TUNNEL_PID" 2>/dev/null || { cat "$LOG" >&2; die "cloudflared died without opening the tunnel."; }
1240
+ sleep 1
1241
+ done
1242
+ [ -n "$PUBLIC" ] || { cat "$LOG" >&2; die "no tunnel address in a minute."; }
1243
+ fi
1244
+
1245
+ step "publishing the external address"
1246
+ if [ "$WHO" != local ]; then
1247
+ set_public_url "$PUBLIC"
1248
+ TOUCHED_ENV=1
1249
+ fi
1250
+ case "$WHO" in
1251
+ local)
1252
+ LEASE_OWNER="${LOCAL_RUN_ID}:$$:${RANDOM}"
1253
+ "${LEASE[@]}" acquire "$LEASE_FILE" "$LOCAL_RUN_ID" "$LEASE_OWNER" "$PUBLIC" "$HEALTH_LOCAL"
1254
+ "${LEASE[@]}" watch "$LEASE_FILE" "$LOCAL_RUN_ID" "$LEASE_OWNER" "$TUNNEL_PID" "$$" "$HEALTH_LOCAL" &
1255
+ LEASE_PID=$!
1256
+ ;;
1257
+ cluster)
1258
+ # Запоминаем ДО вызова: упади он на полпути, config.env уже может нести
1259
+ # этот адрес, и cleanup должен знать, что снимать.
1260
+ CLUSTER_PUBLISHED="$PUBLIC"
1261
+ bash scripts/cluster.sh public-url "$PUBLIC"
1262
+ ;;
1263
+ service)
1264
+ # Службу НЕ перезапускаем, и это исправление.
1265
+ #
1266
+ # Здесь стоял `systemctl restart colloq` с объяснением «служба читает .env
1267
+ # при запуске, новый адрес доезжает только перезапуском». Неправда:
1268
+ # config.publicUrl — геттер, и readPublicUrl (server/src/config.ts)
1269
+ # перечитывает файл не реже раза в две секунды, причём файл главнее
1270
+ # переменной окружения; все ссылки строятся через него на каждый запрос.
1271
+ # То же самое написано и в шапке deploy/colloq.service. А перезапуск рвал
1272
+ # все сокеты комнаты — заметнее всего когда `make host` повторяют посреди
1273
+ # пары, потому что упал туннель: зал уходил в переподключение без всякой
1274
+ # причины.
1275
+ #
1276
+ # Ждать — те самые две секунды, но не вслепую: `/api/health` называет
1277
+ # адрес, который сервер СЕЙЧАС пишет в ссылки (server/src/app.ts ·
1278
+ # publicUrl), и ждём мы совпадения, а не выжданного времени. Здесь стоял
1279
+ # `sleep 3` — единственный сон в этом скрипте, поставленный не потому, что
1280
+ # чего-то ждут, а потому, что спросить было некого.
1281
+ #
1282
+ # Без `-f`: пока не поднято ядро, здоровье отвечает 503 — это по-прежнему
1283
+ # живой сервер, и адрес в его ответе тот самый, ради которого мы ждём.
1284
+ ok=""
1285
+ said=""
1286
+ blind=0
1287
+ for _ in $(seq 1 20); do
1288
+ said="$(curl -s --max-time 2 "$LOCAL/api/health" 2>/dev/null || true)"
1289
+ if printf '%s' "$said" | grep -qF "\"publicUrl\":\"${PUBLIC%/}\""; then ok=1; break; fi
1290
+ # Служба может быть собрана раньше этого поля: тогда спросить нечего, и
1291
+ # остаётся прежний ответ — живой инстанс после трёх секунд перечитывания.
1292
+ # Молчание и «поле есть, адрес другой» — это НЕ он, и ждать их надо до
1293
+ # конца срока.
1294
+ case "$said" in
1295
+ '' | *'"publicUrl"'*) ;;
1296
+ *)
1297
+ blind=$((blind + 1))
1298
+ if [ "$blind" -ge 3 ]; then ok=1; break; fi
1299
+ ;;
1300
+ esac
1301
+ sleep 1
1302
+ done
1303
+ [ -n "$ok" ] || {
1304
+ journalctl -u colloq -n 20 --no-pager 2>/dev/null >&2 || true
1305
+ [ -n "$said" ] || die "the service stopped answering while the address was changing. Log: make service-logs"
1306
+ die "the service answers, but writes not ${PUBLIC} into links: .env was not reread. Log: make service-logs"
1307
+ }
1308
+ say "${DIM} the service reread .env: ${PUBLIC} (no restart, sockets intact)${OFF}"
1309
+ ;;
1310
+ container)
1311
+ PUBLIC_URL="$PUBLIC" docker compose up -d app >/dev/null
1312
+ ;;
1313
+ host)
1314
+ # Native servers reread .env on each cache refresh, just like the service.
1315
+ # The tunnel owns no application PID and must never restart it.
1316
+ ok=""
1317
+ for _ in $(seq 1 20); do
1318
+ said="$(curl -s --max-time 2 "$LOCAL/api/health" 2>/dev/null || true)"
1319
+ if printf '%s' "$said" | grep -qF "\"publicUrl\":\"${PUBLIC%/}\""; then ok=1; break; fi
1320
+ sleep 1
1321
+ done
1322
+ [ -n "$ok" ] || die "The server did not reread the external address; local work continues."
1323
+ ;;
1324
+ *)
1325
+ # Молча пройти мимо нельзя: аудитория получит localhost, то есть ничего.
1326
+ say "${RED} ${LOCAL} is held by some other process, not colloq.${OFF}"
1327
+ # Без докера форму «контейнер» не спросить вовсе, и `make up` здесь
1328
+ # неотличим от чужого процесса — сказать это честнее, чем «не colloq».
1329
+ [ -n "$HAVE_DOCKER" ] || say "${DIM} (there is no docker here, so a make up container could not even be asked)${OFF}"
1330
+ say "${DIM} Its PUBLIC_URL cannot be changed from here — restart it yourself with${OFF}"
1331
+ say "${DIM} PUBLIC_URL=${PUBLIC}, or links to classes will lead to localhost.${OFF}"
1332
+ ;;
1333
+ esac
1334
+
1335
+ step "checking that it really answers from outside"
1336
+ #
1337
+ # Проверка идёт мимо системного резолвера. Измерено на живой машине: туннель
1338
+ # отдавал 200 за 0.6 секунды, а `curl https://<адрес>` тут же падал с «could
1339
+ # not resolve host» — getaddrinfo держал отрицательный ответ, хотя dig то же
1340
+ # имя видел прекрасно. Своя же проверка объявляла рабочий семинар сломанным.
1341
+ #
1342
+ # Поэтому адрес берётся у публичного резолвера и подставляется через --resolve:
1343
+ # так проверяется туннель, а не настройки DNS на этом ноутбуке.
1344
+ host_only="${PUBLIC#https://}"
1345
+
1346
+ probe() {
1347
+ local ip
1348
+ if ip="$(resolve_any "$host_only")"; then
1349
+ [ "$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 --resolve "$host_only:443:$ip" "$PUBLIC/" || true)" = "200" ] && return 0
1350
+ fi
1351
+ # Системный резолвер — второй попыткой, а не первой: именно он здесь и врёт.
1352
+ [ "$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "$PUBLIC/" || true)" = "200" ] && return 0
1353
+ return 1
1354
+ }
1355
+
1356
+ ok=""
1357
+ for _ in $(seq 1 30); do
1358
+ probe && { ok=1; break; }
1359
+ sleep 2
1360
+ done
1361
+
1362
+ # `colloq start --share`: итог печатает супервизор, одним блоком — со ссылкой
1363
+ # на занятие из базы, которой здесь не видно (cli/src/launch-share.ts). Отсюда
1364
+ # ему нужна одна строка-метка: адрес поднят, проверка снаружи прошла или нет.
1365
+ # Экрана она не достигает — супервизор её забирает.
1366
+ #
1367
+ # Ссылки с токеном установки здесь нет намеренно: терминал с --share часто
1368
+ # стоит на проекторе, а CLI токена не печатает никогда (colloq link). Панель
1369
+ # на этом компьютере открыта по локальному адресу, и вход туда уже есть.
1370
+ #
1371
+ # Только у локальной сессии: переменная, забытая в оболочке, не должна
1372
+ # прятать итог у службы или кластера.
1373
+ if [ "${COLLOQ_SHARE:-}" = 1 ] && [ "$WHO" = local ]; then
1374
+ if [ -n "$ok" ]; then say "@colloq-share ok ${PUBLIC}"; else say "@colloq-share unverified ${PUBLIC}"; fi
1375
+ wait "$TUNNEL_PID"
1376
+ exit 0
1377
+ fi
1378
+
1379
+ #
1380
+ # Токен. Кука админки привязана к origin, а туннель каждый раз выдаёт новый —
1381
+ # значит после каждого `make host` преподаватель оказывается разлогинен и
1382
+ # перепечатывает тридцать два символа. Поэтому ссылка с токеном, как у Jupyter.
1383
+ #
1384
+ # Кандидатов два, и они разные: контейнер держит токен в своём томе, а сервер,
1385
+ # запущенный руками, — в локальном DATA_DIR. Угадывать нельзя. Стоило один раз
1386
+ # прочитать «тот, который запущен», и в туннель ушла ссылка от одного инстанса,
1387
+ # а порт держал другой: 401 и экран входа вместо панели.
1388
+ #
1389
+ # Поэтому не гадаем, а спрашиваем сам сервер: 401 — не тот токен, 200 — тот и
1390
+ # инстанс занят, 409 — тот, но инстанс пока ничей. Годятся оба непустых ответа.
1391
+ token_works() {
1392
+ local code
1393
+ code="$(curl -s -o /dev/null -w '%{http_code}' --max-time 5 \
1394
+ -X POST "$LOCAL/api/admin/signin/token" \
1395
+ -H 'content-type: application/json' -d "{\"token\":\"$1\"}" || true)"
1396
+ [ "$code" = "200" ] || [ "$code" = "409" ]
1397
+ }
1398
+
1399
+ #
1400
+ # У k3s кандидат третий: том colloq-data — это <состояние>/data на хосте
1401
+ # (scripts/release.py · render, deploy/k3s/README.md), то есть
1402
+ # /var/lib/colloq/data, если COLLOQ_STATE_DIR не назван. Раньше его здесь не
1403
+ # было, и на k3s-машине ссылка на панель печаталась только тому, кто догадался
1404
+ # сам выставить DATA_DIR, — а `make vast-up` отсылал за ней именно сюда, в
1405
+ # вывод этой сессии. Спрашивается он первым и проверяется тем же token_works.
1406
+ read_setup_token() {
1407
+ local t dir dirs=()
1408
+ if [ "$CLUSTER" = 1 ]; then
1409
+ dirs=("$CLUSTER_STATE/data")
1410
+ elif [ -n "$HAVE_DOCKER" ] && docker compose ps app --format '{{.State}}' 2>/dev/null | grep -q running; then
1411
+ t="$(docker compose exec -T app cat /data/setup-token 2>/dev/null | tr -d '\r\n' || true)"
1412
+ [ -n "$t" ] && token_works "$t" && { printf '%s' "$t"; return 0; }
1413
+ fi
1414
+ dirs+=("${DATA_DIR:-$COLLOQ_STATE_ROOT/data}")
1415
+ for dir in "${dirs[@]}"; do
1416
+ t="$(cat "$dir/setup-token" 2>/dev/null | tr -d '\r\n' || true)"
1417
+ [ -n "$t" ] && token_works "$t" && { printf '%s' "$t"; return 0; }
1418
+ done
1419
+ return 1
1420
+ }
1421
+ SETUP_TOKEN="$(read_setup_token || true)"
1422
+
1423
+ printf '\n'
1424
+ if [ -n "$ok" ]; then
1425
+ say "${BOLD}Colloq is available at this link${OFF}"
1426
+ else
1427
+ say "${RED}The check from this machine did not go through.${OFF}"
1428
+ if [ "$VIA" = direct ]; then
1429
+ # В прямом режиме подозреваемый другой, и он один: сертификат. Порты мы уже
1430
+ # проверили снаружи, имя направили — остаётся выдача, которая идёт минуту, а
1431
+ # при неуехавшем DNS и все пять. caddy при этом молчит в терминал, зато
1432
+ # говорит в журнал, поэтому сюда и посылаем.
1433
+ say "${DIM}The ports are open and the name points here — most likely the certificate${OFF}"
1434
+ say "${DIM}is still being issued: up to a minute, and up to five if the DNS record${OFF}"
1435
+ say "${DIM}has just changed. What is going on: journalctl -u caddy -f${OFF}"
1436
+ else
1437
+ # Чаще всего это не туннель, а сеть, из которой мы проверяем: корпоративный
1438
+ # DNS или блокировка исходящих. Студенты из дома при этом заходят нормально,
1439
+ # поэтому пугать «не работает» нельзя — надо сказать, что именно неясно.
1440
+ say "${DIM}The tunnel is up, but the check did not pass — usually this network's DNS${OFF}"
1441
+ say "${DIM}is to blame, not the class. Open the link from a phone on mobile internet.${OFF}"
1442
+ fi
1443
+ fi
1444
+ say " ${CYAN}${BOLD}${PUBLIC}${OFF}"
1445
+
1446
+ if [ -n "$SETUP_TOKEN" ]; then
1447
+ printf '\n'
1448
+ say "${BOLD}Sign-in to the panel — this link is for you only${OFF}"
1449
+ say " ${CYAN}${PUBLIC}/admin/t/${SETUP_TOKEN}${OFF}"
1450
+ # Строка стоит прямо под ссылкой, а не в конце: спутать её с адресом семинара
1451
+ # и отправить в чат группы — ровно одно движение, и оно необратимо.
1452
+ say " ${RED}This is the key to the instance.${OFF} ${DIM}Do not send it to a chat and do${OFF}"
1453
+ say " ${DIM}not leave it on screen while the room is watching. Students get the${OFF}"
1454
+ say " ${DIM}class link, which you copy from the panel.${OFF}"
1455
+ else
1456
+ # Ссылка, которая не откроет панель, хуже, чем её отсутствие: человек три раза
1457
+ # ткнёт в неё, прежде чем усомнится в ссылке, а не в себе.
1458
+ printf '\n'
1459
+ say "${DIM}Not printing the sign-in link for the panel: none of the setup tokens found${OFF}"
1460
+ if [ "$CLUSTER" = 1 ]; then
1461
+ # Каталог данных k3s — 0750 и принадлежит uid 1000 приложения (cluster.sh
1462
+ # · prepare): не root его не прочтёт, поэтому и совет — через sudo.
1463
+ say "${DIM}suited the server at ${LOCAL}. On k3s it lies in ${CLUSTER_STATE}/data/setup-token${OFF}"
1464
+ say "${DIM}(readable by root): sudo cat it and open ${PUBLIC}/admin/t/<token>.${OFF}"
1465
+ else
1466
+ say "${DIM}suited the server at ${LOCAL}. Take it from the DATA_DIR of the server that${OFF}"
1467
+ say "${DIM}answers there and open ${PUBLIC}/admin/t/<token>.${OFF}"
1468
+ fi
1469
+ fi
1470
+
1471
+ printf '\n'
1472
+ if [ "$VIA" = direct ]; then
1473
+ # Прямой режим нельзя описывать словами про это окно — оно здесь ни при чём,
1474
+ # и притворяться, что Ctrl+C что-то выключает, значит обещать выключатель,
1475
+ # которого нет. Адрес держит служба caddy, она переживает и закрытый терминал,
1476
+ # и оборванный ssh, и перезагрузку машины.
1477
+ say "${DIM}Everything runs here, and the students come straight here too: caddy on${OFF}"
1478
+ say "${DIM}this machine terminates TLS and passes the request to colloq at ${LOCAL}.${OFF}"
1479
+ say "${DIM}There is no relay and no Cloudflare on this path.${OFF}"
1480
+ printf '\n'
1481
+ say "${DIM}This window can be closed: caddy is a systemd service, it lives on its own.${OFF}"
1482
+ say "${DIM}Switch the address off: ${OFF}systemctl stop caddy"
1483
+ say "${DIM}Read the log: ${OFF}journalctl -u caddy -f"
1484
+ say "${DIM}PUBLIC_URL stays in .env: the address is alive, and rolling it back to${OFF}"
1485
+ say "${DIM}localhost is pointless — unlike a tunnel, it does not die with the script.${OFF}"
1486
+ if [ -n "$RELAY_DOMAIN" ] && [ "${COLLOQ_HOSTNAME%".$RELAY_DOMAIN"}" != "$COLLOQ_HOSTNAME" ]; then
1487
+ # Про это надо сказать прямо: запись на конкретное имя сильнее звёздочки,
1488
+ # и пока она есть, ретранслятор для этого имени не при делах. Вернуть имя
1489
+ # ему — значит удалить запись руками (или прогнать scripts/dns.sh, который
1490
+ # приводит зону к виду «звёздочка на ретранслятор»).
1491
+ printf '\n'
1492
+ say "${DIM}The record ${COLLOQ_HOSTNAME} → ${DIRECT_IP} stays in the zone and overrides${OFF}"
1493
+ say "${DIM}*.${RELAY_DOMAIN}: while it is there, this name leads here, not to the relay.${OFF}"
1494
+ fi
1495
+ printf '\n'
1496
+ exit 0
1497
+ fi
1498
+ if [ "$VIA" = relay ]; then
1499
+ say "${DIM}Everything runs here: the students' browsers go to the relay, and it goes${OFF}"
1500
+ else
1501
+ say "${DIM}Everything runs here: the students' browsers go to Cloudflare, and it goes${OFF}"
1502
+ fi
1503
+ say "${DIM}to this window. Close it (Ctrl+C) and the link stops working, while colloq${OFF}"
1504
+ say "${DIM}keeps turning locally at ${LOCAL}.${OFF}"
1505
+ # Про кластер — вслух и заранее: уборка там не мгновенная (перезапуск
1506
+ # приложения), и второй Ctrl+C посреди неё оборвал бы cluster.sh на полпути.
1507
+ if [ -n "$CLUSTER_PUBLISHED" ]; then
1508
+ say "${DIM}On exit the address is taken off the cluster too: the app restarts with${OFF}"
1509
+ say "${DIM}PUBLIC_URL=${LOCAL} — let that finish, do not press Ctrl+C twice.${OFF}"
1510
+ fi
1511
+ printf '\n'
1512
+
1513
+ # Держим окно живым: туннель существует ровно столько, сколько этот процесс.
1514
+ wait "$TUNNEL_PID"