Границы компонентов
В установке на k3s приложение работает с UID 1000 без Docker socket и Kubernetes credentials. Только приватный runtime broker получает namespaced service-account token и создаёт Pod/Service из фиксированного шаблона и доверенного каталога. API не принимает произвольный Pod template или путь хоста. На готовом образе, который рекомендуется для вузов, устройство другое: приложение держит Docker socket, то есть root на VM, — см. «Свой сервер в вузе».
Каждая комната получает отдельный Jupyter Pod, токен и workspace subPath. Если runtime, образ или политика недоступны, запуск не происходит: перехода к общему ядру сервера нет.
Ограничения Pod
Room Pod работает с UID/GID 1000, без дополнительных capabilities и повышения привилегий, с RuntimeDefault seccomp и read-only корневой файловой системой. Service-account token не монтируется. Записываемые области: каталог комнаты, /tmp, /home/runner и /dev/shm.
Pod Security требует restricted-профиль. RBAC брокера ограничен операциями с Pod и Service в namespace; работающий Pod брокер может изменить только через подресурс pods/resize, которым он меняет память комнаты. Но каждое поле создаваемого Pod RBAC ограничить не умеет, поэтому код брокера, его шаблоны и каталог входят в доверенную часть системы.
Сеть: проверяйте фактический результат
NetworkPolicy разрешает вход к Jupyter только приложению и брокеру. Исходящий трафик комнат по умолчанию разрешён только к DNS Pod в kube-system по TCP/UDP 53. Для загрузки внешних данных нужен отдельно спроектированный outbound proxy или профиль.
Если нужна изоляция от host-сервисов, добавьте CNI с host policy или проверенную firewall-политику с точечным разрешением брокеру доступа к Kubernetes API. До допуска недоверенных пользователей выполните реальный room-to-node connection test. Наличие манифеста не является доказательством.
Файлы и квоты
Linux production использует доступ через доверенные файловые дескрипторы, O_NOFOLLOW и /proc/self/fd. Символические ссылки не обходят каталог комнаты. Если защищённый механизм недоступен, production отказывает в работе.
Старый workspace не должен содержать hardlink между комнатами. При миграции копируйте обычные файлы в отдельные каталоги и проверьте владельцев и ссылки. Два имени существующего общего inode не превращаются в независимые данные от проверки пути.
Размер PVC — метаданные планирования, а не filesystem quota. MAX_UPLOAD_MB и MAX_SESSION_MB ограничивают операции приложения и не сдерживают произвольную запись Python. Используйте отдельный раздел и мониторинг свободного места.
Соревнования: что закрыто и что нет
Посылка соревнования исполняется в одноразовом контейнере: своём на каждую посылку, без сети, с данными только на чтение, под тем же укреплением, что и комната (uid 1000, без capabilities, no-new-privileges, предел процессов, корень только на чтение, пределы памяти и ядер). После посылки контейнер сносится.
Скрытые ответы (solution.csv) и код вашей метрики лежат в каталоге соревнования внутри DATA_DIR и никогда — в рабочих папках занятий. В контейнер с тетрадью участника они не попадают вовсе: метрика считается вторым контейнером, где уже нет ни данных участника, ни его кода. Зерно деления строк не уезжает ни одной дверью /api/k. Скрытый тест участнику не отдаётся; скачивают его только преподаватели соревнования — в его материалах или ссылкой в строке файла, — и каждое скачивание записывается в «Журнал действий». Файла ответов среди материалов нет: его содержимое не уходит с сервера ни одной ссылкой.
Машины для посылок
Машина для посылок исполняет тетради посылок соревнований этого сервера у себя — в той же одноразовой песочнице без сети, что и сервер. Ей уходят тетради посылок, которые она исполняет, открытые данные соревнования и готовые наборы пакетов его участников. Скрытый тест получает только доверенная машина, а доверие по умолчанию выключено. Скрытые ответы, код метрики, счёт, имена, почта, ключи входа и база данных на машину не уходят никогда: метрику по-прежнему считает сервер. Помните, что в тетради участника может быть всё, что он туда написал.
Код и секрет. Подключаясь, машина предъявляет код владельца — 128 бит, на заданное число машин и не дольше суток; сервер хранит только его отпечаток, а выпускает код только владелец. Действующий код владелец может отменить раньше срока («Отменить» в списке «Коды подключения»), а «Отключить все» отменяет и все действующие коды: иначе тот, у кого код остался, подключил бы по нему новую машину. В ответ машина получает собственный секрет на 256 бит; сервер хранит его sha256 и сравнивает за постоянное время. Секрет ездит только в заголовке Authorization и только к /api/runners/v1: ни одна другая дверь сервера — панель, страница соревнования, комнаты, WebSocket — его не принимает, а дверь машин не принимает ни cookie преподавателя, ни ключа участника, ни токена комнаты. Токен relay на машину не попадает: она не открывает ни одного адреса и сама приходит к серверу.
Отключение. «Отключить» в панели действует со следующего запроса машины: сервер отвечает 401, её прогоны возвращаются в очередь без потерь для участников, поздние результаты не принимаются, а агент удаляет скрытый тест и останавливается. Снятое доверие так же возвращает её прогоны со скрытым тестом в очередь, обрывает его скачивание и удаляет тест с машины. Выпуск кода — в панели или командой colloq-host runner-code, — отмена кодов, подключение машины, доверие ей или его снятие и отключение попадают в «Журнал действий». В журнале у кода, выпущенного командой, и у машины, подключённой по нему, вместо человека стоит «Консоль сервера»; в списке «Коды подключения» такой код помечен «выпущен на консоли сервера».
Что возвращается, сервер принимает как данные участника: только два файла с известными именами, пределы размера после распаковки, сверка sha256, раскладка вне каталогов попыток и проверка, что результат — к той самой попытке. Тексты для участника, в том числе короткие при скрытом тесте, сервер пишет сам.
Машины с занятиями
Машина, которой открыли занятия, видит всё, что есть у её классов: каждую ячейку и вывод тетрадей класса и личных тетрадей, копию папки занятия, терминал и память ядер — в том числе ключ или пароль, вставленный в ячейку. Того, что у посылок остаётся только на сервере, здесь нет: занятие на машине — целиком. Поэтому занятия открывает только владелец, для каждой машины отдельно, и по умолчанию они выключены. Машине с занятиями не дают скрытый тест: вместе с «Доверенная» флаг не ставится, а машине, на которой ещё остались контейнеры занятий, прогонов со скрытым тестом не дают.
Чего машина не видит. Ключа подписи ссылок (SESSION_SECRET), базы, других занятий, cookie преподавателей, ключа модели ИИ и токена relay. Токен Jupyter контейнера класса на машине выводится из ключа подписи — свой для каждого класса, машины и поколения, — так что машина, с которой ядро переехало, не откроет его на новом месте.
Песочница — та же, что на сервере. Агент запускает ядра классов теми же аргументами Docker, что и сервер (uid 1000, без capabilities, no-new-privileges, потолок процессов, без IPv6), и закрывает их сеть теми же правилами — в собственных цепочках iptables (COLLOQ-MR<метка>-*) и сети colloq-mrooms-<метка>. Режим сети сервера — нижняя граница: машина может быть строже (COLLOQ_ROOM_NETWORK=none, свои закрытые диапазоны), но не мягче — open на машине не действует, а закрытые диапазоны сервера (KERNEL_BLOCKED_CIDRS) едут к машине с каждым ядром. Правила проверяются каждую минуту; после трёх неудач подряд машина сама останавливает все контейнеры классов, и занятия переезжают. Rootless Docker и Docker без iptables занятий не получают. Прежде чем машина возьмёт класс, одноразовый контейнер с защитой класса проверяет песочницу изнутри: не root, без capabilities и повышения прав, не видны Docker socket и файлы агента, недоступны метаданные облака, сама машина, её локальная сеть, IPv6 и порт чужого класса. Jupyter ядер слушает только 127.0.0.1 машины, а из каталога агента в контейнеры классов монтируется только папка самого класса — и в контейнер консилиума ещё три его собственные вещи: снимки тетради только для чтения (/colloq/epochs), папки попыток (/colloq/scratch) и, тоже только для чтения, замок, который пускает попытку в ядро исполнителя, только когда её вход удался (/colloq/gate и /etc/ipython/ipython_kernel_config.py). Каталог папок попыток принадлежит root, права 1733: исполнитель создаёт в нём папку своей попытки и входит в неё по имени, но не видит соседних и не может сменить права каталога. Таким его делает перед каждым запуском контейнера консилиума одноразовый контейнер из образа самого агента: у агента нет своего root, а uid у него тот же, что у ядер.
Туннель и секрет машины. Тот же секрет машины на 256 бит открывает и её туннель, WebSocket /api/runners/v1/tunnel, — тоже только в заголовке Authorization и только у машины, которой открыты занятия. По туннелю идут лишь байты соединений, которые открывает сервер, к портам ядер, которые агент записал сам: команд по нему нет, и ничего другого на машине через него не достать. Секретом машина достаёт только до своих занятий и только на текущем поколении: файл папки занятия сервер отдаёт ей, только если сам положил его в пуш этой машине, а изменения ушедшего поколения не принимает (запоздалый файл получает 409). То, что, по словам машины, изменили её ядра, сервер применяет только поверх версии, о которой стороны договорились последней: если файл с тех пор изменился здесь, остаётся версия сервера, а версия с машины ложится рядом копией. Мелкий файл приходит в самом отчёте, а байты крупного сервер принимает, только если сам их попросил. Всё, что машина присылает, сервер принимает как данные участника: путь и тип проверяются заново, ссылки не создаются, суммы и размеры сверяются, пределы занятия действуют, файлы тетрадей не принимаются.
Отключение. «Отключить» и «Не принимать занятия» сначала переносят занятия машины, потом закрывают её туннель, а агент удаляет контейнеры классов и копии папок. Включение и выключение занятий, «Вернуть занятиям» и «Перенести занятия» попадают в «Журнал действий», а замены туннеля с обоими адресами, переезды и карантин — в события на странице машины; частые замены с разных адресов поднимают баннер (см. перехват связи).
Права, ссылки и секреты
Ссылка комнаты даёт доступ к занятию; преподавательская ссылка даёт доступ к панели. Setup-token позволяет владельцу восстановить контроль. Храните секреты и backup приватно; полный доступ к диску сервера означает доступ к данным и ключам.
Отдельные ответы Консилиума не дают каждому студенту отдельной песочницы: в общем ядре попытки идут одна за другой в ядре тетради, а в пуле на машине исполнители — процессы одного контейнера под одним uid. Правило «Опасные команды» отказывает в том, чем ядро гасят по любопытству (exit(), os._exit(), %reset, !kill -9 -1, rm -rf корня занятия), но это лежачий полицейский, а не песочница: ctypes, fork-бомба и терминал комнаты его обходят. Ограничения действий и browser-ban не являются проверкой личности. Автоматического оценивания, SSO и защиты экзаменационного исполнения система не обещает.
Пределы развёртывания
Контейнеры разделяют Linux-ядро VM. Это не изоляция виртуальными машинами и не защита от уязвимостей ядра. Одна VM, один узел k3s и local PV не дают HA. Потеря машины требует внешнего backup; обновления прерывают работу и теряют память Python.
Отправная точка по ресурсам, сценарии и формула расчёта памяти — в разделе «Машина и ресурсы». Цифры там выведены из пределов в коде, а не из нагрузочного теста, поэтому проверяйте их на своих занятиях: нагрузку определяют вычисления, вывод, файлы, число активных комнат и сеть.
Занятие на своём компьютере
Локальный запуск — colloq start или make dev — слушает только 127.0.0.1; наружу занятие видно лишь после явного colloq host. Ядра комнат — отдельные Docker-контейнеры, а сервер управляет ими через Docker daemon, то есть с правами, равными root на этой машине. На macOS каждый сегмент пути открывается с O_NOFOLLOW, но без Linux-механизма /proc/self/fd между проверкой и операцией остаётся окно гонки.
Каждый контейнер комнаты получает один и тот же укреплённый профиль, как бы ни было запущено занятие (colloq start, make dev, make run, make up, образ vast.ai): UID 1000, никаких Linux capabilities, no-new-privileges, профиль seccomp Docker по умолчанию, потолок процессов (KERNEL_PIDS, по умолчанию 512) и никакого IPv6. Личные тетради студентов считаются во втором контейнере занятия — с тем же профилем, но со своим потолком процессов (KERNEL_OWN_PIDS, по умолчанию 2048), своим токеном Jupyter и без GPU занятия. Интернет комнате доступен — pip install, датасеты, API, — а локальные адреса нет: 10/8, 172.16/12, 192.168/16, 100.64/10, link-local 169.254/16 с метаданными облака, loopback, multicast, сам компьютер и соседние комнаты. Соединение туда сразу получает отказ. Под make up и в образе vast.ai комнаты делят сеть Docker с сервером, и трафик внутри этой сети фильтруется, только если хост пропускает мостовой трафик через iptables — обычно Docker так и настраивает; иначе между комнатами остаётся токен Jupyter каждой комнаты. Открытым до любого адреса остаётся только DNS, порт 53: на части машин резолвер Docker пересылает запросы из сети самой комнаты.
Запрет ставит сам сервер: короткоживущий привилегированный служебный контейнер добавляет правила в собственные цепочки COLLOQ-ROOMS-*, в которые ведут DOCKER-USER и INPUT, — тем же iptables, которым пользуется Docker daemon. Так устроено для colima, Docker Desktop и Docker в Linux; rootless Docker, podman и Docker Desktop с Enhanced Container Isolation служебный контейнер не пускают. Если правила не встали, комнаты не запускаются, а сообщение в комнате объясняет почему. Доверенному занятию, которому нужна локальная сеть, запрет снимает строка COLLOQ_ROOM_NETWORK=open в .env, и colloq doctor тогда об этом напоминает. Контейнер комнаты, поднятый прежней версией, сохраняет старый профиль, пока не остановится; следующий запуск пересоздаёт его.
В отличие от production, корневая файловая система остаётся доступной для записи — ради %pip install, — а сколько диска займёт комната, ничто не ограничивает. Поэтому локальное занятие по-прежнему рассчитано на доверенный периметр «мой класс на моём компьютере»; для открытой аудитории используйте серверную установку.