FreeBSD virtual environment management and repository

2020-10 upd: we reached the first fundraising goal and rented a server in Hetzner for development! Thank you for donating !

Строим свой Jail-based хостинг на FreeBSD

Введение

Данная статья может служить отправной точкой построения хостинга для разработчиков внутри вашей компании, парнеров компании или просто друзей (и подруг, конечно).

Исходные данные

Будем рассматривать случай, когда нужен хостинг чуть более сложнее классического shared/vhost-инга, где клиентов также будет обслуживать 1 движек MySQL, 1 WEB сервер nginx и 1 демон sshd на каждой ноде (что не мешает вам масштабировать такие ноды в неограниченном количестве). Однако, даже внутри компании можно ожидать удара с тыла и непосредственно процессы сотрудников (клиентов) будут запущены в jail. Кроме этого, jail позволяет нам наложить квоты на ресурсы и реплицицировать файловую систему индивидуальных клеток через ZFS send на slave-ноду, легко мигрировать в случае чего "неудобный" по нагрузке jail на другую систему и предоставить клиенту возможность использовать файловые снапшоты. Кроме этого, ZFS позволит предоставить файловую квоту для MySQL баз данных на пользователя.

В примере будет фигурировать лишь одна нода хостинга, но подразумеваем, что с ростом компании ее ресурсы будут легко исчерпаны, что спровоцирует потребность в установке дополнительных серверов. Поэтому изначально, чтобы не мучиться с заведением, блокированием и изменением эккаунтов и паролей на большом количестве систем, договоримся, что авторизация будет централизована через LDAP сервер (который было бы неплохо продублировать репликой на второй LDAP ввиду особой важности сервиса).

Суммируем по пунктам те требования, которые нам необходимо провернуть:

  • Заведение и удаление базы данных, vhost, эккаунта пользователя, создание/удаление клетки - все должно происходить в автоматическом режиме;
  • Клиенты могут иметь доступ к MySQL через PhpMyAdmin (PMA);
  • Клетки будут иметь отдельный профиль, по которому они формируются с заранее прописанным программным обеспечением ( php + модули );
  • Пользовательский процесс php работает в jail, на него могут быть установлены лимиты по числу открытых сокетов/файлов, памяти, процессорному времени и файловой квоты;
  • Движек базы данных Mysql - один, но при этом, на него должны распространяться квоты файловой системы, выданной клиенту;
  • Централизованная авторизация;

Примечание: все действия автор производит на "домашнем" тестовом стенде, состоящим из двух систем: home.my.domain - сервер хостинга (на котором, впрочем, запущена kde4 откуда производится работа) и asus.my.domain - это Asus EEPC 901, нетбук, который в квартире автора находится где-то чулане. Ввиду низкого электропотребления он всегда включен и не смотря на крайне ограниченные ресурсы, успешно выполняет хостинг сервисов, которые всегда нужны - кеширующий + DDNS DNS сервер, Redmine для записок, RSS агрегатор с выводом в WEB для планшета и тп. Так или иначе, данный нетбук прекрасно подойдет на роль вынесенного сервера авторизации и будет хостить еще и Ldap server.

DDNS (dns jail) обслуживает зону .my.domain и необходим ввиду того, что автор часто создает клетки взаимодействующие друг с другом и постоянно править /etc/hosts по всем клеткам - тяжело. Поэтому, все jail при запуске и останове автоматически вписывают или выписывают свое имя в зону через Dynamic DNS и остальные, поскольку используют этот DNS, их видят. Настройки и самого DDNS касаться не будем, поскольку в статье он не фигурирует, но по ходу примеров предполагаем, что они уже внесены в DNS (или /etc/hosts)

Кроме этого, предполагаем что на обоих системах уже установлена CBSD не ниже 10.0.6 (от этой версии необходим лишь автоматический поиск первого доступного IP для cbsd ip pool)

Архитектура и алгоритм работы

В примере используем двухнодувую конфигурацию, где asus.my.domain будет хостить лишь LDAP сервер учетных записей пользователей.

Вторая система asus.my.domain является первой нодой для хостинга клеток, MySQL сервера, WEB-сервера. Кроме того, сделаем из нее простенькую "админку", как пример какого-то единственного интерфейса, с которым (и только с ним) будем работать по окончанию работ по автоматизации всех процессов.

