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.
- colloq/__init__.py +12 -0
- colloq/__main__.py +315 -0
- colloq/_app/.colloq-dist.json +66 -0
- colloq/_app/LICENSE +21 -0
- colloq/_app/THIRD_PARTY_NOTICES.md +106 -0
- colloq/_app/cli/colloq.mjs +2517 -0
- colloq/_app/cli/launch.mjs +1572 -0
- colloq/_app/cli/package.json +5 -0
- colloq/_app/cli/public-url-lease.mjs +198 -0
- colloq/_app/kernel/Dockerfile +93 -0
- colloq/_app/kernel/environments/base-gpu.txt +21 -0
- colloq/_app/kernel/environments/base.txt +12 -0
- colloq/_app/kernel/environments/cv.txt +15 -0
- colloq/_app/kernel/environments/gpu.txt +17 -0
- colloq/_app/kernel/environments/kaggle-base.txt +20 -0
- colloq/_app/kernel/environments/pvz-geo.txt +6 -0
- colloq/_app/kernel/requirements.txt +18 -0
- colloq/_app/package.json +20 -0
- colloq/_app/scripts/backup-local.sh +165 -0
- colloq/_app/scripts/backup.sh +34 -0
- colloq/_app/scripts/cluster.sh +492 -0
- colloq/_app/scripts/dns.sh +251 -0
- colloq/_app/scripts/host.sh +1514 -0
- colloq/_app/scripts/lib.sh +62 -0
- colloq/_app/scripts/restore.sh +338 -0
- colloq/_app/scripts/runtime-backup.py +373 -0
- colloq/_app/scripts/state-lock.py +85 -0
- colloq/_app/server/assets/fonts/HSESans-Black.otf +0 -0
- colloq/_app/server/assets/fonts/HSESans-Bold.otf +0 -0
- colloq/_app/server/assets/fonts/HSESans-Regular.otf +0 -0
- colloq/_app/server/assets/fonts/JetBrainsMono-Medium.ttf +0 -0
- colloq/_app/server/assets/fonts/JetBrainsMono-OFL.txt +93 -0
- colloq/_app/server/dist/local-cleanup.js +15987 -0
- colloq/_app/server/dist/runtime-smoke.js +17026 -0
- colloq/_app/server/dist/server.js +54333 -0
- colloq/_app/server/package.json +6 -0
- colloq/_app/web/dist/assets/AdminScreen-C-cg0GlJ.css +1 -0
- colloq/_app/web/dist/assets/AdminScreen-C-cg0GlJ.css.br +0 -0
- colloq/_app/web/dist/assets/AdminScreen-C-cg0GlJ.css.gz +0 -0
- colloq/_app/web/dist/assets/AdminScreen-fu4cB6Ra.js +105 -0
- colloq/_app/web/dist/assets/AdminScreen-fu4cB6Ra.js.br +0 -0
- colloq/_app/web/dist/assets/AdminScreen-fu4cB6Ra.js.gz +0 -0
- colloq/_app/web/dist/assets/App-CMXNqOjp.css +1 -0
- colloq/_app/web/dist/assets/App-CMXNqOjp.css.br +0 -0
- colloq/_app/web/dist/assets/App-CMXNqOjp.css.gz +0 -0
- colloq/_app/web/dist/assets/App-DvCPzIRO.js +26 -0
- colloq/_app/web/dist/assets/App-DvCPzIRO.js.br +0 -0
- colloq/_app/web/dist/assets/App-DvCPzIRO.js.gz +0 -0
- colloq/_app/web/dist/assets/CellOutputs-Dl-AZL45.js +42 -0
- colloq/_app/web/dist/assets/CellOutputs-Dl-AZL45.js.br +0 -0
- colloq/_app/web/dist/assets/CellOutputs-Dl-AZL45.js.gz +0 -0
- colloq/_app/web/dist/assets/CommandPalette-DhBGho7_.js +3 -0
- colloq/_app/web/dist/assets/CommandPalette-DhBGho7_.js.br +0 -0
- colloq/_app/web/dist/assets/CommandPalette-DhBGho7_.js.gz +0 -0
- colloq/_app/web/dist/assets/CompetitionsScreen-Vu3RpHmf.js +3 -0
- colloq/_app/web/dist/assets/CompetitionsScreen-Vu3RpHmf.js.br +0 -0
- colloq/_app/web/dist/assets/CompetitionsScreen-Vu3RpHmf.js.gz +0 -0
- colloq/_app/web/dist/assets/ConsoleView-B2uyYvFh.js +1 -0
- colloq/_app/web/dist/assets/ConsoleView-B2uyYvFh.js.br +0 -0
- colloq/_app/web/dist/assets/ConsoleView-B2uyYvFh.js.gz +0 -0
- colloq/_app/web/dist/assets/ConsoleView-DLbp0Jul.css +1 -0
- colloq/_app/web/dist/assets/DependencyBundleCard-B392XMVZ.js +4 -0
- colloq/_app/web/dist/assets/DependencyBundleCard-B392XMVZ.js.br +0 -0
- colloq/_app/web/dist/assets/DependencyBundleCard-B392XMVZ.js.gz +0 -0
- colloq/_app/web/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
- colloq/_app/web/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
- colloq/_app/web/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
- colloq/_app/web/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
- colloq/_app/web/dist/assets/PultWindow-B3cc8537.css +1 -0
- colloq/_app/web/dist/assets/PultWindow-B3cc8537.css.br +0 -0
- colloq/_app/web/dist/assets/PultWindow-B3cc8537.css.gz +0 -0
- colloq/_app/web/dist/assets/PultWindow-DPOUhSqt.js +8 -0
- colloq/_app/web/dist/assets/PultWindow-DPOUhSqt.js.br +0 -0
- colloq/_app/web/dist/assets/PultWindow-DPOUhSqt.js.gz +0 -0
- colloq/_app/web/dist/assets/ReaderScreen-CV_0VAvW.js +9 -0
- colloq/_app/web/dist/assets/ReaderScreen-CV_0VAvW.js.br +0 -0
- colloq/_app/web/dist/assets/ReaderScreen-CV_0VAvW.js.gz +0 -0
- colloq/_app/web/dist/assets/SessionScreen-BrcB6CLH.js +132 -0
- colloq/_app/web/dist/assets/SessionScreen-BrcB6CLH.js.br +0 -0
- colloq/_app/web/dist/assets/SessionScreen-BrcB6CLH.js.gz +0 -0
- colloq/_app/web/dist/assets/SessionScreen-Dek9dK3C.css +1 -0
- colloq/_app/web/dist/assets/SessionScreen-Dek9dK3C.css.br +0 -0
- colloq/_app/web/dist/assets/SessionScreen-Dek9dK3C.css.gz +0 -0
- colloq/_app/web/dist/assets/actions-DLPyolgo.js +1 -0
- colloq/_app/web/dist/assets/admin-en-BHmR7Co0.js +9 -0
- colloq/_app/web/dist/assets/admin-en-BHmR7Co0.js.br +0 -0
- colloq/_app/web/dist/assets/admin-en-BHmR7Co0.js.gz +0 -0
- colloq/_app/web/dist/assets/admin-ru-Dr6Z36Zi.js +11 -0
- colloq/_app/web/dist/assets/admin-ru-Dr6Z36Zi.js.br +0 -0
- colloq/_app/web/dist/assets/admin-ru-Dr6Z36Zi.js.gz +0 -0
- colloq/_app/web/dist/assets/codemirror-6LUh0q1l.js +32 -0
- colloq/_app/web/dist/assets/codemirror-6LUh0q1l.js.br +0 -0
- colloq/_app/web/dist/assets/codemirror-6LUh0q1l.js.gz +0 -0
- colloq/_app/web/dist/assets/competitions-en-zw57uAGL.js +5 -0
- colloq/_app/web/dist/assets/competitions-en-zw57uAGL.js.br +0 -0
- colloq/_app/web/dist/assets/competitions-en-zw57uAGL.js.gz +0 -0
- colloq/_app/web/dist/assets/competitions-ru-BBQUjAx0.js +5 -0
- colloq/_app/web/dist/assets/competitions-ru-BBQUjAx0.js.br +0 -0
- colloq/_app/web/dist/assets/competitions-ru-BBQUjAx0.js.gz +0 -0
- colloq/_app/web/dist/assets/editor-theme-CVYtpZDy.js +1 -0
- colloq/_app/web/dist/assets/editor-theme-CVYtpZDy.js.br +0 -0
- colloq/_app/web/dist/assets/editor-theme-CVYtpZDy.js.gz +0 -0
- colloq/_app/web/dist/assets/index-CwbYK31L.css +1 -0
- colloq/_app/web/dist/assets/index-CwbYK31L.css.br +0 -0
- colloq/_app/web/dist/assets/index-CwbYK31L.css.gz +0 -0
- colloq/_app/web/dist/assets/index-Di16xdmz.js +2 -0
- colloq/_app/web/dist/assets/index-Di16xdmz.js.br +0 -0
- colloq/_app/web/dist/assets/index-Di16xdmz.js.gz +0 -0
- colloq/_app/web/dist/assets/katex-CS_ezkZp.css +1 -0
- colloq/_app/web/dist/assets/katex-CS_ezkZp.css.br +0 -0
- colloq/_app/web/dist/assets/katex-CS_ezkZp.css.gz +0 -0
- colloq/_app/web/dist/assets/katex-DLorvT8M.js +257 -0
- colloq/_app/web/dist/assets/katex-DLorvT8M.js.br +0 -0
- colloq/_app/web/dist/assets/katex-DLorvT8M.js.gz +0 -0
- colloq/_app/web/dist/assets/pdf-DEp6sk-T.js +55 -0
- colloq/_app/web/dist/assets/pdf-DEp6sk-T.js.br +0 -0
- colloq/_app/web/dist/assets/pdf-DEp6sk-T.js.gz +0 -0
- colloq/_app/web/dist/assets/persistence.svelte-vcuCBcT5.js +1 -0
- colloq/_app/web/dist/assets/persistence.svelte-vcuCBcT5.js.br +0 -0
- colloq/_app/web/dist/assets/persistence.svelte-vcuCBcT5.js.gz +0 -0
- colloq/_app/web/dist/assets/reader-en-UCGE3U6V.js +3 -0
- colloq/_app/web/dist/assets/reader-en-UCGE3U6V.js.br +0 -0
- colloq/_app/web/dist/assets/reader-en-UCGE3U6V.js.gz +0 -0
- colloq/_app/web/dist/assets/reader-ru-CaiNj0K8.js +3 -0
- colloq/_app/web/dist/assets/reader-ru-CaiNj0K8.js.br +0 -0
- colloq/_app/web/dist/assets/reader-ru-CaiNj0K8.js.gz +0 -0
- colloq/_app/web/dist/assets/render-CjP3RuRT.js +155 -0
- colloq/_app/web/dist/assets/render-CjP3RuRT.js.br +0 -0
- colloq/_app/web/dist/assets/render-CjP3RuRT.js.gz +0 -0
- colloq/_app/web/dist/assets/render.svelte-B4_hAJmX.js +0 -0
- colloq/_app/web/dist/assets/render.svelte-B4_hAJmX.js.br +0 -0
- colloq/_app/web/dist/assets/render.svelte-B4_hAJmX.js.gz +0 -0
- colloq/_app/web/dist/assets/room-en-Bh8L6NcO.js +3 -0
- colloq/_app/web/dist/assets/room-en-Bh8L6NcO.js.br +0 -0
- colloq/_app/web/dist/assets/room-en-Bh8L6NcO.js.gz +0 -0
- colloq/_app/web/dist/assets/room-ru-Bar2GgS0.js +3 -0
- colloq/_app/web/dist/assets/room-ru-Bar2GgS0.js.br +0 -0
- colloq/_app/web/dist/assets/room-ru-Bar2GgS0.js.gz +0 -0
- colloq/_app/web/dist/assets/seminar-link-CRaZl9Ku.js +1 -0
- colloq/_app/web/dist/assets/seminar-link-CRaZl9Ku.js.br +0 -0
- colloq/_app/web/dist/assets/seminar-link-CRaZl9Ku.js.gz +0 -0
- colloq/_app/web/dist/assets/seminar-link-DoTugxxA.css +1 -0
- colloq/_app/web/dist/assets/seminar-link-DoTugxxA.css.br +0 -0
- colloq/_app/web/dist/assets/seminar-link-DoTugxxA.css.gz +0 -0
- colloq/_app/web/dist/assets/yjs-rjB3hdnL.js +6 -0
- colloq/_app/web/dist/assets/yjs-rjB3hdnL.js.br +0 -0
- colloq/_app/web/dist/assets/yjs-rjB3hdnL.js.gz +0 -0
- colloq/_app/web/dist/fonts/JetBrainsMono-OFL.txt +93 -0
- colloq/_app/web/dist/fonts/hse-sans-400.woff2 +0 -0
- colloq/_app/web/dist/fonts/hse-sans-600.woff2 +0 -0
- colloq/_app/web/dist/fonts/hse-sans-700.woff2 +0 -0
- colloq/_app/web/dist/fonts/hse-sans-900.woff2 +0 -0
- colloq/_app/web/dist/fonts/jetbrains-mono-cyrillic-ext.woff2 +0 -0
- colloq/_app/web/dist/fonts/jetbrains-mono-cyrillic.woff2 +0 -0
- colloq/_app/web/dist/fonts/jetbrains-mono-latin-ext.woff2 +0 -0
- colloq/_app/web/dist/fonts/jetbrains-mono-latin.woff2 +0 -0
- colloq/_app/web/dist/index.html +268 -0
- colloq/_app/web/dist/og/colloq.png +0 -0
- colloq/_app/web/dist/pdf/pdf.worker.min.mjs +29 -0
- colloq/_app/web/dist/pdf/pdf.worker.min.mjs.br +0 -0
- colloq/_app/web/dist/pdf/pdf.worker.min.mjs.gz +0 -0
- colloq/_app/web/dist/plotly/plotly.min.js +3620 -0
- colloq/_app/web/dist/plotly/plotly.min.js.br +0 -0
- colloq/_app/web/dist/plotly/plotly.min.js.gz +0 -0
- colloq/_version.py +5 -0
- colloq-0.2.0.dist-info/METADATA +124 -0
- colloq-0.2.0.dist-info/RECORD +224 -0
- colloq-0.2.0.dist-info/WHEEL +4 -0
- 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"
|