Я разворачиваю внутреннюю базу знаний на чистой Ubuntu Server 26.04. В качестве базы данных использую Percona Server for MySQL 8.4 LTS, перед PHP-FPM 8.5 ставлю Nginx, а MediaWiki настраиваю так, чтобы читать статьи могли посетители, а создавать и изменять их — только администратор.
В этом руководстве я прохожу установку вручную: добавляю репозитории, устанавливаю пакеты, создаю базу, записываю конфигурации и проверяю результат. Все файлы, необходимые для описанного варианта, приведены в статье. Для ежедневного резервного копирования отдельно создаю служебную программу и запускаю её через cron.
Содержание
1. Что я собираюсь получить
| Параметр | Мой вариант |
|---|---|
| Операционная система | Ubuntu Server 26.04 LTS, включая 26.04.1 |
| Ресурсы | 4 ГБ RAM и 2 vCPU |
| Адрес сервера | 172.16.2.246 |
| Приложение | MediaWiki 1.46.0 |
| База данных | Percona Server for MySQL 8.4 LTS |
| Веб-сервер | Nginx из репозитория Ubuntu |
| PHP | PHP-FPM 8.5 |
| Кэширование | OPcache, APCu и закрытый Memcached |
| Адреса статей | /Название_статьи |
| Протоколы | HTTP и HTTPS без принудительного переключения |
| Сертификат | Самоподписной, на 9000 дней, с IP в SAN |
| Резервные копии | Каждый день в 03:30, один архив, хранение 7 дней |
Для одной статьи — «Аудит прав пользователей, групп и групповых политик в домене» — я дополнительно включаю пароль Nginx. Воспроизводимый пример использует логин
root и пароль root. Это отдельная учётная запись HTTP Basic Auth: она не связана с системным root, root базы данных или администратором MediaWiki.Эта комбинация версий относится к конфигурации на сентябрь 2026 года. В статье я сохраняю MediaWiki 1.46.0; при установке позднее отдельно проверяю её поддержку и исправления безопасности. Требования PHP и БД сверяю с матрицей совместимости MediaWiki.
Границы этой конфигурации. По HTTP данные, включая Basic Auth и сеансовые cookie, передаются без шифрования. Для входа администратора я использую HTTPS и доверенный на клиенте сертификат. Самоподписной сертификат сначала вызывает предупреждение браузера. Пароль
root оставляю здесь для воспроизведения примера; на доступном другим людям сервере задаю собственный длинный пароль. Отключение автообновлений означает, что устанавливать исправления мне придётся вручную.Запрет исходников ниже относится к викитексту и интерфейсам его получения. HTML, который браузер уже получил для отображения публичной страницы, посетитель всё равно может посмотреть и сохранить.
2. Подготавливаю чистую Ubuntu
Сервер у меня выделен только под эту вики. На нём ещё нет Nginx, PHP-FPM, MySQL/MariaDB, базы MediaWiki и каталога
/var/www/mediawiki. IP я назначаю при установке Ubuntu либо заранее закрепляю в настройках сети. Шлюз и DNS беру из своей сети: адрес сервера сам по себе не позволяет их определить.Перехожу в root-сессию:
Код:
sudo -i
umask 077
Проверяю версию системы, адрес, память, процессоры и время:
Код:
cat /etc/os-release
ip -br -4 address
free -h
nproc
timedatectl
Дальнейшие команды выполняю в этой же Bash-сессии. Переменные
WORK, SECRET_DIR и другие используются в следующих шагах. После переподключения их нужно задать заново. Блоки вставляю по очереди, сохраняя переводы строк; при ошибке сначала разбираюсь с ней.Обновляю индекс пакетов и ставлю инструменты:
Код:
apt-get update
apt-get install -y --no-install-recommends ca-certificates curl openssl gnupg lsb-release nano software-properties-common
add-apt-repository -y universe
apt-get update
Universe нужен, в частности, для используемых PHP-модулей и headers-more. Для загружаемых файлов создаю отдельный каталог:
Код:
WORK=$(mktemp -d /root/mediawiki-manual.XXXXXXXX)
printf '%s\n' "$WORK"
Для копий выбираю 03:30 по времени сервера. Если мне нужен московский часовой пояс, задаю его явно:
Код:
timedatectl set-timezone Europe/Moscow
Для другого часового пояса использую своё значение; к установке MediaWiki конкретный часовой пояс не привязан.
3. Подключаю репозиторий Percona
Использую официальный репозиторий именно для Ubuntu 26.04, кодовое имя
resolute. Порядок подключения основан на руководстве Percona для APT.
Код:
export PERCONA_TELEMETRY_DISABLE=1
curl --http1.1 --fail --show-error --location --proto '=https' --proto-redir '=https' --retry 5 --retry-all-errors --connect-timeout 20 --max-time 1800 https://repo.percona.com/apt/percona-release_latest.generic_all.deb -o "$WORK/percona-release.deb"
apt-get install -y --no-install-recommends "$WORK/percona-release.deb"
percona-release setup -y ps-84-lts --scheme https
percona-release enable ps-84-lts release --scheme https
apt-get update
apt-cache policy percona-server-server
В Candidate ожидаю пакет ветки 8.4 для resolute. Для этого варианта использую 8.4.11-11 или более свежий выпуск 8.4. Пакет от другой Ubuntu сюда не подставляю.
До установки сервера БД закрываю его сетевой доступ. Создаю каталог и открываю файл:
Код:
install -d -m 755 /etc/mysql/mysql.conf.d
nano /etc/mysql/mysql.conf.d/zz-mediawiki.cnf
Пока записываю настройки, которые должны действовать уже при первом старте Percona:
Код:
[mysqld]
skip_networking=ON
mysqlx=0
local_infile=OFF
percona_telemetry_disable=ON
innodb_buffer_pool_size=1024M
max_connections=40
innodb_flush_log_at_trx_commit=1
sync_binlog=1
binlog_expire_logs_seconds=604800
Код:
chmod 644 /etc/mysql/mysql.conf.d/zz-mediawiki.cnf
Доступ root к Percona настраиваю через
auth_socket. Следующие пустые поля debconf относятся к установщику Percona: для проверенной ветки пакетов они выбирают аутентификацию системного root через сокет. После установки я обязательно проверю получившийся плагин.
Код:
debconf-set-selections <<'DEBCONF'
percona-server-server percona-server-server/root-pass password
percona-server-server percona-server-server/re-root-pass password
percona-server-server percona-server-server/lowercase-table-names select Disabled
DEBCONF
4. Устанавливаю Percona, Nginx, PHP и зависимости
Сначала проверяю подходящую версию пакета:
Код:
PS_PACKAGE_VERSION=$(LC_ALL=C apt-cache policy percona-server-server | awk '/Candidate:/ {print $2}')
printf '%s\n' "$PS_PACKAGE_VERSION"
dpkg --compare-versions "$PS_PACKAGE_VERSION" ge 8.4.11-11
Продолжаю только при версии
8.4.*resolute* и успешной проверке минимальной версии. Устанавливаю весь стек одной операцией:
Код:
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends \
"percona-server-server=$PS_PACKAGE_VERSION" nginx \
php8.5-fpm php8.5-cli php8.5-mysql php8.5-intl php8.5-mbstring \
php8.5-xml php8.5-curl php8.5-gd php8.5-apcu php8.5-memcached \
python3 memcached cron logrotate apache2-utils \
libnginx-mod-http-headers-more-filter
apache2-utils нужен для утилиты htpasswd; сам веб-сервер Apache я не устанавливаю. Модуль headers-more беру из пакетов Ubuntu, а не собираю поверх другого Nginx.Установочные процедуры пакетов могли запустить стандартные службы. Останавливаю Nginx, PHP-FPM и общий Memcached до завершения конфигурации вики:
Код:
systemctl disable --now nginx php8.5-fpm memcached.service
systemctl disable --now percona-telemetry-agent.service
unlink /etc/nginx/sites-enabled/default
На чистой Ubuntu
default — стандартная символическая ссылка. Если её уже нет, удалять нечего. Конфигурацию самой вики я подключу позднее, когда она будет полной.Проверяю PHP и расширения:
Код:
php8.5 -v
php8.5 -r 'foreach (["mysqli","intl","mbstring","dom","curl","gd","apcu","memcached","Zend OPcache"] as $e) { if (!extension_loaded($e)) { fwrite(STDERR,"Missing extension: $e\n"); exit(1); } } echo "PHP extensions: OK\n";'
dpkg-query -W libnginx-mod-http-headers-more-filter
5. Завершаю настройку базы данных
Для администратора БД использую системный root:
Код:
mysql --no-defaults --protocol=socket --socket=/run/mysqld/mysqld.sock --user=root -e "SELECT VERSION(), @@version_comment; SELECT User,Host,plugin FROM mysql.user WHERE User='root';"
Ожидаю Percona Server 8.4 и
auth_socket для root@localhost. Если вывод другой, дальше не продолжаю: сначала проверяю пакет и настройки аутентификации.Агент телеметрии уже отключён. Проверяю отдельный компонент в самой БД:
Код:
mysql --no-defaults --protocol=socket --socket=/run/mysqld/mysqld.sock --user=root -e "SELECT component_urn FROM mysql.component WHERE component_urn='file://component_percona_telemetry';"
Только если запрос вернул эту строку, выполняю:
Код:
mysql --no-defaults --protocol=socket --socket=/run/mysqld/mysqld.sock --user=root -e "UNINSTALL COMPONENT 'file://component_percona_telemetry';"
Создаю журнал медленных запросов:
Код:
install -d -o mysql -g adm -m 750 /var/log/mysql
touch /var/log/mysql/mediawiki-slow
chown mysql:adm /var/log/mysql/mediawiki-slow
chmod 640 /var/log/mysql/mediawiki-slow
nano /etc/mysql/mysql.conf.d/zz-mediawiki.cnf
Теперь полное содержимое файла у меня такое:
Код:
[mysqld]
skip_networking=ON
mysqlx=0
local_infile=OFF
percona_telemetry_disable=ON
innodb_buffer_pool_size=1024M
max_connections=40
innodb_flush_log_at_trx_commit=1
sync_binlog=1
binlog_expire_logs_seconds=604800
slow_query_log=ON
slow_query_log_file=/var/log/mysql/mediawiki-slow
long_query_time=1
log_queries_not_using_indexes=OFF
log_output=FILE
Код:
chmod 644 /etc/mysql/mysql.conf.d/zz-mediawiki.cnf
mysqld --validate-config
systemctl enable mysql
systemctl restart mysql
mysql --no-defaults --protocol=socket --socket=/run/mysqld/mysqld.sock --user=root -e "SELECT @@skip_networking, @@local_infile, @@percona_telemetry_disable, @@innodb_buffer_pool_size, @@max_connections, @@innodb_flush_log_at_trx_commit, @@sync_binlog;"
Ожидаемые значения:
1, 0, 1, 1073741824, 40, 1, 1. База принимает соединения через Unix-сокет. Буфер InnoDB занимает 1 ГиБ; настройки сохранения транзакций оставлены с синхронизацией на диск. Эти размеры я использую как начальный профиль для 4 ГБ RAM, а не как обещание максимальной скорости при любой нагрузке.6. Скачиваю и проверяю MediaWiki
Архив беру с выбранного зеркала, а подпись и ключи — с сайтов Wikimedia:
Код:
curl --http1.1 --fail --show-error --location --proto '=https' --proto-redir '=https' --retry 5 --retry-all-errors --connect-timeout 20 --max-time 1800 https://sysadmin.guru/static/mediawiki-1.46.0.tar.gz -o "$WORK/mediawiki-1.46.0.tar.gz"
curl --http1.1 --fail --show-error --location --proto '=https' --proto-redir '=https' --retry 5 --retry-all-errors --connect-timeout 20 --max-time 1800 https://releases.wikimedia.org/mediawiki/1.46/mediawiki-1.46.0.tar.gz.sig -o "$WORK/mediawiki-1.46.0.tar.gz.sig"
curl --http1.1 --fail --show-error --location --proto '=https' --proto-redir '=https' --retry 5 --retry-all-errors --connect-timeout 20 --max-time 1800 https://www.mediawiki.org/keys/keys.txt -o "$WORK/wikimedia-keys.txt"
mkdir -m 700 "$WORK/gnupg"
gpg --batch --homedir "$WORK/gnupg" --import "$WORK/wikimedia-keys.txt"
gpg --batch --homedir "$WORK/gnupg" --verify "$WORK/mediawiki-1.46.0.tar.gz.sig" "$WORK/mediawiki-1.46.0.tar.gz"
К распаковке перехожу только после успешной проверки подписи.
BAD signature или ошибка GPG останавливает этот шаг. Предупреждение об отсутствии личной цепочки доверия к импортированному ключу отличается от неверной подписи: сами ключи я получил с официального сайта через проверенное HTTPS-соединение.Выходной файл у
curl задан явно. Это позволяет избежать ситуации, когда новая загрузка сохранилась как имя.1, а следующая команда использовала старое имя.7. Создаю пользователя Linux и каталоги
PHP-FPM будет работать от отдельного системного пользователя
mediawiki. Код вики оставляю во владении root; приложению разрешаю запись в загрузки и свой кэш.
Код:
useradd --system --user-group --home-dir /var/lib/mediawiki --shell /usr/sbin/nologin mediawiki
install -d -m 755 /var/www/mediawiki
tar -xzf "$WORK/mediawiki-1.46.0.tar.gz" --strip-components=1 --no-same-owner -C /var/www/mediawiki
chown -R root:root /var/www/mediawiki
find /var/www/mediawiki -type d -exec chmod 755 {} +
find /var/www/mediawiki -type f -exec chmod 644 {} +
install -d -o mediawiki -g mediawiki -m 750 /var/lib/mediawiki /var/cache/mediawiki
install -d -o mediawiki -g www-data -m 2750 /var/www/mediawiki/images
Эти команды относятся к только что распакованной вики. Владельцев и права уже существующей установки по этому блоку я не переустанавливаю.
8. Создаю базу и устанавливаю MediaWiki через CLI
Для приложения создаю БД
mediawiki и пользователя mediawiki@localhost. Пароль генерирую случайно; в историю команд его не записываю.
Код:
SECRET_DIR=$(mktemp -d /run/mediawiki-manual.XXXXXXXX)
DB_PASSWORD=$(openssl rand -hex 32)
printf '%s' "$DB_PASSWORD" > "$SECRET_DIR/db-password"
mysql --no-defaults --protocol=socket --socket=/run/mysqld/mysqld.sock --user=root <<SQL
CREATE DATABASE mediawiki CHARACTER SET binary;
CREATE USER 'mediawiki'@'localhost' IDENTIFIED WITH caching_sha2_password BY '$DB_PASSWORD';
GRANT ALL PRIVILEGES ON mediawiki.* TO 'mediawiki'@'localhost';
SQL
unset DB_PASSWORD
На время создания таблиц у пользователя есть полные права только на свою БД. Рабочий набор прав я уменьшу после установки.
Задаю название сайта и имя администратора:
Код:
WIKI_NAME='Внутренняя база знаний'
ADMIN_NAME='WikiAdmin'
read -r -s -p 'Пароль администратора MediaWiki, от 16 символов: ' ADMIN_PASSWORD
printf '\n'
Проверяю длину, записываю пароль в закрытый временный файл и удаляю переменную:
Код:
if test "${#ADMIN_PASSWORD}" -ge 16; then
printf '%s' "$ADMIN_PASSWORD" > "$SECRET_DIR/admin-password"
unset ADMIN_PASSWORD
printf 'Пароль сохранён во временный файл.\n'
else
printf 'Пароль короче 16 символов. Повторяю ввод, установку пока не запускаю.\n'
fi
Если проверка длины завершилась неуспешно, сначала ввожу новый пароль. Затем запускаю штатную консольную установку MediaWiki:
Код:
php8.5 /var/www/mediawiki/maintenance/run.php install \
--dbtype=mysql --dbserver=localhost --dbname=mediawiki --dbuser=mediawiki \
--dbpassfile="$SECRET_DIR/db-password" --passfile="$SECRET_DIR/admin-password" \
--server=https://172.16.2.246 --scriptpath='' --lang=ru --skins=Vector \
"$WIKI_NAME" "$ADMIN_NAME"
После успешной установки проверяю, что создан
LocalSettings.php, и закрываю доступ к нему:
Код:
test -f /var/www/mediawiki/LocalSettings.php
chown root:mediawiki /var/www/mediawiki/LocalSettings.php
chmod 640 /var/www/mediawiki/LocalSettings.php
Временные пароли удаляю только после успешного завершения установки:
Код:
test -n "${SECRET_DIR:-}" && rm -f -- "$SECRET_DIR/db-password" "$SECRET_DIR/admin-password"
rmdir -- "$SECRET_DIR"
unset SECRET_DIR
Пароль приложения остаётся в
LocalSettings.php. Администратор MediaWiki входит под WikiAdmin и введённым паролем. Это третья отдельная учётная запись, помимо Linux и Percona.9. Настраиваю PHP-FPM, OPcache и журналы
Для небольшого сервера использую до четырёх процессов PHP-FPM с лимитом памяти 256 МиБ на запрос. OPcache выделяю 192 МиБ, APCu — 64 МиБ. Такое сочетание кэшей соответствует общей схеме из рекомендаций MediaWiki по производительности; конкретные объёмы памяти подбираю под свою машину.
Открываю отдельный файл настроек PHP-FPM:
Код:
nano /etc/php/8.5/fpm/conf.d/99-mediawiki.ini
Код:
expose_php=Off
display_errors=Off
log_errors=On
allow_url_fopen=Off
allow_url_include=Off
cgi.fix_pathinfo=0
session.use_trans_sid=0
opcache.enable=1
opcache.memory_consumption=192
opcache.interned_strings_buffer=16
opcache.max_accelerated_files=20000
opcache.validate_timestamps=1
opcache.revalidate_freq=2
opcache.jit=disable
apc.shm_size=64M
Для CLI создаю файл
/etc/php/8.5/cli/conf.d/99-mediawiki-security.ini:
Код:
nano /etc/php/8.5/cli/conf.d/99-mediawiki-security.ini
Код:
allow_url_fopen=Off
allow_url_include=Off
session.use_trans_sid=0
Создаю журналы и пул PHP-FPM:
Код:
install -d -o mediawiki -g adm -m 750 /var/log/mediawiki
touch /var/log/mediawiki/php-error.log /var/log/php8.5-fpm-mediawiki-slow.log
chown mediawiki:adm /var/log/mediawiki/php-error.log
chown root:adm /var/log/php8.5-fpm-mediawiki-slow.log
chmod 640 /var/log/mediawiki/php-error.log /var/log/php8.5-fpm-mediawiki-slow.log
nano /etc/php/8.5/fpm/pool.d/mediawiki.conf
Код:
[mediawiki]
user = mediawiki
group = mediawiki
listen = /run/php/php8.5-fpm-mediawiki.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
pm = dynamic
pm.max_children = 4
pm.start_servers = 2
pm.min_spare_servers = 1
pm.max_spare_servers = 2
pm.max_requests = 500
request_terminate_timeout = 120s
request_slowlog_timeout = 5s
slowlog = /var/log/php8.5-fpm-mediawiki-slow.log
clear_env = yes
security.limit_extensions = .php
php_admin_value[memory_limit] = 256M
php_admin_value[max_execution_time] = 60
php_admin_value[upload_max_filesize] = 20M
php_admin_value[post_max_size] = 25M
php_admin_value[error_log] = /var/log/mediawiki/php-error.log
Выключаю стандартный пул
www и фиксирую права конфигураций:
Код:
mv /etc/php/8.5/fpm/pool.d/www.conf /etc/php/8.5/fpm/pool.d/www.conf.disabled
chmod 644 /etc/php/8.5/fpm/pool.d/mediawiki.conf /etc/php/8.5/fpm/conf.d/99-mediawiki.ini /etc/php/8.5/cli/conf.d/99-mediawiki-security.ini
php-fpm8.5 -t
PHP-ошибки пишутся в журнал, а не в ответ посетителю. Запросы дольше пяти секунд попадают в журнал медленных запросов FPM. Значение
opcache.validate_timestamps=1 позволяет подхватывать изменения PHP-файлов; после изменения настроек я всё равно выполняю проверку и перезапуск службы. Значения директив описаны в документации PHP-FPM.10. Поднимаю закрытый Memcached
Общий сервис
memcached.service уже отключён. Для вики создаю собственный, доступный только через Unix-сокет:
Код:
nano /etc/systemd/system/mediawiki-memcached.service
Код:
[Unit]
Description=MediaWiki private Memcached
Before=php8.5-fpm.service mediawiki-jobs.service
[Service]
User=mediawiki
Group=mediawiki
RuntimeDirectory=mediawiki-memcached
RuntimeDirectoryMode=0750
ExecStart=/usr/bin/memcached -s /run/mediawiki-memcached/cache.sock -a 0600 -m 128 -c 128 -t 2 -I 4m -U 0
Restart=on-failure
NoNewPrivileges=true
PrivateTmp=true
ProtectSystem=strict
ProtectHome=true
RestrictAddressFamilies=AF_UNIX
MemoryMax=256M
[Install]
WantedBy=multi-user.target
Код:
chmod 644 /etc/systemd/system/mediawiki-memcached.service
systemctl daemon-reload
systemctl enable --now mediawiki-memcached.service
stat /run/mediawiki-memcached/cache.sock
runuser -u mediawiki -- php8.5 -r '$m=new Memcached(); $m->addServer("/run/mediawiki-memcached/cache.sock",0); $k="manual-check-".bin2hex(random_bytes(8)); if (!$m->set($k,"ok",30) || $m->get($k)!=="ok") {fwrite(STDERR,$m->getResultMessage().PHP_EOL);exit(1);} $m->delete($k); echo "Memcached: OK\n";'
Сокет принадлежит пользователю
mediawiki и имеет права 0600. Сетевого порта у этого экземпляра нет. Кэш общий для PHP-FPM и заданий MediaWiki; пользовательские сессии ниже оставляю в БД, чтобы очистка Memcached не уничтожала их.11. Записываю окончательные настройки MediaWiki
Настройки разделяю на несколько файлов. В
LocalSettings.php сохраняю созданные установкой секреты и подключаю основной профиль. Файлы с правилами доступа создаю полностью до запуска сайта.В конец
/var/www/mediawiki/LocalSettings.php, оставаясь внутри PHP, добавляю:
PHP:
$wgDefaultSkin = 'vector-2022';
$wgCookieHttpOnly = true;
$wgEnableEmail = false;
$wgEnableUploads = true;
$wgUseImageMagick = false;
$wgFileExtensions = [ 'png', 'gif', 'jpg', 'jpeg', 'webp', 'pdf' ];
$wgMaxUploadSize = 20 * 1024 * 1024;
require_once __DIR__ . '/ProductionSettings.php';
Этот фрагмент добавляю в конец, а не заменяю им весь LocalSettings.php. SMTP в этом варианте не настроен. Для растровых миниатюр использую GD; обработку миниатюр PDF отдельно не подключаю.
Создаю
/var/www/mediawiki/ProductionSettings.php:
Код:
nano /var/www/mediawiki/ProductionSettings.php
PHP:
<?php
// Managed configuration for the dedicated 4 GB / 2 CPU wiki.
$wgServer = '//172.16.2.246';
$wgCanonicalServer = 'https://172.16.2.246';
$wgForceHTTPS = false;
$wgSecureLogin = false;
$wgCookieSecure = 'detect';
$wgCookieHttpOnly = true;
$wgScriptPath = '';
$wgArticlePath = '/$1';
$wgUsePathInfo = true;
// Shared between CLI jobs and FPM. APCu remains the automatically detected local cache.
$wgMainCacheType = 'memcached-pecl';
$wgParserCacheType = 'memcached-pecl';
$wgMemCachedServers = [ '/run/mediawiki-memcached/cache.sock:0' ];
// Sessions persist across Memcached restarts and are not subject to cache eviction.
$wgSessionCacheType = CACHE_DB;
$wgCacheDirectory = '/var/cache/mediawiki';
$wgJobRunRate = 0;
$wgPingback = false;
$internalWikiWriteRights = [ 'edit', 'createpage', 'createtalk', 'createaccount',
'upload', 'reupload', 'reupload-own', 'reupload-shared', 'move', 'move-subpages',
'move-rootuserpages', 'move-categorypages', 'movefile' ];
$internalWikiGroups = array_unique( array_merge( array_keys( $wgGroupPermissions ?? [] ),
[ '*', 'user', 'autoconfirmed', 'emailconfirmed', 'bot', 'bureaucrat', 'interface-admin', 'sysop' ] ) );
foreach ( $internalWikiGroups as $internalWikiGroup ) {
foreach ( $internalWikiWriteRights as $internalWikiRight ) {
$wgGroupPermissions[$internalWikiGroup][$internalWikiRight] = ( $internalWikiGroup === 'sysop' );
}
}
unset( $internalWikiWriteRights, $internalWikiGroups, $internalWikiGroup, $internalWikiRight );
require_once __DIR__ . '/AdminSourceAccess.php';
require_once __DIR__ . '/PublicUrlSettings.php';
В этом файле право создавать, изменять, перемещать и загружать материалы получает только группа
sysop. Сам факт регистрации не даёт права редактирования. Автоматический запуск очереди заданий из веб-запросов выключен: отдельный таймер я создам ниже.Создаю
/var/www/mediawiki/PublicHosts.php:
Код:
nano /var/www/mediawiki/PublicHosts.php
PHP:
<?php
return ["172.16.2.246"];
Теперь
/var/www/mediawiki/PublicUrlSettings.php:
Код:
nano /var/www/mediawiki/PublicUrlSettings.php
PHP:
<?php
// Host names are a deployment allowlist, never arbitrary request input.
$mediawikiPublicHosts = require __DIR__ . '/PublicHosts.php';
$mediawikiCanonicalHost = $mediawikiPublicHosts[count($mediawikiPublicHosts) - 1];
$mediawikiRequestHost = $_SERVER['MEDIAWIKI_PUBLIC_HOST'] ?? '';
$wgCanonicalServer = 'https://' . $mediawikiCanonicalHost;
$wgServer = '//' . (in_array($mediawikiRequestHost, $mediawikiPublicHosts, true)
? $mediawikiRequestHost : $mediawikiCanonicalHost);
$wgScriptPath = '';
$wgArticlePath = '/$1';
$wgUsePathInfo = true;
$wgForceHTTPS = false;
$wgSecureLogin = false;
$wgCookieSecure = 'detect';
unset($mediawikiPublicHosts, $mediawikiCanonicalHost, $mediawikiRequestHost);
require_once __DIR__ . '/AuditPageAccess.php';
Здесь
$wgArticlePath = '/$1' задаёт адреса без /wiki/. Разрешённый host приходит от Nginx и проверяется по списку. Произвольный Host из запроса не становится адресом моей вики. При добавлении домена я обновлю список и сертификат отдельным шагом.12. Ограничиваю получение викитекста администраторами
Одного скрытия кнопки «Просмотр кода» недостаточно: викитекст можно запросить через raw, export, API и другие интерфейсы. Для выбранной версии MediaWiki использую локальное дополнение, которое ограничивает эти способы на сервере и убирает соответствующие элементы навигации.
Создаю
/var/www/mediawiki/AdminSourceAccess.php:
Код:
nano /var/www/mediawiki/AdminSourceAccess.php
PHP:
<?php
// Local configuration for MediaWiki 1.46.x. Load after the other LocalSettings settings.
// Restricts wikitext interfaces; rendered HTML and published files remain public.
$wgAvailableRights[] = 'internalwiki-viewsource';
foreach ( array_keys( $wgGroupPermissions ?? [] ) as $internalWikiGroup ) {
$wgGroupPermissions[$internalWikiGroup]['internalwiki-viewsource'] = false;
}
$wgGroupPermissions['*']['internalwiki-viewsource'] = false;
$wgGroupPermissions['user']['internalwiki-viewsource'] = false;
$wgGroupPermissions['sysop']['internalwiki-viewsource'] = true;
unset( $internalWikiGroup );
final class InternalWikiSourceAccess {
private const RIGHT = 'internalwiki-viewsource';
private static function allowed( $context ): bool {
// Use MediaWiki's existing permission cache, with no custom SQL queries.
return $context->getAuthority()->isAllowed( self::RIGHT );
}
private static function denyPage( $output ): bool {
$output->setStatusCode( 403 );
$output->disableClientCache();
$output->setRobotPolicy( 'noindex,nofollow' );
$output->setPageTitle( 'Доступ запрещён' );
$output->addHTML( '<p>Просмотр исходного викитекста доступен только администраторам.</p>' );
return false;
}
public static function onAction( $output, $article, $title, $user, $request, $wiki ): bool {
if ( self::allowed( $output ) ) {
return true;
}
// A diff contains wikitext even though its action is normally "view".
if ( !in_array( $request->getVal( 'action', 'view' ), [ 'view', 'render' ], true )
|| $request->getCheck( 'diff' ) || $request->getCheck( 'feed' )
) {
return self::denyPage( $output );
}
return true;
}
public static function onSpecialPage( $special, $subPage ): bool {
if ( self::allowed( $special ) ) {
return true;
}
// Canonical names also cover translated aliases and subpage URLs.
if ( in_array( $special->getName(), [
'Export', 'ExpandTemplates', 'Diff', 'ComparePages', 'ApiSandbox'
], true ) || $special->getRequest()->getCheck( 'feed' ) ) {
return self::denyPage( $special->getOutput() );
}
return true;
}
public static function onNavigation( $skin, &$links ): void {
if ( self::allowed( $skin ) ) {
return;
}
foreach ( [ 'views', 'actions' ] as $section ) {
foreach ( [ 'viewsource', 'edit', 'history' ] as $item ) {
unset( $links[$section][$item] );
}
}
}
public static function onApi( $module, $user, &$message ): bool {
if ( self::allowed( $module ) ) {
return true;
}
$name = $module->getModuleName();
// Keep search suggestions and login usable before the administrator logs in.
if ( in_array( $name, [ 'opensearch', 'login', 'clientlogin', 'logout' ], true ) ) {
return true;
}
if ( $name === 'query' ) {
// Use the API's parsed parameters, including boolean/export and multi-value syntax.
$params = $module->extractRequestParams();
$permitted = empty( $params['export'] ) && empty( $params['exportnowrap'] );
foreach ( [
'prop' => [ 'info', 'pageimages', 'pageterms' ],
'list' => [ 'search', 'prefixsearch' ],
'meta' => [ 'siteinfo', 'userinfo', 'tokens' ]
] as $kind => $allowedModules ) {
if ( array_diff( $params[$kind] ?? [], $allowedModules ) ) {
$permitted = false;
}
}
// Generator belongs to ApiPageSet, not ApiQuery's own parameter collection.
if ( !in_array( $module->getRequest()->getVal( 'generator' ),
[ null, 'search', 'prefixsearch' ], true )
) {
$permitted = false;
}
if ( $permitted ) {
return true;
}
}
// Includes revisions, compare, parse, expandtemplates, XML export and future modules.
$message = 'apierror-action-notallowed';
return false;
}
public static function onRest( $module, $handler, $path, $request, &$error ): bool {
if ( self::allowed( $handler ) ) {
return true;
}
// REST exposes both source and Parsoid HTML with editing metadata.
$error = new \MediaWiki\Rest\HttpException(
'Просмотр исходного викитекста доступен только администраторам.', 403
);
return false;
}
}
$wgHooks['MediaWikiPerformAction'][] = [ InternalWikiSourceAccess::class, 'onAction' ];
$wgHooks['SpecialPageBeforeExecute'][] = [ InternalWikiSourceAccess::class, 'onSpecialPage' ];
$wgHooks['SkinTemplateNavigation::Universal'][] = [ InternalWikiSourceAccess::class, 'onNavigation' ];
$wgHooks['ApiCheckCanExecute'][] = [ InternalWikiSourceAccess::class, 'onApi' ];
$wgHooks['RestCheckCanExecute'][] = [ InternalWikiSourceAccess::class, 'onRest' ];
Для обычного посетителя остаются чтение страниц, поиск и необходимые интерфейсы входа. API ограничен списком разрешённых операций; REST для неадминистратора закрыт. Это локальная политика моей вики, а не стандартная настройка, которую MediaWiki автоматически включает сама. После обновления ядра или установки расширений правила нужно перепроверять.
13. Дополняю пароль статьи проверками MediaWiki
Защита только одного красивого URL в Nginx не покрывает альтернативный адрес через
index.php?title=..., поиск и включение текста в другую страницу. Поэтому пароль проверяет Nginx, а MediaWiki получает от него отдельный признак успешной проверки.Создаю
/var/www/mediawiki/AuditPageAccess.php:
Код:
nano /var/www/mediawiki/AuditPageAccess.php
PHP:
<?php
// MediaWiki 1.46: complement Nginx Basic Auth for aliases, APIs and transclusion.
// Password verification is performed only by Nginx. No custom database queries.
final class InternalAuditPageAccess {
public const TITLE = 'Аудит_прав_пользователей,_групп_и_групповых_политик_в_домене';
public static function verified(): bool {
return ($_SERVER['MEDIAWIKI_AUDIT_VERIFIED'] ?? '') === '1';
}
public static function protectedTitle($title): bool {
return $title && $title->getNamespace() === NS_MAIN
&& $title->getDBkey() === self::TITLE;
}
private static function protectedText($text): bool {
return is_string($text) && self::protectedTitle(\MediaWiki\Title\Title::newFromText($text));
}
public static function beforeInitialize($title, $unused, $output, $user, $request, $entry): void {
if (self::protectedTitle($title) && !self::verified()) {
// Non-canonical URLs challenge too. On retry with Authorization,
// Nginx validates credentials before forwarding the request to PHP.
$request->response()->header('WWW-Authenticate: Basic realm="Audit article"');
$request->response()->header('Cache-Control: private, no-store');
throw new \MediaWiki\Exception\HttpError(401, 'Для этой статьи требуется пароль Nginx.');
}
}
public static function permissions($title, $user, $action, &$result): bool {
if (self::protectedTitle($title) && !self::verified()) {
$result = ['badaccess-group0'];
return false;
}
return true;
}
public static function template($context, $title, &$skip, &$revision): void {
// Never cache the protected article's text inside another public page,
// even when the editor/reader supplying the template has authenticated.
if (self::protectedTitle($title)) {
$skip = true;
$revision = null;
}
}
public static function search($term, &$titleMatches, &$textMatches): void {
if (self::verified()) {
return;
}
foreach (['titleMatches', 'textMatches'] as $which) {
$set = $$which;
if (!$set) {
continue;
}
$rows = $set->extractResults();
$visible = array_values(array_filter($rows,
static fn($row) => !self::protectedTitle($row->getTitle())));
if (count($visible) !== count($rows)) {
$$which = new \MediaWiki\Search\FauxSearchResultSet($visible,
max(count($visible), ($set->getTotalHits() ?? count($rows)) - count($rows) + count($visible)));
}
}
}
public static function afterApi($module): void {
if (self::verified()) {
return;
}
$result = $module->getResult();
foreach (['search', 'prefixsearch', 'pages'] as $collection) {
$rows = $result->getResultData(['query', $collection]);
if (!is_array($rows)) {
continue;
}
foreach ($rows as $key => $row) {
if (is_array($row) && self::protectedText($row['title'] ?? null)) {
$result->removeValue(['query', $collection], $key);
}
}
}
// OpenSearch uses parallel arrays of titles, descriptions and links.
if ($module->getModuleName() === 'opensearch') {
$titles = $result->getResultData([1]);
if (is_array($titles)) {
foreach ($titles as $key => $title) {
if (self::protectedText($title)) {
foreach ([1, 2, 3] as $column) {
$result->removeValue([$column], $key);
}
}
}
}
}
}
}
$wgHooks['BeforeInitialize'][] = [InternalAuditPageAccess::class, 'beforeInitialize'];
$wgHooks['getUserPermissionsErrors'][] = [InternalAuditPageAccess::class, 'permissions'];
$wgHooks['BeforeParserFetchTemplateRevisionRecord'][] = [InternalAuditPageAccess::class, 'template'];
$wgHooks['SpecialSearchResults'][] = [InternalAuditPageAccess::class, 'search'];
$wgHooks['APIAfterExecute'][] = [InternalAuditPageAccess::class, 'afterApi'];
Дополнение привязано к одной статье основного пространства имён. Её текст не разрешается включать в публичные страницы, даже если редактирующий их человек прошёл Basic Auth. Неавторизованные результаты поиска и API очищаются от этой статьи. Дополнительные самостоятельные SQL-запросы в обработчиках не создаются.
Если я меняю название защищаемой статьи, одинаково меняю его в константе
TITLE этого файла и в карте Nginx из следующего раздела. Опубликованные отдельно файлы и изображения в images при этом остаются публичными: правило защищает статью, а не превращает все её вложения в закрытое хранилище.Закрываю файлы настроек и проверяю синтаксис:
Код:
chown root:mediawiki /var/www/mediawiki/LocalSettings.php /var/www/mediawiki/ProductionSettings.php /var/www/mediawiki/PublicHosts.php /var/www/mediawiki/PublicUrlSettings.php /var/www/mediawiki/AdminSourceAccess.php /var/www/mediawiki/AuditPageAccess.php
chmod 640 /var/www/mediawiki/LocalSettings.php /var/www/mediawiki/ProductionSettings.php /var/www/mediawiki/PublicHosts.php /var/www/mediawiki/PublicUrlSettings.php /var/www/mediawiki/AdminSourceAccess.php /var/www/mediawiki/AuditPageAccess.php
php8.5 -l /var/www/mediawiki/LocalSettings.php
php8.5 -l /var/www/mediawiki/ProductionSettings.php
php8.5 -l /var/www/mediawiki/PublicHosts.php
php8.5 -l /var/www/mediawiki/PublicUrlSettings.php
php8.5 -l /var/www/mediawiki/AdminSourceAccess.php
php8.5 -l /var/www/mediawiki/AuditPageAccess.php
14. Выпускаю сертификат на 9000 дней
Для обращения по IP включаю адрес в
subjectAltName. Одного CN для этого недостаточно.
Код:
install -d -m 700 /etc/ssl/private
openssl req -x509 -newkey rsa:3072 -sha256 -noenc -days 9000 \
-keyout /etc/ssl/private/mediawiki.key \
-out /etc/ssl/certs/mediawiki.crt \
-subj '/CN=172.16.2.246' \
-addext 'subjectAltName=IP:172.16.2.246' \
-addext 'basicConstraints=critical,CA:FALSE' \
-addext 'keyUsage=critical,digitalSignature,keyEncipherment' \
-addext 'extendedKeyUsage=serverAuth'
chmod 600 /etc/ssl/private/mediawiki.key
chmod 644 /etc/ssl/certs/mediawiki.crt
openssl x509 -in /etc/ssl/certs/mediawiki.crt -noout -dates -ext subjectAltName -fingerprint -sha256
openssl verify -CAfile /etc/ssl/certs/mediawiki.crt -verify_ip 172.16.2.246 /etc/ssl/certs/mediawiki.crt
Клиентам передаю только
mediawiki.crt и проверяю его SHA-256-отпечаток доверенным способом. Приватный ключ остаётся на сервере. Принятие самоподписного сертификата зависит от политики браузера и клиентской системы; ниже curl проверяет его через явно заданный файл доверия.15. Настраиваю Nginx, ЧПУ, лимит и пароль статьи
Сначала создаю файл пользователей HTTP Basic Auth:
Код:
install -d -o root -g www-data -m 750 /etc/nginx/auth
htpasswd -cB -C 10 /etc/nginx/auth/mediawiki-audit.htpasswd root
chown root:www-data /etc/nginx/auth/mediawiki-audit.htpasswd
chmod 640 /etc/nginx/auth/mediawiki-audit.htpasswd
Для точного воспроизведения примера при запросе пароля ввожу
root два раза. Пароль не передаётся параметром командной строки. Опцию -c использую только при первом создании файла.Создаю
/etc/nginx/mediawiki-public-hosts.conf:
Код:
nano /etc/nginx/mediawiki-public-hosts.conf
Код:
172.16.2.246 172.16.2.246;
Создаю
/etc/nginx/mediawiki-server-names.conf:
Код:
nano /etc/nginx/mediawiki-server-names.conf
Код:
server_name 172.16.2.246;
Теперь создаю основной файл
/etc/nginx/sites-available/mediawiki. Карты map и limit_req_zone в нём находятся выше server: Ubuntu подключает sites-enabled/* внутри контекста http.
Код:
nano /etc/nginx/sites-available/mediawiki
Код:
# Ubuntu includes sites-enabled/* inside http {}. These directives must stay
# outside server {}. ResourceLoader serves CSS/JS and has no per-IP limit.
map_hash_bucket_size 512;
server_names_hash_bucket_size 512;
map $host $mediawiki_public_host {
default "";
include /etc/nginx/mediawiki-public-hosts.conf;
}
map $request_uri $mediawiki_legacy_target {
default "";
~^/wiki(/.*)$ $1;
}
map $uri $mediawiki_is_audit_uri {
default 0;
/Аудит_прав_пользователей,_групп_и_групповых_политик_в_домене 1;
}
# If credentials accompany an alternative URL/API request, Nginx verifies them
# too. PHP never trusts the unverified remote_user variable by itself.
map "$mediawiki_audit_entry:$http_authorization" $mediawiki_auth_realm {
"0:" off;
default "Audit article";
}
map "$mediawiki_auth_realm:$remote_user" $mediawiki_audit_verified {
default 0;
"Audit article:root" 1;
}
map "$uri:$mediawiki_audit_verified" $mediawiki_private_cache {
default "";
~^/(?:index|api|opensearch_desc|thumb)\.php:1$ "private, no-store";
~^/rest\.php(?:/.*)?:1$ "private, no-store";
}
map $uri $mediawiki_limit_key {
default $binary_remote_addr;
/load.php "";
}
limit_req_zone $mediawiki_limit_key zone=mediawiki_per_ip:10m rate=1r/s;
server {
listen 172.16.2.246:80;
listen 172.16.2.246:443 ssl;
http2 on;
include /etc/nginx/mediawiki-server-names.conf;
if ($mediawiki_public_host = "") { return 444; }
# Evaluate the map before location rewrites change $uri to /index.php.
set $mediawiki_audit_entry $mediawiki_is_audit_uri;
root /var/www/mediawiki;
index index.php;
autoindex off;
server_tokens off;
client_max_body_size 25m;
limit_req_status 429;
limit_req_log_level warn;
# Ubuntu package: libnginx-mod-http-headers-more-filter.
# Replace any upstream value; also applies to Nginx error responses.
more_set_headers 'X-Powered-By: https://sysadmin.guru';
more_set_headers -s 429 'Retry-After: 1';
ssl_certificate /etc/ssl/certs/mediawiki.crt;
ssl_certificate_key /etc/ssl/private/mediawiki.key;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:MediaWikiTLS:10m;
ssl_session_timeout 1h;
ssl_session_tickets off;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Cache-Control $mediawiki_private_cache always;
# MediaWiki reads the original REQUEST_URI using wgArticlePath.
# No title interpolation into a query string: &, + and Unicode titles stay intact.
location = / { rewrite ^ /index.php last; }
location = /wiki { return 308 $scheme://$mediawiki_public_host/; }
location ^~ /wiki/ {
if ($mediawiki_legacy_target = "") { return 404; }
# Keep original escaping/query; never put a decoded title into Location.
return 308 $scheme://$mediawiki_public_host$mediawiki_legacy_target;
}
location = /robots.txt {
default_type text/plain;
return 200 "User-agent: *\nDisallow: /\n";
}
location ~ (^|/)\. { return 404; }
location ~* ^/(includes|vendor|maintenance|mw-config|cache|tests|docs|sql|node_modules|serialized|backups?)(/|$) { return 404; }
location ~* /(tests|docs|node_modules|vendor)/ { return 404; }
location ^~ /images/deleted/ { return 404; }
location ^~ /images/temp/ { return 404; }
location ^~ /images/lockdir/ { return 404; }
# ResourceLoader does not serve article text; skip repeated bcrypt work for CSS/JS.
location = /load.php {
try_files $uri =404;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/load.php;
fastcgi_param HTTPS $https if_not_empty;
fastcgi_param HTTP_PROXY "";
fastcgi_param MEDIAWIKI_PUBLIC_HOST $mediawiki_public_host;
fastcgi_param MEDIAWIKI_AUDIT_VERIFIED 0;
fastcgi_pass unix:/run/php/php8.5-fpm-mediawiki.sock;
fastcgi_read_timeout 120s;
}
location ~ ^/(index|api|opensearch_desc|thumb)\.php$ {
auth_basic $mediawiki_auth_realm;
auth_basic_user_file /etc/nginx/auth/mediawiki-audit.htpasswd;
limit_req zone=mediawiki_per_ip burst=5 nodelay;
try_files $uri =404;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param HTTPS $https if_not_empty;
fastcgi_param HTTP_PROXY "";
fastcgi_param MEDIAWIKI_PUBLIC_HOST $mediawiki_public_host;
fastcgi_param MEDIAWIKI_AUDIT_VERIFIED $mediawiki_audit_verified;
fastcgi_pass unix:/run/php/php8.5-fpm-mediawiki.sock;
fastcgi_read_timeout 120s;
}
location ~ ^/rest\.php(?:/|$) {
auth_basic $mediawiki_auth_realm;
auth_basic_user_file /etc/nginx/auth/mediawiki-audit.htpasswd;
limit_req zone=mediawiki_per_ip burst=5 nodelay;
fastcgi_split_path_info ^(/rest\.php)(/.*)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/rest.php;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param HTTPS $https if_not_empty;
fastcgi_param HTTP_PROXY "";
fastcgi_param MEDIAWIKI_PUBLIC_HOST $mediawiki_public_host;
fastcgi_param MEDIAWIKI_AUDIT_VERIFIED $mediawiki_audit_verified;
fastcgi_pass unix:/run/php/php8.5-fpm-mediawiki.sock;
fastcgi_read_timeout 120s;
}
location ~* \.(php[0-9]?|phtml|phar)([./]|$) { return 404; }
location ~* ^/(composer\.(json|lock)|package(-lock)?\.json|COPYING|CREDITS|HISTORY|INSTALL|README(\..*)?|SECURITY(\..*)?)$ { return 404; }
location ~* ^/images/.+\.(png|gif|jpe?g|webp|pdf)$ {
try_files $uri =404;
expires 7d;
access_log off;
}
location ~ ^/(resources|skins|extensions)/.+\.(css|js|png|gif|jpe?g|webp|svg|ico|woff2?|ttf|eot|wasm)$ {
try_files $uri =404;
expires 7d;
access_log off;
}
location ~* ^/(images|resources|skins|extensions)(/|$) { return 404; }
# Every other URL is an article, never a file-system fallback.
location / { rewrite ^ /index.php last; }
error_page 502 504 =503 @maintenance;
location @maintenance {
default_type text/plain;
add_header Retry-After 60 always;
return 503 "Wiki maintenance in progress. Please retry shortly.\n";
}
}
Код:
chmod 644 /etc/nginx/mediawiki-public-hosts.conf /etc/nginx/mediawiki-server-names.conf /etc/nginx/sites-available/mediawiki
ln -s /etc/nginx/sites-available/mediawiki /etc/nginx/sites-enabled/mediawiki
nginx -t
Что здесь существенно для моей конфигурации:
- Один server обслуживает HTTP и HTTPS. Перенаправления между протоколами нет, HSTS не включён.
- Статьи обрабатываются через
index.phpпо исходному URI; символы&,+и кириллица не подставляются вручную в строку параметров. - Старый путь
/wiki/Названиеполучает 308 на/Названиес сохранением протокола, host, экранирования и параметров. - Динамические запросы ограничены одной общей зоной: 1 запрос/с на IP,
burst=5 nodelay, превышение — HTTP 429. Один обычный запрос и пять избыточных могут пройти сразу при пустом счётчике. Пользователи за одним NAT делят этот лимит. - ResourceLoader
load.phpи разрешённая статика не расходуют лимит. Для статики сохраняется кэширование, в том числе когда браузер уже запомнил Basic Auth. - Модуль headers-more выставляет
X-Powered-By: https://sysadmin.guruиRetry-After: 1для 429. Заголовок Server остаётся без номера версии Nginx. - PHP исполняется только через перечисленные входные файлы. Конфиги, служебные каталоги, скрытые файлы и PHP в загрузках закрыты. Неизвестный host отклоняется.
Механика ограничения описана в документации limit_req, изменение заголовков — в документации headers-more. Связку маршрутизации и настроек MediaWiki сверяю с руководством по коротким URL, адаптируя путь статей к корню сайта.
Если
nginx -t сообщает unknown directive "more_set_headers", проверяю установленный пакет и его подключение в /etc/nginx/modules-enabled/. До успешной проверки Nginx не запускаю.16. Выношу очередь заданий из веб-запросов
Создаю службу
/etc/systemd/system/mediawiki-jobs.service:
Код:
nano /etc/systemd/system/mediawiki-jobs.service
Код:
[Unit]
Description=MediaWiki job queue
After=mysql.service
Requires=mysql.service
[Service]
Type=oneshot
User=mediawiki
Group=mediawiki
WorkingDirectory=/var/www/mediawiki
ExecStart=/usr/bin/php8.5 -d memory_limit=256M maintenance/run.php runJobs --maxjobs=50 --maxtime=45 --quiet
TimeoutStartSec=120
Nice=10
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=full
И таймер
/etc/systemd/system/mediawiki-jobs.timer:
Код:
nano /etc/systemd/system/mediawiki-jobs.timer
Код:
[Unit]
Description=Run MediaWiki jobs regularly
[Timer]
OnBootSec=60s
OnUnitInactiveSec=60s
AccuracySec=10s
Unit=mediawiki-jobs.service
[Install]
WantedBy=timers.target
Задания работают от
mediawiki, за один запуск обрабатывается до 50 заданий с ограничением времени. Следующий запуск назначается через минуту после завершения предыдущего. Это таймер очереди MediaWiki; расписание резервных копий я отдельно добавлю в cron.17. Настраиваю ротацию журналов
Создаю
/etc/logrotate.d/mediawiki:
Код:
nano /etc/logrotate.d/mediawiki
Код:
/var/log/mysql/mediawiki-slow {
daily
rotate 7
missingok
notifempty
compress
delaycompress
su mysql adm
create 0640 mysql adm
postrotate
/usr/bin/mysql --no-defaults --protocol=socket --socket=/run/mysqld/mysqld.sock --user=root -e 'FLUSH SLOW LOGS' >/dev/null 2>&1 || true
endscript
}
/var/log/mediawiki/php-error.log {
daily
rotate 7
missingok
notifempty
compress
delaycompress
su mediawiki adm
create 0640 mediawiki adm
}
/var/log/php8.5-fpm-mediawiki-slow.log {
daily
rotate 7
missingok
notifempty
compress
delaycompress
create 0640 root adm
postrotate
/bin/systemctl kill --kill-who=main --signal=USR1 php8.5-fpm.service >/dev/null 2>&1 || true
endscript
}
Код:
chmod 644 /etc/logrotate.d/mediawiki
logrotate --debug /etc/logrotate.conf
Режим debug проверяет настройки без принудительной ротации. Журнал медленных SQL-запросов специально назван
mediawiki-slow без окончания .log, чтобы не пересечься с типовыми правилами для /var/log/mysql/*.log.18. Отключаю автоматические обновления
Для этой машины я выбрал ручную установку обновлений. Механизм штатных периодических обновлений описан в руководстве Ubuntu. Создаю файл
/etc/apt/apt.conf.d/99zz-mediawiki-no-auto-updates:
Код:
nano /etc/apt/apt.conf.d/99zz-mediawiki-no-auto-updates
Код:
APT::Periodic::Enable "0";
APT::Periodic::Update-Package-Lists "0";
APT::Periodic::Download-Upgradeable-Packages "0";
APT::Periodic::Unattended-Upgrade "0";
APT::Periodic::AutocleanInterval "0";
Unattended-Upgrade::Automatic-Reboot "false";
Код:
chmod 644 /etc/apt/apt.conf.d/99zz-mediawiki-no-auto-updates
systemctl disable --now apt-daily.timer apt-daily-upgrade.timer
systemctl mask apt-daily.timer apt-daily-upgrade.timer apt-daily.service apt-daily-upgrade.service
apt-config shell MW_AUTO APT::Periodic::Enable
apt-config shell MW_AUTO APT::Periodic::Unattended-Upgrade
systemctl is-enabled apt-daily.timer apt-daily-upgrade.timer apt-daily.service apt-daily-upgrade.service
Ожидаю нули в параметрах APT и
masked для перечисленных единиц. Останавливаю планирование, не прерывая уже выполняющуюся операцию apt/dpkg. Службу cron оставляю включённой.Если в системе установлен Snap, дополнительно выполняю:
Код:
snap refresh --hold=forever
snap refresh --time
На машине без Snap этот блок пропускаю. Назначение hold описано в документации Snap. Настройки относятся к штатным APT/Snap-механизмам; отдельный агент управления или собственное задание обновления потребовали бы отдельной проверки.
19. Уменьшаю права БД и запускаю сайт
Таблицы уже созданы. Оставляю приложению рабочие права на его базу:
Код:
mysql --no-defaults --protocol=socket --socket=/run/mysqld/mysqld.sock --user=root <<'SQL'
REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'mediawiki'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE TEMPORARY TABLES ON mediawiki.* TO 'mediawiki'@'localhost';
SHOW GRANTS FOR 'mediawiki'@'localhost';
SQL
Для обновления схемы эти права впоследствии придётся временно расширить. Для обычного чтения, создания и редактирования статей изменение схемы не требуется.
Проверяю конфигурации и системные единицы до запуска:
Код:
chmod 644 /etc/systemd/system/mediawiki-jobs.service /etc/systemd/system/mediawiki-jobs.timer
mysqld --validate-config
php-fpm8.5 -t
nginx -t
systemd-analyze verify /etc/systemd/system/mediawiki-jobs.service /etc/systemd/system/mediawiki-jobs.timer /etc/systemd/system/mediawiki-memcached.service
systemctl daemon-reload
systemctl enable --now mysql mediawiki-memcached.service php8.5-fpm nginx
runuser -u mediawiki -- php8.5 /var/www/mediawiki/maintenance/run.php showJobs
systemctl start mediawiki-jobs.service
systemctl enable --now mediawiki-jobs.timer
Если UFW уже включён, разрешаю веб-доступ:
Код:
ufw status
При
Status: active добавляю:
Код:
ufw allow proto tcp to 172.16.2.246 port 80
ufw allow proto tcp to 172.16.2.246 port 443
Настроенный SSH-доступ сохраняю. Включать ранее выключенный firewall без проверки правил своего SSH-порта в этот шаг не включаю.
20. Проверяю URL, заголовки и доступ
Сначала проверяю Nginx по HTTP и HTTPS на лёгком статическом ответе:
Код:
curl --noproxy '*' -sS -D - -o /dev/null http://172.16.2.246/robots.txt
curl --noproxy '*' --cacert /etc/ssl/certs/mediawiki.crt -sS -D - -o /dev/null https://172.16.2.246/robots.txt
Ожидаю 200 и
X-Powered-By: https://sysadmin.guru в обоих ответах. Самоподписной сертификат curl проверяет через --cacert.Проверяю действующий путь статей:
Код:
curl --noproxy '*' -sS 'http://172.16.2.246/api.php?action=query&meta=siteinfo&format=json' | python3 -c 'import json,sys; g=json.load(sys.stdin)["query"]["general"]; print("articlepath:",g["articlepath"],"server:",g["server"])'
Ожидаю
articlepath: /$1. Главную страницу проверяю по корневому адресу:
Код:
sleep 2
MAIN_PATH=$(php8.5 -r 'echo rawurlencode("Заглавная_страница");')
curl --noproxy '*' --cacert /etc/ssl/certs/mediawiki.crt -sS -o /dev/null -w '%{http_code}\n' "https://172.16.2.246/$MAIN_PATH"
Ожидаю 200. Старый префикс должен возвращать 308 с новым путём:
Код:
curl --noproxy '*' -sS -D - -o /dev/null http://172.16.2.246/wiki/Test
В Location ожидаю
http://172.16.2.246/Test, с тем же HTTP.Проверяю закрытый конфиг и запрет викитекста для гостя:
Код:
curl --noproxy '*' -sS -o /dev/null -w '%{http_code}\n' http://172.16.2.246/LocalSettings.php
sleep 2
curl --noproxy '*' -sS -o /dev/null -w '%{http_code}\n' 'http://172.16.2.246/Main_Page?action=raw'
Ожидаю соответственно 404 и 403. Динамические проверки разделяю паузами, чтобы собственная диагностика не упиралась в 429.
Для проверки защищённой статьи формирую URL:
Код:
AUDIT_PATH=$(php8.5 -r 'echo rawurlencode("Аудит_прав_пользователей,_групп_и_групповых_политик_в_домене");')
sleep 2
curl --noproxy '*' --cacert /etc/ssl/certs/mediawiki.crt -sS -D - -o /dev/null "https://172.16.2.246/$AUDIT_PATH"
Без пароля ожидаю 401 и
WWW-Authenticate: Basic realm="Audit article". Затем проверяю правильный пароль; curl -u root запросит его интерактивно:
Код:
sleep 2
curl --noproxy '*' --cacert /etc/ssl/certs/mediawiki.crt -u root -sS -D - -o /dev/null "https://172.16.2.246/$AUDIT_PATH"
На только что созданной вики эта статья ещё не существует. В таком случае после правильного пароля нормален ответ MediaWiki 404. После создания статьи администратором ожидаю 200. Проверяю и альтернативный адрес без пароля:
Код:
sleep 2
curl --noproxy '*' --cacert /etc/ssl/certs/mediawiki.crt -sS -o /dev/null -w '%{http_code}\n' "https://172.16.2.246/index.php?title=$AUDIT_PATH"
Он тоже должен дать 401. В браузере вхожу в MediaWiki как
WikiAdmin, создаю защищённую статью с точным названием и обычную тестовую статью. В приватном окне обычная статья должна читаться свободно, защищённая — запрашивать Basic Auth, а исходники и редактирование — оставаться недоступными без прав sysop.Сам ограничитель проверяю короткой серией запросов к защищённому адресу без пароля. Это ответы Nginx, без выполнения страницы PHP:
Код:
sleep 7
for i in {1..12}; do curl --noproxy '*' -sS -o /dev/null -w '%{http_code}\n' "http://172.16.2.246/$AUDIT_PATH"; done
В быстрой серии должны появиться 429 после допустимого всплеска. Точное количество зависит от скорости выполнения команд: счётчик освобождает место со скоростью один запрос в секунду. После проверки даю ему несколько секунд восстановиться.
21. Создаю автоматический бэкап в один архив
Для восстановления мне нужны и база, и файлы вики. Такой состав соответствует руководству MediaWiki по резервному копированию. В своём варианте дополнительно сохраняю конфигурации служб и данные для восстановления пользователя БД.
Каждая копия будет одним
.tar.gz в /var/backups/mediawiki. Внутри находятся:database.sql— полный логический дамп основной БДmediawiki: статьи, история, пользователи и остальные таблицы.accounts.sql— создание пользователя БД и его права.var/www/mediawiki— код, настройки, расширения, темы и загруженные файлы.- Конфигурации Nginx, PHP, MySQL, созданные единицы systemd и cron, TLS-сертификат и ключ, политика APT.
manifest.json,versions.txtиSHA256SUMS— сведения об архиве и контрольные суммы обычных файлов.
Программа приостанавливает PHP-FPM и очередь заданий на время дампа и упаковки, затем восстанавливает их предыдущее состояние. Проверка содержимого архива идёт уже после возобновления работы вики. При ошибке старые архивы сохраняются. Служебная программа ниже автоматизирует только копирование; установку сервера я уже выполнил вручную.
Создаю каталог и файл программы:
Код:
install -d -o root -g root -m 700 /var/backups/mediawiki
nano /usr/local/sbin/mediawiki-backup.py
Код:
#!/usr/bin/python3
"""Consistent local MediaWiki backups. Root only; no database password in argv."""
import argparse
import hashlib
import json
import os
import re
import shutil
import signal
import subprocess
import sys
import tarfile
import uuid
from datetime import datetime, timedelta, timezone
from pathlib import Path
ROOT = Path('/var/backups/mediawiki')
STATE = Path('/run/mediawiki-backup-state.json')
NAME = re.compile(r'^mediawiki-\d{4}-\d{2}-\d{2}T\d{6}Z-[a-f0-9]{8}\.tar\.gz$')
MYSQL = ['mysql', '--no-defaults', '--protocol=socket', '--socket=/run/mysqld/mysqld.sock', '--user=root']
def run(args, **kwargs):
return subprocess.run(args, check=True, **kwargs)
def prune_backups(root, now):
"""Only remove this program's completed backups, after a new success exists."""
for item in root.iterdir():
if item.is_symlink() or not item.is_file() or not NAME.fullmatch(item.name):
continue
try:
with tarfile.open(item, 'r|gz') as archive:
first = archive.next()
if first is None or first.name != 'manifest.json' or first.size > 65536:
continue
data = json.load(archive.extractfile(first))
completed = datetime.fromisoformat(data['completed_at'])
if data['kind'] != 'mediawiki-backup-v1' or completed.tzinfo is None:
continue
except (ValueError, KeyError, tarfile.TarError, EOFError, OSError):
continue
if completed < now - timedelta(days=7):
if item.resolve().parent != root.resolve():
raise RuntimeError('Unsafe backup retention path')
item.unlink()
def snapshot(root, controller, writer):
now = datetime.now(timezone.utc)
name = 'mediawiki-' + now.strftime('%Y-%m-%dT%H%M%SZ') + '-' + uuid.uuid4().hex[:8]
stage = root / ('.partial-' + name)
stage.mkdir(mode=0o700)
try:
try:
controller.pause()
writer(stage)
finally:
controller.resume()
result = root / (name + '.tar.gz')
(stage / 'backup.tar.gz').rename(result)
prune_backups(root, datetime.now(timezone.utc))
return result
finally:
if stage.is_dir() and not stage.is_symlink() and stage.parent == root:
shutil.rmtree(stage)
class Services:
units = ['mediawiki-jobs.timer', 'php8.5-fpm.service']
def pause(self):
states = {unit: subprocess.run(['systemctl', 'is-active', '--quiet', unit]).returncode == 0
for unit in self.units}
# Record recovery information BEFORE stopping anything; ExecStopPost also uses this.
STATE.write_text(json.dumps(states))
os.chmod(STATE, 0o600)
if states['mediawiki-jobs.timer']:
run(['systemctl', 'stop', 'mediawiki-jobs.timer'])
run(['systemctl', 'stop', 'mediawiki-jobs.service'])
if states['php8.5-fpm.service']:
run(['systemctl', 'stop', 'php8.5-fpm.service'])
def resume(self):
if not STATE.exists():
return
states = json.loads(STATE.read_text())
errors = []
for unit in reversed(self.units):
if states.get(unit):
try:
run(['systemctl', 'start', unit])
except subprocess.CalledProcessError:
errors.append(unit)
if errors:
raise RuntimeError('Could not resume services: ' + ', '.join(errors))
STATE.unlink()
def verify(backup):
# One streaming pass checks each archived regular file's SHA-256.
digests = {}
expected = None
with tarfile.open(backup, 'r|gz') as archive:
for member in archive:
if not member.isfile():
continue
stream = archive.extractfile(member)
if member.name == 'SHA256SUMS':
if member.size > 32 * 1024 * 1024:
raise RuntimeError('Checksum list too large')
expected = {}
for line in stream.read().decode().splitlines():
digest, filename = line.split(' ', 1)
if filename in expected:
raise RuntimeError('Duplicate checksum entry')
expected[filename] = digest
else:
digests[member.name] = hashlib.file_digest(stream, 'sha256').hexdigest()
if expected is None or digests != expected:
raise RuntimeError('Backup checksum mismatch')
required = {'database.sql', 'accounts.sql', 'manifest.json',
'var/www/mediawiki/LocalSettings.php', 'etc/nginx/sites-available/mediawiki'}
if not required <= digests.keys():
raise RuntimeError('Required files missing from backup')
def pack_archive(stage, include):
import io
digests = {}
class HashReader:
def __init__(self, stream):
self.stream = stream
self.digest = hashlib.sha256()
def read(self, length=-1):
data = self.stream.read(length)
self.digest.update(data)
return data
with tarfile.open(stage / 'backup.tar.gz', 'w:gz', compresslevel=1, dereference=False) as archive:
def add(path, name):
info = archive.gettarinfo(str(path), arcname=name)
if info.isfile():
with path.open('rb') as stream:
reader = HashReader(stream)
archive.addfile(info, reader)
digests[name] = reader.digest.hexdigest()
else:
archive.addfile(info)
if info.isdir():
for child in sorted(path.iterdir()):
add(child, name + '/' + child.name)
# Manifest FIRST lets retention inspect old copies without reading database/files.
for name in ['manifest.json', 'database.sql', 'accounts.sql', 'versions.txt']:
add(stage / name, name)
for path, name in include:
add(path, name)
data = ''.join(digest + ' ' + name + '\n' for name, digest in digests.items()).encode()
info = tarfile.TarInfo('SHA256SUMS')
info.size, info.mode = len(data), 0o600
archive.addfile(info, io.BytesIO(data))
def capture(stage):
# Cold capture also covers non-InnoDB tables, uploads, and maintenance side effects.
dump = ['mysqldump', '--no-defaults', '--protocol=socket', '--socket=/run/mysqld/mysqld.sock',
'--user=root', '--single-transaction', '--quick', '--hex-blob', '--no-tablespaces',
'--set-gtid-purged=OFF', '--default-character-set=utf8mb4', '--routines', '--events',
'--triggers', '--databases', 'mediawiki']
with (stage / 'database.sql').open('wb') as target:
run(dump, stdout=target)
with (stage / 'accounts.sql').open('w') as target:
created = run(MYSQL + ['--batch', '--skip-column-names', '--raw', '-e',
"SHOW CREATE USER 'mediawiki'@'localhost'"], capture_output=True, text=True).stdout
target.write(created.rstrip().split('\t', 1)[-1] + ';\n')
grants = run(MYSQL + ['--batch', '--skip-column-names', '--raw', '-e',
"SHOW GRANTS FOR 'mediawiki'@'localhost'"], capture_output=True, text=True).stdout
target.write(''.join(line + ';\n' for line in grants.splitlines()))
include = ['var/www/mediawiki', 'etc/nginx', 'etc/php/8.5', 'etc/mysql']
optional = ['etc/systemd/system/mediawiki-jobs.service', 'etc/systemd/system/mediawiki-jobs.timer',
'etc/systemd/system/mediawiki-backup.service', 'etc/cron.d/mediawiki-backup',
'etc/systemd/system/mediawiki-memcached.service', 'etc/logrotate.d/mediawiki',
'etc/apt/apt.conf.d/52mediawiki-security', 'etc/apt/apt.conf.d/20mediawiki-periodic',
'etc/apt/apt.conf.d/99zz-mediawiki-no-auto-updates',
'etc/systemd/system/apt-daily.timer', 'etc/systemd/system/apt-daily-upgrade.timer',
'etc/systemd/system/apt-daily.service', 'etc/systemd/system/apt-daily-upgrade.service',
'etc/ssl/private/mediawiki.key', 'etc/ssl/certs/mediawiki.crt',
'usr/local/sbin/mediawiki-backup.py', 'usr/local/sbin/mediawiki-update-schema',
'etc/tmpfiles.d/mediawiki.conf']
include += [entry for entry in optional if Path('/' + entry).exists()]
with (stage / 'versions.txt').open('w') as target:
run(['dpkg-query', '-W', 'percona-server-server', 'nginx', 'php8.5-fpm'],
stdout=target)
(stage / 'manifest.json').write_text(json.dumps({
'kind': 'mediawiki-backup-v1', 'completed_at': datetime.now(timezone.utc).isoformat(),
'database': 'mediawiki', 'wiki': '/var/www/mediawiki',
'consistency': 'PHP-FPM and scheduled jobs stopped during capture'
}, indent=2) + '\n')
pack_archive(stage, [(Path('/') / name, name) for name in include])
# Validation consumes I/O; do it after PHP-FPM has resumed (see main writer wrapper).
def main():
import fcntl
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--recover', action='store_true')
parser.add_argument('--check-latest', action='store_true')
options = parser.parse_args()
if os.geteuid() != 0:
raise RuntimeError('Run as root')
os.umask(0o077)
os.environ['PATH'] = '/usr/sbin:/usr/bin:/sbin:/bin'
with open('/run/lock/mediawiki-maintenance.lock', 'a') as lock:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
service = Services()
if options.recover:
service.resume()
return
ROOT.mkdir(mode=0o700, parents=True, exist_ok=True)
if ROOT.is_symlink() or ROOT.resolve() != ROOT or ROOT.stat().st_uid != 0:
raise RuntimeError('Backup directory must be a root-owned physical directory')
os.chmod(ROOT, 0o700)
if options.check_latest:
backups = sorted(p for p in ROOT.iterdir() if NAME.fullmatch(p.name) and not p.is_symlink())
if not backups:
raise RuntimeError('No completed backup')
verify(backups[-1])
print('Verified:', backups[-1])
return
def interrupted(signum, frame):
raise RuntimeError('Backup interrupted by signal ' + str(signum))
signal.signal(signal.SIGTERM, interrupted)
signal.signal(signal.SIGINT, interrupted)
# Keep failed recovery visible rather than overwriting its state on the next run.
service.resume()
class CaptureServices(Services):
def resume(self):
super().resume()
if capture_complete:
verify(staging[0] / 'backup.tar.gz')
staging = []
capture_complete = []
def writer(stage):
staging.append(stage)
capture(stage)
capture_complete.append(True)
result = snapshot(ROOT, CaptureServices(), writer)
print('Backup completed and verified:', result)
if __name__ == '__main__':
try:
main()
except Exception as error:
print('BACKUP FAILED:', error, file=sys.stderr)
sys.exit(1)
Проверяю синтаксис без создания pycache и закрываю файл:
Код:
chown root:root /usr/local/sbin/mediawiki-backup.py
chmod 700 /usr/local/sbin/mediawiki-backup.py
python3 -c 'from pathlib import Path; p=Path("/usr/local/sbin/mediawiki-backup.py"); compile(p.read_text(),str(p),"exec"); print("Backup syntax: OK")'
Создаю
/etc/systemd/system/mediawiki-backup.service:
Код:
nano /etc/systemd/system/mediawiki-backup.service
Код:
[Unit]
Description=Consistent MediaWiki database and file backup
After=mysql.service
Requires=mysql.service
[Service]
Type=oneshot
User=root
UMask=0077
ExecStart=/usr/bin/python3 /usr/local/sbin/mediawiki-backup.py
ExecStopPost=/usr/bin/python3 /usr/local/sbin/mediawiki-backup.py --recover
TimeoutStartSec=2h
TimeoutStopSec=180
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=full
Код:
chmod 644 /etc/systemd/system/mediawiki-backup.service
systemd-analyze verify /etc/systemd/system/mediawiki-backup.service
systemctl daemon-reload
Создаю расписание
/etc/cron.d/mediawiki-backup:
Код:
nano /etc/cron.d/mediawiki-backup
Код:
SHELL=/bin/sh
PATH=/usr/sbin:/usr/bin:/sbin:/bin
# Daily 03:30 in the server timezone. systemd provides logging and failure recovery.
30 3 * * * root /usr/bin/systemctl start mediawiki-backup.service
Код:
chown root:root /etc/cron.d/mediawiki-backup
chmod 644 /etc/cron.d/mediawiki-backup
systemctl enable --now cron
cat /etc/cron.d/mediawiki-backup
В конце файла cron сохраняю перевод строки. Задание работает от root каждый день в 03:30. Оно находится в системном
/etc/cron.d, поэтому crontab -l пользователя его не показывает. Отдельного таймера бэкапа я не создаю.Теперь запускаю первую копию: в неё уже попадёт и расписание cron.
Код:
systemctl start mediawiki-backup.service
journalctl -u mediawiki-backup.service -n 50 --no-pager
ls -lh /var/backups/mediawiki/
Ожидаю сообщение
Backup completed and verified с именем архива. Одноразовая служба после успешного выполнения может иметь состояние inactive (dead): это нормально для её типа. Результат последнего запуска смотрю так:
Код:
systemctl show mediawiki-backup.service -p Result -p ExecMainStatus
Ожидаю
Result=success и ExecMainStatus=0. После первой копии ещё раз открываю главную страницу: это проверяет и восстановление работы PHP-FPM.Хранение — 7 × 24 часа, а не ровно семь файлов. Несколько ручных запусков в один день дадут несколько архивов. Удаление старых копий выполняется только после успешной новой копии. Время в имени архива — UTC, расписание cron использует часовой пояс сервера. Если сервер был выключен в 03:30, cron пропустит этот запуск.
Архивы имеют права 0600 и содержат секреты, включая ключ HTTPS. На диске должно хватать места под старые архивы, новый архив и временный несжатый SQL-дамп. Длительность паузы в работе вики зависит от объёма данных и скорости диска. Копию хотя бы одного архива я также храню вне этого сервера.
Для отдельной проверки последнего архива использую:
Код:
/usr/local/sbin/mediawiki-backup.py --check-latest
Проверка контрольных сумм полезна, но пробное восстановление на другой машине она не заменяет.
22. Проверяю автозапуск после перезагрузки
Перед перезагрузкой смотрю состояние и автозапуск:
Код:
systemctl is-active mysql nginx php8.5-fpm mediawiki-memcached.service cron mediawiki-jobs.timer
systemctl is-enabled mysql nginx php8.5-fpm mediawiki-memcached.service cron mediawiki-jobs.timer
systemctl list-timers mediawiki-jobs.timer --all
Ожидаю активные службы и включённый автозапуск. Затем в согласованное время перезагружаю сервер:
Код:
reboot
После повторного входа проверяю те же службы, главную страницу, HTTPS, обычную и защищённую статьи. IP должен остаться прежним. Штатная служба Nginx в проверенном пакете Ubuntu уже ожидает
network-online.target.23. Подключаю домен к тому же серверу
Этот раздел выполняю, если хочу обращаться к вики ещё и по домену. В качестве примера использую
wiki.example.ru; в своей конфигурации везде заменяю его реальным именем. DNS-запись A должна указывать на 172.16.2.246 и быть доступной клиентам моей сети.В
/var/www/mediawiki/PublicHosts.php записываю:
PHP:
<?php
return ["172.16.2.246", "wiki.example.ru"];
В
/etc/nginx/mediawiki-public-hosts.conf:
Код:
172.16.2.246 172.16.2.246;
wiki.example.ru wiki.example.ru;
В
/etc/nginx/mediawiki-server-names.conf:
Код:
server_name 172.16.2.246 wiki.example.ru;
Последнее имя в PHP-массиве становится каноническим для задач CLI. Обычный веб-запрос продолжает использовать свой разрешённый host: и IP, и домен работают на HTTP и HTTPS.
Перевыпускаю сертификат с IP и DNS SAN, сохраняя существующий приватный ключ:
Код:
openssl req -new -x509 -sha256 -days 9000 \
-key /etc/ssl/private/mediawiki.key \
-out /etc/ssl/certs/mediawiki.crt \
-subj '/CN=172.16.2.246' \
-addext 'subjectAltName=IP:172.16.2.246,DNS:wiki.example.ru' \
-addext 'basicConstraints=critical,CA:FALSE' \
-addext 'keyUsage=critical,digitalSignature,keyEncipherment' \
-addext 'extendedKeyUsage=serverAuth'
chmod 644 /etc/ssl/certs/mediawiki.crt
php8.5 -l /var/www/mediawiki/PublicHosts.php
openssl verify -CAfile /etc/ssl/certs/mediawiki.crt -verify_hostname wiki.example.ru /etc/ssl/certs/mediawiki.crt
openssl verify -CAfile /etc/ssl/certs/mediawiki.crt -verify_ip 172.16.2.246 /etc/ssl/certs/mediawiki.crt
nginx -t
systemctl restart php8.5-fpm
systemctl reload nginx
После перевыпуска заново устанавливаю доверие к публичному сертификату на клиентах. Проверяю домен, даже если DNS ещё не обновился на моей машине:
Код:
curl --noproxy '*' --resolve wiki.example.ru:443:172.16.2.246 --cacert /etc/ssl/certs/mediawiki.crt -sS -D - -o /dev/null https://wiki.example.ru/robots.txt
Повторяю проверки главной страницы и пароля статьи для домена. Само правило статьи от смены host не меняется.
24. Как я обслуживаю эту установку
Для ручной копии запускаю
systemctl start mediawiki-backup.service. Журнал копирования смотрю через journalctl. Ошибки PHP находятся в /var/log/mediawiki/php-error.log, медленные PHP-запросы — в /var/log/php8.5-fpm-mediawiki-slow.log, медленные SQL-запросы — в /var/log/mysql/mediawiki-slow.Перед плановыми изменениями создаю копию, затем вручную обновляю пакеты:
Код:
systemctl start mediawiki-backup.service && apt-get update && apt-get upgrade
Обновление пакетов Ubuntu не обновляет установленный из архива код MediaWiki. Саму MediaWiki обновляю по её официальному руководству, сохраняя LocalSettings.php, локальные файлы политик и загрузки. После установки новой версии кода для обновления схемы временно нужны расширенные права пользователя БД.
До замены работающего кода останавливаю PHP-FPM и задания; закрываю параллельный запуск резервного копирования общей блокировкой:
Код:
exec 9>/run/lock/mediawiki-maintenance.lock
flock -n 9
Продолжаю только если блокировка получена. Останавливаю обработку запросов:
Код:
systemctl stop mediawiki-jobs.timer mediawiki-jobs.service php8.5-fpm
Теперь заменяю код подготовленной и проверенной версией, сохраняя настройки и загрузки по руководству обновления. Затем временно расширяю права и обновляю схему:
Код:
mysql --no-defaults --protocol=socket --socket=/run/mysqld/mysqld.sock --user=root -e "GRANT ALL PRIVILEGES ON mediawiki.* TO 'mediawiki'@'localhost';"
runuser -u mediawiki -- php8.5 /var/www/mediawiki/maintenance/run.php update --quick
После завершения команды возвращаю рабочие права. Если обновление завершилось ошибкой, это тоже делаю, а сайт оставляю остановленным до разбора ошибки:
Код:
mysql --no-defaults --protocol=socket --socket=/run/mysqld/mysqld.sock --user=root <<'SQL'
REVOKE ALL PRIVILEGES, GRANT OPTION FROM 'mediawiki'@'localhost';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE TEMPORARY TABLES ON mediawiki.* TO 'mediawiki'@'localhost';
SQL
При успешном обновлении и восстановлении прав запускаю службы. Блокировку освобождаю после окончания операции:
Код:
systemctl start php8.5-fpm mediawiki-jobs.timer
flock -u 9
exec 9>&-
После обновления повторяю проверки гостя, администратора, API, альтернативного адреса защищённой статьи и поиска. Локальные ограничения доступа зависят от интерфейсов MediaWiki и требуют внимания при смене версии.
25. Проверяю возможность восстановления
Для пробного восстановления использую отдельную машину с совместимыми Percona 8.4, PHP 8.5 и Nginx. Сначала создаю системного пользователя
mediawiki и подготавливаю службы. Выбранный архив распаковываю в закрытый промежуточный каталог, подставив настоящее имя файла:
Код:
sudo -i
umask 077
mkdir -p /root/mediawiki-restore
tar -xzf /var/backups/mediawiki/ИМЯ_АРХИВА.tar.gz -C /root/mediawiki-restore
cd /root/mediawiki-restore
sha256sum -c SHA256SUMS
После проверки останавливаю PHP-FPM и задания на машине восстановления и импортирую основную БД:
Код:
systemctl stop mediawiki-jobs.timer mediawiki-jobs.service php8.5-fpm
mysql --no-defaults --protocol=socket --socket=/run/mysqld/mysqld.sock --user=root < database.sql
На новой БД-среде, где пользователя
mediawiki@localhost ещё нет, импортирую также:
Код:
mysql --no-defaults --protocol=socket --socket=/run/mysqld/mysqld.sock --user=root < accounts.sql
Если пользователь уже существует, отдельно сверяю его пароль и права с восстановленным LocalSettings.php; повторный CREATE USER сам по себе эту задачу не решит. Импорт SQL заменяет данные целевой вики, поэтому эти действия относятся к подготовленной машине восстановления.
Код и загрузки возвращаю из
var/www/mediawiki в чистый каталог сайта, сохраняя владельцев и права. Создаю каталог кэша с владельцем mediawiki. Конфиги из архива сверяю с новой системой перед установкой; чужие настройки сервера ими целиком не перезаписываю. Затем проверяю Percona, PHP-FPM и Nginx, перечитываю единицы systemd и запускаю службы.Для того же IP подходят сохранённые настройки адреса. При переносе на другой IP меняю привязки Nginx, списки разрешённых host и сертификат. После восстановления проверяю вход администратора, историю статьи, изображение, поиск и все ограничения доступа. Только такой пробный запуск показывает, что у меня есть практически пригодная копия.
26. На что я смотрю при неполадках
- Nginx возвращает 404 на /Заглавная_страница. Проверяю, что подключён полный конфиг, а в MediaWiki действует
/$1. В Nginx должен быть заключительный маршрут статьи через index.php. - Не появился X-Powered-By. Проверяю пакет headers-more, результат
nginx -tи реально подключённый server черезnginx -T. После сохранения конфигурации выполняю reload. - Появился 429. Сначала учитываю общий лимит IP и NAT. Быстрые диагностические запросы тоже попадают в него.
- Защищённая статья после пароля отвечает 404. Проверяю, создана ли она с точным названием. Успешная Basic Auth не создаёт статью автоматически.
- Бэкап не виден в crontab -l. Проверяю
/etc/cron.d/mediawiki-backup, часовой пояс, службу cron и журнал mediawiki-backup.service. - Нужно открыть конфиг в nano. Сначала устанавливаю редактор, затем выполняю
nano /путь/к/файлу. Путь редактируемого файла не является аргументом установки пакетов apt.
27. Подключаю купленный домен example.com и сертификат Let’s Encrypt
В основном варианте статьи я использую внутренний IP и самоподписной сертификат. Теперь добавляю к той же вики зарегистрированный домен и сертификат Let’s Encrypt, которому доверяют обычные браузеры. В примерах пишу
example.com: это зарезервированное демонстрационное имя, поэтому перед выполнением команд заменяю его своим купленным доменом во всех файлах и командах. Сам домен я приобретаю и продлеваю у регистратора; Let’s Encrypt выдаёт сертификат для уже контролируемого мной имени.Основную установку заново не выполняю. Для домена будет отдельный блок Nginx с сертификатом Let’s Encrypt, а для
172.16.2.246 сохраняю существующий самоподписной сертификат. Частный IP не включаю в заявку Let’s Encrypt. ЧПУ, пароль одной статьи, headers-more и общая зона 1 запрос/с продолжат работать на обоих адресах. Как и в основном варианте, HTTP открывает сайт без перенаправления на HTTPS; для передачи паролей использую HTTPS.27.1. Выбираю способ подтверждения домена
- Вики доступна из интернета — HTTP-01. Публичная A-запись домена указывает на мой внешний адрес. На маршрутизаторе направляю TCP 80 и 443 на
172.16.2.246. Let’s Encrypt проверяет специальный файл на порту 80. Этот вариант разбираю в пункте 27.5. - Вики остаётся внутри сети и VPN — DNS-01. Подтверждаю владение именем через TXT-запись в публичной DNS-зоне. Входящие соединения из интернета к вики для такого выпуска не нужны. Во внутреннем DNS домен указывает на
172.16.2.246. В пункте 27.6 показываю автоматизацию на примере Cloudflare DNS; для другого DNS-провайдера выбираю соответствующий плагин Certbot.
Выбираю один способ выпуска, а затем выполняю общие пункты 27.7–27.10. Различия HTTP-01 и DNS-01 описаны в документации Let’s Encrypt.
Для HTTP-01 в панели провайдера, обслуживающего авторитетные DNS-серверы моего домена, создаю A-запись:
| Тип | Имя | Значение |
|---|---|---|
| A | @ | Мой реальный публичный IPv4-адрес |
Значение
@ обычно обозначает сам домен example.com; у некоторых провайдеров поле имени оставляют пустым. Запись только на DNS-сервере AD не подтверждает владение публичным доменом. Если у провайдера включён прокси перед сайтом, для описанного прямого подключения выбираю режим DNS only. При CGNAT без доступного входящего порта 80 использую DNS-01 либо отдельно организую публичный вход к серверу.AAAA добавляю только при реально работающем IPv6 до этого Nginx. Ошибочная AAAA может сорвать проверку, даже если IPv4 исправен: Let’s Encrypt сначала пробует IPv6. Описание проверки IPv6. Если в DNS уже настроено ограничение CAA, оно должно разрешать выпуск для
letsencrypt.org; существующие разрешения других CA сохраняю.Для LAN и VPN при необходимости делаю внутреннюю A-запись
example.com → 172.16.2.246. Публичные DNS при этом продолжают обслуживать проверку домена; при DNS-01 TXT-запись должна появляться именно там. Серверу Ubuntu нужны исходящие HTTPS-соединения к ACME и, для DNS-01, к API DNS-провайдера.27.2. Подготавливаю Certbot и резервную копию
Перехожу в root и создаю рабочий каталог. После переподключения восстанавливаю переменную
LE_WORK по напечатанному пути. Блоки выполняю последовательно; при ошибке до следующего шага не перехожу.
Bash:
sudo -i
umask 077
LE_WORK=$(mktemp -d /root/mediawiki-letsencrypt.XXXXXXXX)
printf 'Рабочий каталог: %s\n' "$LE_WORK"
systemctl start mediawiki-backup.service
cp -a --parents /etc/nginx /var/www/mediawiki/PublicHosts.php /usr/local/sbin/mediawiki-backup.py "$LE_WORK/"
apt-get update
apt-get install -y --no-install-recommends certbot dnsutils
systemctl stop certbot.timer
certbot --version
Здесь использую пакет Certbot из Ubuntu 26.04. Вызовы идут через
/usr/bin/certbot; параллельную установку через Snap не добавляю. Плагин автоматического редактирования Nginx не нужен: сертификат получаю через certonly, а конфигурацию записываю вручную.Сразу дополняю существующую программу резервного копирования:
Bash:
nano /usr/local/sbin/mediawiki-backup.py
В списке
optional = [...] добавляю следующие элементы, сохраняя остальные строки и запятые:
Python:
'etc/letsencrypt',
'var/lib/letsencrypt',
'etc/systemd/system/certbot.service.d',
В функции
main() заменяю единственную строку:
Python:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
на:
Python:
fcntl.flock(lock, fcntl.LOCK_EX)
Теперь бэкап дождётся окончания продления, если оно уже удерживает общий файл блокировки. Все команды Certbot ниже тоже используют
/run/lock/mediawiki-maintenance.lock. Это исключает одновременное изменение сертификатов и их упаковку. Время ожидания входит в существующий тайм-аут службы бэкапа.
Bash:
python3 -c 'from pathlib import Path; p=Path("/usr/local/sbin/mediawiki-backup.py"); compile(p.read_text(),str(p),"exec"); print("Backup syntax: OK")'
27.3. Добавляю разрешённое имя сайта
В
/var/www/mediawiki/PublicHosts.php записываю:
PHP:
<?php
return ["172.16.2.246", "example.com"];
В
/etc/nginx/mediawiki-public-hosts.conf:
NGINX:
172.16.2.246 172.16.2.246;
example.com example.com;
В этом дополнении описываю два адреса: IP и купленный домен. Если ранее добавлял другие имена, сохраняю их в списках и обслуживаю отдельными согласованными блоками Nginx с подходящими сертификатами. Файл
PublicUrlSettings.php из основного руководства уже выбирает разрешённый host; менять в нём cookie или жёстко задавать один адрес не требуется. Последнее имя массива становится каноническим адресом для фоновых задач.
Bash:
chown root:mediawiki /var/www/mediawiki/PublicHosts.php
chmod 640 /var/www/mediawiki/PublicHosts.php
chmod 644 /etc/nginx/mediawiki-public-hosts.conf
php8.5 -l /var/www/mediawiki/PublicHosts.php
27.4. Разделяю сертификаты IP и домена в Nginx
Общие правила выношу в один файл. Nginx читает этот include при загрузке конфигурации, поэтому дополнительных обращений к PHP или БД от такого разделения нет.
Bash:
install -d -o root -g root -m 755 /etc/nginx/snippets
install -d -o root -g root -m 755 /var/lib/letsencrypt /var/lib/letsencrypt/.well-known /var/lib/letsencrypt/.well-known/acme-challenge
nano /etc/nginx/snippets/mediawiki-site.conf
NGINX:
if ($mediawiki_public_host = "") { return 444; }
# Evaluate the map before location rewrites change $uri to /index.php.
set $mediawiki_audit_entry $mediawiki_is_audit_uri;
root /var/www/mediawiki;
index index.php;
autoindex off;
server_tokens off;
client_max_body_size 25m;
limit_req_status 429;
limit_req_log_level warn;
# Ubuntu package: libnginx-mod-http-headers-more-filter.
# Replace any upstream value; also applies to Nginx error responses.
more_set_headers 'X-Powered-By: https://sysadmin.guru';
more_set_headers -s 429 'Retry-After: 1';
ssl_protocols TLSv1.2 TLSv1.3;
ssl_session_cache shared:MediaWikiTLS:10m;
ssl_session_timeout 1h;
ssl_session_tickets off;
ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-RSA-CHACHA20-POLY1305;
ssl_prefer_server_ciphers off;
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
add_header Cache-Control $mediawiki_private_cache always;
# MediaWiki reads the original REQUEST_URI using wgArticlePath.
# No title interpolation into a query string: &, + and Unicode titles stay intact.
location = / { rewrite ^ /index.php last; }
location = /wiki { return 308 $scheme://$mediawiki_public_host/; }
location ^~ /wiki/ {
if ($mediawiki_legacy_target = "") { return 404; }
# Keep original escaping/query; never put a decoded title into Location.
return 308 $scheme://$mediawiki_public_host$mediawiki_legacy_target;
}
location = /robots.txt {
default_type text/plain;
return 200 "User-agent: *\nDisallow: /\n";
}
# Public HTTP-01 tokens live outside the MediaWiki installation.
location ^~ /.well-known/acme-challenge/ {
root /var/lib/letsencrypt;
default_type text/plain;
auth_basic off;
limit_except GET { deny all; }
try_files $uri =404;
}
location ~ (^|/)\. { return 404; }
location ~* ^/(includes|vendor|maintenance|mw-config|cache|tests|docs|sql|node_modules|serialized|backups?)(/|$) { return 404; }
location ~* /(tests|docs|node_modules|vendor)/ { return 404; }
location ^~ /images/deleted/ { return 404; }
location ^~ /images/temp/ { return 404; }
location ^~ /images/lockdir/ { return 404; }
# ResourceLoader does not serve article text; skip repeated bcrypt work for CSS/JS.
location = /load.php {
try_files $uri =404;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/load.php;
fastcgi_param HTTPS $https if_not_empty;
fastcgi_param HTTP_PROXY "";
fastcgi_param MEDIAWIKI_PUBLIC_HOST $mediawiki_public_host;
fastcgi_param MEDIAWIKI_AUDIT_VERIFIED 0;
fastcgi_pass unix:/run/php/php8.5-fpm-mediawiki.sock;
fastcgi_read_timeout 120s;
}
location ~ ^/(index|api|opensearch_desc|thumb)\.php$ {
auth_basic $mediawiki_auth_realm;
auth_basic_user_file /etc/nginx/auth/mediawiki-audit.htpasswd;
limit_req zone=mediawiki_per_ip burst=5 nodelay;
try_files $uri =404;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
fastcgi_param HTTPS $https if_not_empty;
fastcgi_param HTTP_PROXY "";
fastcgi_param MEDIAWIKI_PUBLIC_HOST $mediawiki_public_host;
fastcgi_param MEDIAWIKI_AUDIT_VERIFIED $mediawiki_audit_verified;
fastcgi_pass unix:/run/php/php8.5-fpm-mediawiki.sock;
fastcgi_read_timeout 120s;
}
location ~ ^/rest\.php(?:/|$) {
auth_basic $mediawiki_auth_realm;
auth_basic_user_file /etc/nginx/auth/mediawiki-audit.htpasswd;
limit_req zone=mediawiki_per_ip burst=5 nodelay;
fastcgi_split_path_info ^(/rest\.php)(/.*)$;
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $document_root/rest.php;
fastcgi_param PATH_INFO $fastcgi_path_info;
fastcgi_param HTTPS $https if_not_empty;
fastcgi_param HTTP_PROXY "";
fastcgi_param MEDIAWIKI_PUBLIC_HOST $mediawiki_public_host;
fastcgi_param MEDIAWIKI_AUDIT_VERIFIED $mediawiki_audit_verified;
fastcgi_pass unix:/run/php/php8.5-fpm-mediawiki.sock;
fastcgi_read_timeout 120s;
}
location ~* \.(php[0-9]?|phtml|phar)([./]|$) { return 404; }
location ~* ^/(composer\.(json|lock)|package(-lock)?\.json|COPYING|CREDITS|HISTORY|INSTALL|README(\..*)?|SECURITY(\..*)?)$ { return 404; }
location ~* ^/images/.+\.(png|gif|jpe?g|webp|pdf)$ {
try_files $uri =404;
expires 7d;
access_log off;
}
location ~ ^/(resources|skins|extensions)/.+\.(css|js|png|gif|jpe?g|webp|svg|ico|woff2?|ttf|eot|wasm)$ {
try_files $uri =404;
expires 7d;
access_log off;
}
location ~* ^/(images|resources|skins|extensions)(/|$) { return 404; }
# Every other URL is an article, never a file-system fallback.
location / { rewrite ^ /index.php last; }
error_page 502 504 =503 @maintenance;
location @maintenance {
default_type text/plain;
add_header Retry-After 60 always;
return 503 "Wiki maintenance in progress. Please retry shortly.\n";
}
Маршрут
location ^~ /.well-known/acme-challenge/ обслуживает только файлы отдельного каталога. Благодаря ^~ запрос не попадёт под запрет скрытых файлов из основного конфига. PHP, Basic Auth и лимит динамических запросов к этому маршруту не применяются.Заменяю содержимое
/etc/nginx/sites-available/mediawiki следующим конфигом. Карты и зона лимита объявлены только один раз. На первом этапе оба блока используют уже существующий сертификат: путь Let’s Encrypt подключу после успешного выпуска.
Bash:
nano /etc/nginx/sites-available/mediawiki
NGINX:
# Ubuntu includes sites-enabled/* inside http {}. These directives must stay
# outside server {}. ResourceLoader serves CSS/JS and has no per-IP limit.
map_hash_bucket_size 512;
server_names_hash_bucket_size 512;
map $host $mediawiki_public_host {
default "";
include /etc/nginx/mediawiki-public-hosts.conf;
}
map $request_uri $mediawiki_legacy_target {
default "";
~^/wiki(/.*)$ $1;
}
map $uri $mediawiki_is_audit_uri {
default 0;
/Аудит_прав_пользователей,_групп_и_групповых_политик_в_домене 1;
}
# If credentials accompany an alternative URL/API request, Nginx verifies them
# too. PHP never trusts the unverified remote_user variable by itself.
map "$mediawiki_audit_entry:$http_authorization" $mediawiki_auth_realm {
"0:" off;
default "Audit article";
}
map "$mediawiki_auth_realm:$remote_user" $mediawiki_audit_verified {
default 0;
"Audit article:root" 1;
}
map "$uri:$mediawiki_audit_verified" $mediawiki_private_cache {
default "";
~^/(?:index|api|opensearch_desc|thumb)\.php:1$ "private, no-store";
~^/rest\.php(?:/.*)?:1$ "private, no-store";
}
map $uri $mediawiki_limit_key {
default $binary_remote_addr;
/load.php "";
}
limit_req_zone $mediawiki_limit_key zone=mediawiki_per_ip:10m rate=1r/s;
server {
listen 172.16.2.246:80 default_server;
listen 172.16.2.246:443 ssl default_server;
http2 on;
server_name 172.16.2.246;
ssl_certificate /etc/ssl/certs/mediawiki.crt;
ssl_certificate_key /etc/ssl/private/mediawiki.key;
include /etc/nginx/snippets/mediawiki-site.conf;
}
server {
listen 172.16.2.246:80;
listen 172.16.2.246:443 ssl;
http2 on;
server_name example.com;
ssl_certificate /etc/ssl/certs/mediawiki.crt;
ssl_certificate_key /etc/ssl/private/mediawiki.key;
include /etc/nginx/snippets/mediawiki-site.conf;
}
Директивы
server_name теперь записаны явно в двух блоках. Старый /etc/nginx/mediawiki-server-names.conf этим вариантом конфига больше не подключается. Если стандартный сайт Nginx ранее не отключался, сначала убираю конфликт его default_server; в основной установке статьи такой сайт уже отключён.
Bash:
chmod 644 /etc/nginx/snippets/mediawiki-site.conf /etc/nginx/sites-available/mediawiki
nginx -t && systemctl reload nginx && systemctl reload php8.5-fpm
До успешного
nginx -t не перехожу к выпуску. Пока у домена временный самоподписной сертификат, предупреждение HTTPS ожидаемо. HTTP-01 и DNS-01 не требуют доверия к этому временному сертификату.27.5. Выпускаю сертификат через HTTP-01
Этот пункт выполняю для доступного из интернета сайта. Сначала проверяю публичные DNS:
Bash:
dig @1.1.1.1 example.com A +short
dig @1.1.1.1 example.com AAAA +short
dig @1.1.1.1 example.com CAA +short
A должна показывать мой внешний адрес. Затем создаю тестовый файл:
Bash:
printf 'acme-ok\n' > /var/lib/letsencrypt/.well-known/acme-challenge/mediawiki-check
chmod 644 /var/lib/letsencrypt/.well-known/acme-challenge/mediawiki-check
С устройства вне моей локальной сети открываю
http://example.com/.well-known/acme-challenge/mediawiki-check либо выполняю:
Bash:
curl --noproxy '*' -fsS http://example.com/.well-known/acme-challenge/mediawiki-check
Ожидаю ровно
acme-ok. Проверка с самого сервера через внутренний DNS не доказывает доступность для Let’s Encrypt. При 404 проверяю include и маршрут ACME, при тайм-ауте — публичный IP, NAT и межсетевой экран. Порт 80 и этот маршрут оставляю доступными для последующих продлений.Сначала запускаю пробную выдачу через тестовую среду:
Bash:
flock /run/lock/mediawiki-maintenance.lock /usr/bin/certbot certonly --webroot -w /var/lib/letsencrypt --cert-name example.com -d example.com --key-type rsa --rsa-key-size 3072 --dry-run
При запросе Certbot указываю свою действующую почту и принимаю условия сервиса. После успешного теста выполняю настоящий выпуск:
Bash:
flock /run/lock/mediawiki-maintenance.lock /usr/bin/certbot certonly --webroot -w /var/lib/letsencrypt --cert-name example.com -d example.com --key-type rsa --rsa-key-size 3072
Явно выбираю RSA, потому что шифры TLS 1.2 в исходном Nginx настроены для RSA-сертификатов. Certbot сохраняет файлы и параметры продления в
/etc/letsencrypt. После выпуска перехожу к пункту 27.7. Работа webroot описана в руководстве Certbot.27.6. Альтернатива: DNS-01 для внутренней вики
Этот пункт выполняю вместо 27.5, если сайт должен оставаться внутри LAN/VPN. В примере публичная зона моего купленного домена уже обслуживается Cloudflare DNS. Регистратор домена может быть другим. Простого наличия аккаунта Cloudflare недостаточно: публичные NS домена должны указывать на DNS, которым управляет используемый API.
Устанавливаю плагин:
Bash:
apt-get install -y --no-install-recommends python3-certbot-dns-cloudflare
install -d -o root -g root -m 700 /etc/letsencrypt/credentials
nano /etc/letsencrypt/credentials/cloudflare.ini
В панели Cloudflare создаю API Token с разрешением
Zone:DNS:Edit, ограниченным зоной своего домена. В файл записываю реальное значение токена:
Код:
dns_cloudflare_api_token = МОЙ_API_ТОКЕН
Bash:
chown root:root /etc/letsencrypt/credentials/cloudflare.ini
chmod 600 /etc/letsencrypt/credentials/cloudflare.ini
Проверяю выпуск:
Bash:
flock /run/lock/mediawiki-maintenance.lock /usr/bin/certbot certonly --dns-cloudflare --dns-cloudflare-credentials /etc/letsencrypt/credentials/cloudflare.ini --dns-cloudflare-propagation-seconds 60 --cert-name example.com -d example.com --key-type rsa --rsa-key-size 3072 --dry-run
После успешного теста выпускаю рабочий сертификат:
Bash:
flock /run/lock/mediawiki-maintenance.lock /usr/bin/certbot certonly --dns-cloudflare --dns-cloudflare-credentials /etc/letsencrypt/credentials/cloudflare.ini --dns-cloudflare-propagation-seconds 60 --cert-name example.com -d example.com --key-type rsa --rsa-key-size 3072
Плагин создаёт и удаляет TXT-запись
_acme-challenge.example.com. При продлении он использует сохранённый путь к файлу токена. Сам токен оставляю доступным только root. Для другого провайдера заменяю плагин и параметры согласно его документации; повторять команды Cloudflare для чужого DNS API нельзя. Документация плагина.Ручное добавление TXT через
--manual тоже возможно, но без автоматического authentication hook оно не обеспечивает автоматическое продление. Поэтому для постоянной эксплуатации здесь использую DNS API. Публичная A-запись, доступная извне, для DNS-01 не обязательна; клиентам в LAN/VPN мой внутренний DNS возвращает 172.16.2.246.27.7. Подключаю выданный сертификат к домену
Сначала убеждаюсь, что сертификат действительно выдан:
Bash:
/usr/bin/certbot certificates
test -r /etc/letsencrypt/live/example.com/fullchain.pem
test -r /etc/letsencrypt/live/example.com/privkey.pem
openssl x509 -in /etc/letsencrypt/live/example.com/cert.pem -noout -subject -issuer -dates -ext subjectAltName
В
/etc/nginx/sites-available/mediawiki заменяю только второй блок server, обслуживающий домен, на следующий:
NGINX:
server {
listen 172.16.2.246:80;
listen 172.16.2.246:443 ssl;
http2 on;
server_name example.com;
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
include /etc/nginx/snippets/mediawiki-site.conf;
}
Первый блок IP продолжает использовать
/etc/ssl/certs/mediawiki.crt и /etc/ssl/private/mediawiki.key. Nginx выбирает доменный сертификат по SNI. Подключаю именно fullchain.pem, чтобы отправлять промежуточные сертификаты, и оставляю ссылки Certbot в каталоге live. Приватный ключ не делаю доступным пользователю PHP. Настройка HTTPS и цепочки в Nginx.
Bash:
nginx -t && systemctl reload nginx
curl --noproxy '*' --resolve example.com:443:172.16.2.246 -sS -o /dev/null -w 'HTTPS domain: %{http_code}\n' https://example.com/robots.txt
curl --noproxy '*' --resolve example.com:80:172.16.2.246 -sS -o /dev/null -w 'HTTP domain: %{http_code}\n' http://example.com/robots.txt
curl --noproxy '*' --cacert /etc/ssl/certs/mediawiki.crt -sS -o /dev/null -w 'HTTPS IP: %{http_code}\n' https://172.16.2.246/robots.txt
Ожидаю 200 во всех трёх проверках. Для публичного сертификата не использую
-k и не подставляю старый самоподписной сертификат в --cacert. Проверка с --resolve идёт прямо к Nginx с правильным доменом и SNI. Затем открываю главную страницу по домену, вхожу под администратором и проверяю обычную и защищённую статьи. По домену потребуется отдельный вход: cookie от IP не переносятся на другое имя.27.8. Включаю автоматическое продление и перечитывание сертификата
Создаю deploy hook, который проверяет Nginx и перечитывает сертификаты после успешной выдачи при продлении:
Bash:
install -d -o root -g root -m 755 /etc/letsencrypt/renewal-hooks/deploy
nano /etc/letsencrypt/renewal-hooks/deploy/20-reload-nginx
Bash:
#!/bin/sh
set -eu
/usr/sbin/nginx -t
/usr/bin/systemctl reload nginx
Bash:
chown root:root /etc/letsencrypt/renewal-hooks/deploy/20-reload-nginx
chmod 700 /etc/letsencrypt/renewal-hooks/deploy/20-reload-nginx
install -d -o root -g root -m 755 /etc/systemd/system/certbot.service.d
nano /etc/systemd/system/certbot.service.d/mediawiki-lock.conf
В override записываю:
INI:
[Service]
ExecStart=
ExecStart=/usr/bin/flock -w 7200 /run/lock/mediawiki-maintenance.lock /usr/bin/certbot -q renew --no-random-sleep-on-renew
TimeoutStartSec=3h
Служба Certbot из пакета Ubuntu запускается от root. Добавленная блокировка согласует её работу с бэкапом из пункта 27.2; hook не захватывает блокировку повторно. Проверяю пробное продление вместе с deploy hook:
Bash:
systemctl daemon-reload
systemd-analyze verify certbot.service certbot.timer
systemctl cat certbot.service
flock /run/lock/mediawiki-maintenance.lock /usr/bin/certbot renew --cert-name example.com --dry-run --run-deploy-hooks
При успешном результате включаю штатный таймер:
Bash:
systemctl enable --now certbot.timer
systemctl list-timers --all certbot.timer
systemctl start certbot.service
journalctl -u certbot.service -n 50 --no-pager
Обычный запуск
renew продлевает только сертификаты, для которых подошёл срок. Deploy hook выполняется после успешного продления; код возврата 0 сам по себе не означает, что новый сертификат уже выдан. Продление Certbot.Таймер Certbot не является автоматическим обновлением Ubuntu: настройки ручного обновления APT/Snap из раздела 18 сохраняются. Для сертификатов использую только штатный
certbot.timer, дополнительный cron не создаю. Продление зависит от доступности ACME и выбранного способа проверки; при DNS-01 также от действующего API-токена. В случае сбоя смотрю journalctl -u certbot.service и /var/log/letsencrypt/letsencrypt.log.27.9. Проверяю новый состав бэкапа
После изменений запускаю копирование и его проверку:
Bash:
systemctl start mediawiki-backup.service
/usr/local/sbin/mediawiki-backup.py --check-latest
ls -lh /var/backups/mediawiki/
В одном архиве теперь сохраняются также
/etc/letsencrypt целиком: учётная запись ACME, настройки продления, hook, приватные ключи, каталог archive, ссылки live и файл DNS API-токена, если выбран DNS-01. Общий include Nginx уже попадает в копию вместе с /etc/nginx. Срок хранения остаётся 7 дней, запуск — от root в 03:30.При восстановлении возвращаю каталог Let’s Encrypt целиком с владельцами, правами и относительными ссылками. Проверяю дату сертификата, наличие плагина DNS, пути сертификатов и доступность webroot/API. Если срок истёк, сначала обеспечиваю условия проверки домена и выполняю продление. Затем делаю
systemctl daemon-reload, проверяю конфигурацию, включаю certbot.timer и проверяю HTTPS. HTTP-01 требует заново создать каталоги webroot с правами 755, если они не были восстановлены.27.10. Что проверяю при ошибках выпуска
- HTTP-01 возвращает 404. Проверяю маршрут с
^~, реальный webroot и активный блок для домена. Путь ACME должен обходить запрет dotfiles, не открывая остальные скрытые каталоги. - Timeout при проверке. Проверяю внешний адрес, CGNAT, NAT TCP 80, межсетевой экран и AAAA. При закрытой внутренней вики использую DNS-01.
- DNS-01 не находит TXT. Проверяю авторитетные публичные NS, зону API-токена и время распространения записи. TXT только на внутреннем DNS AD для публичной проверки недостаточно.
- Certbot выдал сертификат, а Nginx показывает старый. Проверяю путь
fullchain.pemво втором server и успешный reload. Самоподписной сертификат по IP при этом остаётся ожидаемым. - Продление не работает. Проверяю таймер, журнал службы и
renew --dry-runпод общей блокировкой. Для ручного DNS без hook автоматического подтверждения не будет. - После восстановления потерялся ключ или токен. Проверяю, включён ли весь
/etc/letsencryptв новую копию, а не только символические ссылкиlive.
После перехода на Let’s Encrypt сертификат домена обслуживает Certbot. Повторный запуск старых помощников настройки, рассчитанных на самоподписной сертификат, требует отдельной адаптации: они могут вернуть прежний общий server и схему TLS. Ручные настройки этого раздела применяю к уже работающей вики, сохраняя её базу, статьи и права доступа.