Мы не хотим давать vhost-ам (каждой клетке) внешние IP, поэтому, клиенты будут соединяться на IP адрес ноды. В следствии чего, обслуживать их будет лишь один sshd сервис.

Есть несколько вариантов обеспечить доступ в jail через единственный сервис sshd, например можно установить модуль pam_jail, который по соответствующей записи в homedir эккаунта будет "прокидывать" авторизованного клиента в его персональный jail. Вы воспользуемся обычным chroot параметров в OpenSSH и обеспечим sftp/scp-only доступ к docroot данным внутри клиентского jail.

Такой же выбор есть для WEB сервера. Можно запускать внутри клеток отдельный и персональный для каждого клиента nginx, что может быть избыточно, либо воспользоваться одним nginx, установленнным на balancer-сервере или установив в мастер-ноду хостинга как и MySQL. При этом, статику клиента WEB сервер будет брать с файловой системы jail напрямую, а PHP скрипты будет приземлять в апстрим в виде php-fpm через Unix-socket, который открыт внутри jail.

Для того, чтобы у нас появилась возможность ставить квоту на объем базы данных MySQL, расположим данные клиентской базы внутри Jail (каждый jail создается на собственном ZFS fileset-е, на который мы поставим квоту). Для того, чтобы клиентская база становилась недоступна когда клетка погашена, пропишем хуки при jstart и jstop в CBSD на установку и снятие симлинка на базу. И, кроме всего прочего, GRANT-ы на соединение с базой данных клиента будем устанавливать _только_ для IP непосредственно клетки клиента и PMA клетки, которой клиент может пользоваться.

Схематично, это выглядит следующим образом:

Для реализации данной схемы (в частности, распределение и постановка задач по созданию jail, ldap, mysqldb и тд) напишем shell-скрипты, которые будут отрабатывать на входе по единственному конфигурационному файлу, полученному от WEB интерфейса.

Данные скрипты опубликованы на GitHub в виде cbsd-dummyhosting. В статье не будем выводить их содержимое, ввиду того, что они написаны только как дополнение этой статьи. В реальных же условиях, системным инженерам желательно посмотреть в сторону message queue сервисов, например таких AMQP-реализаций как CrossroadIO, 0mq, RabbitMQ и т.п. Один из таких вариантов доступен в виде модуля под именем jailhosting. Но для понимания, стоящие задачи и алгоритм работы функционала, который реализуют эти скрипты -- опишем.

Минимальная вводная динамическая информация, которую мы будем получать от админки следующая:

  • First name: человеческое имя клиента;
  • Last name: человеческая фамилия;
  • Mail: человечья почта;
  • Shell: стандарный Unix shell эккаунта (в нашем случае роли играть не будет, но для демонстрации запрета шелла, выставим в /bin/csh;
  • sftp/scp/ssh login: системный эккаунт/логин клиента;
  • sftp/scp/ssh password: пароль для системного эккаунта;
  • primary mysql db: имя базы данных. В нашем примере, у клиента будет только одна база;
  • primary mysql pw: пароль для базы данных;
  • jail IPs: IP клетки. Поскольку наша цель - автоматизировать все что можно, будем использовать служебное значение DHCP - тогда CBSD будет новому клиенту сама находить свободный следующий IP из отведенного пула;

Соответственно, если разбить эту большую задачу на маленькие, их можно попробовать сформулировать так:

  • используя параметр login как идентификатор клиента, сформировать LDIF файл (параллельно сформировав из Plain password) по {SHA} (password scheme) для Ldap;
  • отправить данный файл в клетку LDAP;
  • в клетке LDAP выполнить ldapadd на данный LDIF;
  • создать базу данных в MySQL;
  • создать эккаунт пользователя в MySQL;
  • применить заранее определенные привилегии для клиента на соответствующую базу данных;
  • импортировать (или создать из темплейта) новую клетку для клиента, выдать ей какой-либо IP, сконфигурировать PAM авторизацию на Ldap сервер (в примере не будет востребован);
  • Перенести базу данных MySQL клиента на файловую систему jail;
  • Запустить клетку, при этом слинковав базу данных в "движек";

При этом, желательно помнить, что мы живем не в идеальном мире и что какой-то шаг (например, канал связи мигнул и передача LDIF файла в удаленную клетку ldapsrv не прошла) может не отработать, поэтому, задачи будем формировать в неких spool каталогах, из которых сценарий удаляется только если он выполнен.

Блок-схема создания эккаунтов, реализованная для статьи на sh:

По это же схеме будет происходить и уничтожение пользователя со всем его хозяйством.

CBSD профиль

Наши клетки для хостинга будут отличаться от "обычных" тем, что мы задействуем параметр exec_master_poststop и exec_master_prestart в своих корыстных целях, дабы при запуске и остановке клетки, база клиента становилась недоступной. Для этого создадим в $workdir/etc/ файл jail-freebsd-ownhosting.conf на основе ${workdir}/etc/defaults/jail-freebsd-default.conf следующего содержания:

	jail_profile="hosting"                                                                                                                                                                                                                                                         
	exec_master_poststop="rm -f /var/db/mysql/${jname}";                                                                                                                                                                                                                           
	exec_master_prestart="ln -sf /usr/jails/jails-data/${jname}-data/var/db/mysql/${jname} /var/db/mysql/${jname}";                                                                                                                                                                
	default_jailname="cl"                                                                                                                                                                                                                                                          
	default_domain="hosting.com"
	

Этот профиль нам понадиться, если мы будем создавать клетки в ручном варианте, поскольку при выполнении cbsd jconstruct-tui появятся настройки данного профиля. В частности, помимо exec_master_poststop и exec_master_prestart, профиль предлагает другие имена клеток (clX вместо jailX) и другой домен (hosting.com вместо my.domain):

Автоматика будет генерировать свой .jconf, в котором эти параметры продублируем.

Конфигурирование MySQL

Устанавливаем в мастер-ноде MySQL сервер, назначаем пользователю root пароль, удаляем возможность соединятся с базой с пустым паролем:

	% pkg install mysql55-server
	% sysrc mysql_enable="YES"
	% service mysql-server start
	% mysql
	mysql> GRANT ALL PRIVILEGES ON *.* TO root@'%' IDENTIFIED BY 'zelepuka';
	mysql> connect mysql;
	mysql> DELETE FROM USER WHERE Password='';
	mysql> FLUSH PRIVILEGES;
	^D
	

В данном примере '%' разрешает соединение пользователю root с любых хостов используя пароль zelepuka. Используйте собственный пароль, более хитрый.

Конфигурирование Nginx

Устанавливаем в мастер-ноде Nginx, который будет обслуживать статику клиентов напрямую, php скрипты приземлять в апстримы php-fpm внутри клеток и кроме этого, обслуживать страничку заведения усеров для нас.

В реальном хостинге вы можете предпочесть ставить Nginx в качестве беленсеров (и возможно, не в одном варианте а в режиме отказоусточивости через CARP) в виде отдельного сервера.

	% pkg install nginx
	% sysrc nginx_enable="YES"
	

Минимальное содержимое /usr/local/etc/nginx/nginx.conf:

	user  nobody;
	worker_processes  4;  <<- используйте свои, более подходящие значения;

	error_log /var/log/httpd/nginx.err;
	pid /var/run/nginx.pid;

	events {
		worker_connections   40000; <<- используйте свои, более подходящие значения;
		kqueue_changes  1024;  <<- используйте свои, более подходящие значения;
		use kqueue;
	}

	http {
		server {
			listen *:80 default;
			listen [::]:80 default ipv6only=on;
		}
		include vhosts/*.conf;
	}
	

Минимальное содержимое /usr/local/etc/nginx/vhosts/admin.conf:

	server {
		listen [::]:80;
		listen *:80;
		server_name  cbsd.localhost;
		root /usr/jails/modules/dummyhosting/web;
		set $php_root $document_root;

		location ~ \.php$ {
			include php-core.conf;
		}

		location / {
			index adduser.php;
		}
	}
	

Запускаем WEB:

	% service nginx start
	

Конфигурирование OpenLDAP сервера

Клетка с LDAP сервером и WEB интерфейсом присутствует в репозитории CBSD, поэтому заходим на наш второй сервер, скачиваем темплейт ldapsrv, даем начальные параметры (Ldap OU, Manager password и IP клетки)

	% cbsd repo action=get sources=img name=ldapsrv ver=10.0
	% cbsd jconfig jname=ldapsrv ldapsrvinstall
	

Либо без диалога:

	% cbsd jconfig jname=ldapsrv ldapsrvinstall cnpw=superpass fqdn=ldapsrv.my.domain ldapsuffix="dc=hosting,dc=com"
	

В примере административный пароль - superpass. Используйте собственный пароль, более мудрый.

И запускаем:

	% cbsd jstart ldapsrv
	

После этого, по URL http://ldapsrv.my.domain вы сможете заводить пользователей вручную через PhpLdapAdmin (что для данного примера с хостингом нам не понадобится)

Ldapize

Для того, чтобы сама клетка ldapsrv имела представление о LDAP эккаунтах (например, для поиска следующего свободного UID для нового клиена через pw usernext), ее также необходимо сконфигурировать на LDAP сервер.

Это можно сделать с помощью хелпера cbsd ldapize, которая произведет все модификации автоматически, либо выполнить вручную. Здесь опишем пример ручной пример для большего понимания. К тому же, эту же операцию необходимо будет проделать в мастере ноды, которая размещает на себе хостинг - тогда, WEB интерфейс будет находить первое свободное имя для клиента автоматически.

Устанавливаем необходимые модули:

		% pkg install nss_ldap pam_ldap
	

И модифицируем следующие файлы, заменяя 192.168.1.125 на IP вашего LDAP сервера

  • /usr/local/etc/ldap.conf:
     URI ldap://192.168.1.125
     base dc=hosting,dc=com
     ldap_version 3
     scope sub
     bind_timelimit 3
     bind_policy soft
     timelimit 3
     idle_timelimit 3
    
     pam_password SSHA
    
     pam_filter              objectclass=posixAccount
     pam_check_host_attr     yes
     pam_login_attribute     uid:caseExactMatch:
     pam_member_attribute    memberUid
    
     pam_lookup_policy no
    
     nss_base_group ou=People,dc=hosting,dc=com
     nss_base_netgroup ou=People,dc=hosting,dc=com
     nss_initgroups_ignoreusers root,ldap
     nss_connect_policy oneshot
    	
  • /usr/local/etc/nss_ldap.conf
     URI ldap://192.168.1.125
     base ou=People,dc=hosting,dc=com
     ldap_version 3
    
     timelimit 3
     bind_timelimit 3
     bind_policy soft
     idle_timelimit 3
    	
  • /etc/nsswitch.conf
     group: files ldap
     passwd: files ldap
    	
  • /usr/local/etc/openldap/ldap.conf
     nss_reconnect_tried     2
     pam_login_attributes    uid
     pam_member_attributes   cn
     pam_password            SSHA
     nss_base_passwd         ou=People,dc=hosting,dc=com
     nss_base_shadow         ou=People,dc=hosting,dc=com
    
     TIMEOUT 3
     TIMELIMIT       3
    	
  • /etc/pam.d/ssh
     auth            sufficient      /usr/local/lib/pam_ldap.so      no_warn try_first_pass
     auth            required        pam_unix.so             no_warn try_first_pass
     account         sufficient      /usr/local/lib/pam_ldap.so      no_warn ignore_authinfo_unavail ignore_unknown_user
     account         required        pam_unix.so
     session         required        pam_permit.so
     password        sufficient      /usr/local/lib/pam_ldap.so      no_warn try_first_pass
     password        required        pam_unix.so             no_warn try_first_pass
    	

    После этих модификаций, ваша система будет интегрирована с LDAP сервером и на такие системные команды как id, finger и тд. будут подгружаться эккаунты из LDAP

    Кроме этих действий, непосредственно в клетке ldapsrv нам необходимо поместить скрипты для обработки каталога спула, куда будут поступать задачи по заведению или удалению пользователей со стороны админки. Для этого, находясь в клетке LDAP сервера, скачиваем архив скриптов. Предполагаем, что директория со скриптами будет находится по пути /root/dummyhosting:

    	% cd /root
    	% fetch http://www.bsdstore.ru/modules/dummyhosting.tgz
    	% tar xfz dummyhosting.tgz
    	

    и прописываем в crontab запуск обработчика каталога dummyhosting/ldap_adduser:

    	% crontab -e
    	
    	* * * * * /usr/bin/lockf -s -t0 /tmp/to_ldapsrv.lock /root/dummyhosting/ldap_adduser/bin/go > /dev/null  2>&1
    	

Шаблон для создания клеток

При деплое новых окружений, вы можете использовать несколько вариантов в качестве источника данных новой клетки. Самое простое - это создать заранее "образцовый" jail, установить в нем весь необходимый софт и при создании нового клиента, просто делать jimport, постфактум поменяв какие-то уникальные для каждой клетки данные. Второй вариант - каждый раз создавать клетку с нуля. В статье будет описан этот случай, поскольку мы сможем использовать pkglist для списка ПО, которое необходимо установить в jail. В этом случае, вы всегда будете ставить свежий софт (если репозиторий обновляем), в то время как преднастроенный имидж вам придется периодически запускать, обновлять и экспортировать вновь.

Заведением клеток занимается скрипт go, расположенный в каталоге to_cbsdjail/bin и при генерации использующий следующий empty.jconf, расположенный в каталоге to_cbsdjail/skel:

jname="%%JNAME%%";
path="/usr/jails/jails/%%JNAME%%";
host_hostname="%%HOST_HOSTNAME%%";
ip4_addr="%%IP4_ADDR%%";
mount_devfs="1";
allow_mount="1";
allow_devfs="1";
allow_nullfs="1";
mount_fstab="/usr/jails/jails-fstab/fstab.%%JNAME%%";
arch="native";
mkhostsfile="1";
devfs_ruleset="4";
ver="native";
basename="";
baserw="0";
mount_src="0";
mount_obj="0";
mount_kernel="0";
mount_ports="1";
astart="1";
data="/usr/jails/jails-data/%%JNAME%%-data";
vnet="0";
applytpl="1";
mdsize="0";
rcconf="/usr/jails/jails-rcconf/rc.conf_%%JNAME%%";
floatresolv="1";

exec_start="/bin/sh /etc/rc"
exec_stop="/bin/sh /etc/rc.shutdown"

exec_poststart="0";
exec_poststop="";
exec_prestart="0";
exec_prestop="0";

exec_master_poststart="0";
exec_master_poststop="rm -f /var/db/mysql/%%JNAME%%";
exec_master_prestart="ln -sf /usr/jails/jails-data/%%JNAME%%-data/var/db/mysql/%%JNAME%% /var/db/mysql/%%JNAME%%";
exec_master_prestop="0";

Обратите внимание на параметры arch и ver. Значение в них могут быть "i386", "amd64" и "9.2", "10.0", "10.1" "11" и тд , в зависимости от той версии базы, которую вы предпочтете для клеток. Значение native заставляет CBSD брать всегда ту версию и архитектуру, на которой запущена нода, что делает версию плавающей, если вы перейдете с FreeBSD 10.2 на 11.0, то будет использоваться база 11.0, если вы хотите зафиксировать конкретную версию - укажите вместо native

По даному темплейту отработает cbsd jcreate, при этом будет применен альтернативный путь skel/empty в этом каталоге в качестве skeldir при создании клетки. Вы можете отредактировать темплейт to_cbsdjail/skel/empty.jconf, выставив свои предпочтения и пути. Если же пути вас устраивают, отредактируйте содержимое в каталоге to_cbsdjail/skel/empty, поскольку данные файлы перезапишут оригинальные при создании клетки. В первую очередь это относится к файлам паролей

Софт в создаваемых клетках

Как упоминалось в параграфе выше, вы можете добавить pkglist= аргумент в .jconf, в котором будет перечислен требуемой клетки софт. Вы можете использовать стандартный репозиторий FreeBSD для этого. Если вы предпочитаете для своей фермы инсталлировать свой репозиторий, вам необходимо добавить в skel каталоге клетки в /usr/loca/etc/pkg/repos/ информацию о вашем репозитории. Также, вы можете просто сделать зеркало пекеджей на локальной машине (или собирать репозиторий на ней), чтобы установка пакетов при разворачивании клетки была максимально быстрой. В этом случае, в pkg.conf для клетки нужно использовать в качестве URL: file:/// и путь к каталогу с packagesite.xml

Запуск скриптов по обработке задач

Кроме обработчика задач в клетке ldapsrv, который мы прописали при конфигурировании LDAP сервера, нам нужно прописать в запуск такие же обработчики задач на мастер-ноде.

Скрипты написаны так, что каждую задачу обслужиает собственный скрипт. Запускаем

	% crontab -e

и добавляем запуск нужных нам скриптов:

* * * * * /usr/bin/lockf -s -t0 /tmp/dispatch.lock /root/dummyhosting/dispatcher/bin/go > /dev/null  2>&1
* * * * * /usr/bin/lockf -s -t0 /tmp/to_cbsdjail.lock /root/dummyhosting/to_cbsdjail/bin/go > /dev/null  2>&1
* * * * * /usr/bin/lockf -s -t0 /tmp/to_ldapsrv.lock /root/dummyhosting/to_ldapsrv/bin/go > /dev/null  2>&1
* * * * * /usr/bin/lockf -s -t0 /tmp/to_mysqlsrv.lock /root/dummyhosting/to_mysqlsrv/bin/go > /dev/null  2>&1

Конфигурация OpenSSH

Сконфигурируем OpenSSH таким образом, чтобы все наши клиенты по маске от имени логина имели scp/sftp доступ в клетку, при этом домашний каталог являлся для них корнем и вход по SSH был возможен только для sftp подсистемы OpenSSH:

	% vi /etc/sshd/sshd_config

Дописываем файл следующими строчками

	Match User cl*
		ChrootDirectory /usr/jails/jails/%u%h
        	PasswordAuthentication yes
        	ForceCommand internal-sftp
        	X11Forwarding no
        	AllowTCPForwarding no

Сохраняем, применяем:

	% service sshd restart

После чего, наш хостинг готов на 99%

WEB форма

Напишем HTML WEB форму, которая на выходе сгенерирует ascii файл в каталог /root/dummyhosting/dispatch/add с заполненными перечисленными выше полями:

результат html
<form action="adduser-fromargs.php" method="post">
<label for="firstname">First name:</label>
<input type="text" name="firstname" value="" size="15"/>
<label for="lastname">Last name:</label>
<input type="text" name="lastname" value="" size="15"/>
<label for="mail">Mail:</label>
<input type="text" name="mail" value="" size="15"/>
<label for="shell">Shell:</label>
<input type="text" name="shell" value="/bin/csh" size="15"/>
<label for="login">sftp/ssh login:</label>
<input type="text" name="login" value="cl1" size="15"/>
<label for="masterpass">sftp/ssh password:</label>
<input type="text" name="masterpass" value="HxurVQOTvE8qdyX" size="15"/>
<label for="mysqldb">primary mysql db:</label>
<input type="text" name="mysqldb" value="cl1_db" size="15"/>
<label for="mysqlpw">primary mysql pw:</label>
<input type="text" name="mysqlpw" value="xZziC7La3cDSAVe" size="15"/>
<label for="ip4_addr">jail IP4,IP6 addr:</label>
<input type="text" name="ip4_addr" value="DHCP" size="15"/>
<input type="submit" name="create" value="Create User" >
<button onclick="goBack()">Go Back</button>
</form>

adduser-fromargs.php:

<?php
$savedir="/root/dummyhosting/dispatch/add";

$firstname = $_POST['firstname'];
$lastname = $_POST['lastname'];
$mail = $_POST['mail'];
$shell = $_POST['shell'];
$login = $_POST['login'];
$masterpass = $_POST['masterpass'];
$mysqldb = $_POST['mysqldb'];
$mysqlpw = $_POST['mysqlpw'];
$ip4_addr = $_POST['ip4_addr'];

$str = <<<EOF
firstname="$firstname"
lastname="$lastname"
mail="$mail"
shell="$shell"
login="$login"
masterpass="$masterpass"
mysqldb="$mysqldb"
mysqlpw="$mysqlpw"
ip4_addr="$ip4_addr"
EOF;

$fp = fopen("$savedir/$login.add", "w+");
if (!$fp) {
	echo "Error fopen $savedir/$login.add for write";
	exit(1);
}

if (!fwrite($fp,$str)) echo "Error write to $savedir/$login.add";
	fclose($fp);
echo "Stored in $savedir/$login.add";
?>

Проверка

WIP

Эпилог

WIP

Описание работы скриптов

WIP