diff --git a/README.md b/README.md index 2493e18..4bb735b 100644 --- a/README.md +++ b/README.md @@ -1,322 +1,330 @@ -# Портал администрирования: HAProxy + Django + AD → Backend admin panels -## Общее описание реализованного проекта - -Единая точка входа для администраторов: логин через Active Directory, -персональный список доступных ресурсов по AD-группам, переход в нужную -админку по адресу `https://///...`. - -Все компоненты устанавливаются как обычные пакеты/systemd-сервисы -непосредственно на одну ВМ. - -## 1. Состав решения - -``` -admin-portal-vm/ -├── README.md — этот файл -├── .env.example — шаблон переменных окружения -├── haproxy/ -│ ├── haproxy.cfg — полный конфиг HAProxy (-> /etc/haproxy/haproxy.cfg) -│ └── group_map.cfg — карта "путь -> backend" (-> /etc/haproxy/group_map.cfg) -├── systemd/ -│ └── admin-portal.service — юнит Gunicorn (Django-приложение) -├── django_app/ — код портала (Django) -│ ├── manage.py -│ ├── requirements.txt -│ ├── config/ — settings.py, urls.py, wsgi.py -│ ├── portal/ — модели, вью, шаблоны, LDAP-логика -│ └── resources.example.yaml — пример декларативного каталога ресурсов -├── backend_demo/ — 2 демо-админки (Nginx) для проверки маршрутизации -│ ├── resource1/{index.html,nginx.conf} -│ └── resource2/{index.html,nginx.conf} -├── scripts/ -│ ├── install.sh — автоматизация шагов 3–9 (см. ниже) -│ └── generate_secrets.sh — генерация DJANGO_SECRET_KEY / RESOURCE_TOKEN_SECRET -└── docs/ - └── architecture.md — полное описание архитектуры и решений -``` - -## 2. Архитектура (кратко) - -``` -Браузер админа - │ HTTPS (443) - ▼ - HAProxy (systemd-сервис haproxy) ── TLS termination, ACL-роутинг по пути - │ - ├── путь портала / нет валидного resource_token ──► Gunicorn (127.0.0.1:8000) - │ └─ Django frontend - │ ├─ django-auth-ldap -> AD - │ ├─ PostgreSQL (localhost) - │ └─ Redis (localhost, сессии) - │ - └── путь /{adminusergroup}/{resource}/... + resource_token - │ - ▼ - backend_<группа> — Nginx на 127.0.0.1:808x (демо) или - реальная админка на другом хосте сети -``` - -Полное описание потоков, модели данных и вопросов безопасности — в -`docs/architecture.md`. - -## 3. Требования к ВМ - -- РедОС либо АстраЛинукс -- 2 vCPU / 4 GB RAM достаточно для старта (PostgreSQL, Redis, Gunicorn, - HAProxy, Nginx — все на одной машине) -- Сетевой доступ до контроллера домена AD (LDAP/LDAPS, порт 389/636) -- Технический пользователь AD с правом bind + поиска (service account), - без прав записи -- Валидный TLS-сертификат на FQDN портала (или self-signed для теста) -- DNS-запись FQDN, указывающая на эту ВМ -- Порты 80/443 открыты снаружи; всё остальное (5432, 6379, 8000, 808x) — - только на loopback, наружу не публикуется - -## 4. Установка — автоматически (рекомендуемый способ) - -```bash -# 1. Скопировать архив решения на ВМ и распаковать -unzip admin-portal-vm.zip -cd admin-portal-vm - -# 2. Подготовить .env -cp .env.example .env -bash scripts/generate_secrets.sh # подставить значения ключей в .env вручную -nano .env # заполнить AD_*, POSTGRES_PASSWORD и т.д. - -# 3. Запустить установку (пакеты, БД, Redis, venv, миграции, systemd, HAProxy, Nginx) -sudo bash scripts/install.sh ./.env -``` - -Скрипт последовательно: -1. ставит `haproxy`, `nginx`, `postgresql`, `redis-server`, Python и dev-библиотеки - для сборки `python-ldap`; -2. создаёт системного пользователя `adminportal` без права логина; -3. копирует `django_app/` в `/opt/admin-portal/django_app`; -4. создаёт venv и ставит зависимости из `requirements.txt`; -5. кладёт `.env` в `/etc/admin-portal/admin-portal.env` (права 640, владелец root:adminportal); -6. создаёт роль и базу PostgreSQL по значениям из `.env`, включает PostgreSQL и Redis; -7. прогоняет `migrate` и `collectstatic` от имени `adminportal`; -8. устанавливает и включает `systemd`-юнит `admin-portal.service` (Gunicorn на 127.0.0.1:8000); -9. раскладывает `haproxy.cfg`/`group_map.cfg`, поднимает 2 демо-бэкенда через Nginx - на 127.0.0.1:8081/8082, открывает 80/443 в `ufw`. - -После скрипта останутся 3 ручных шага (секреты и сертификат сознательно не -автоматизированы): - -```bash -# TLS-сертификат -cat fullchain.pem privkey.pem > /etc/haproxy/certs/fqdn.pem -haproxy -c -f /etc/haproxy/haproxy.cfg && sudo systemctl reload haproxy - -# Суперпользователь Django -sudo -u adminportal bash -c \ - 'set -a; source /etc/admin-portal/admin-portal.env; set +a; \ - /opt/admin-portal/venv/bin/python /opt/admin-portal/django_app/manage.py createsuperuser' - -# Наполнение каталога ресурсов -sudo -u adminportal bash -c \ - 'set -a; source /etc/admin-portal/admin-portal.env; set +a; \ - /opt/admin-portal/venv/bin/python /opt/admin-portal/django_app/manage.py \ - sync_resources /opt/admin-portal/django_app/resources.example.yaml' -``` - -Дальше — к разделу 6 (проверка). - -## 5. Установка — вручную, шаг за шагом - -Если нужно понимать/контролировать каждое действие (или адаптировать под -другой дистрибутив), либо не доверяете единому скрипту — то же самое руками. - -### Шаг 1. Системные пакеты - -```bash -sudo apt-get update -sudo apt-get install -y haproxy nginx postgresql redis-server \ - python3 python3-venv python3-dev \ - libldap2-dev libsasl2-dev libssl-dev build-essential ufw -``` - -### Шаг 2. Системный пользователь приложения - -```bash -sudo useradd --system --home /opt/admin-portal --shell /usr/sbin/nologin adminportal -``` - -### Шаг 3. Код приложения и виртуальное окружение - -```bash -sudo mkdir -p /opt/admin-portal -sudo cp -r django_app /opt/admin-portal/django_app -sudo chown -R adminportal:adminportal /opt/admin-portal - -sudo python3 -m venv /opt/admin-portal/venv -sudo /opt/admin-portal/venv/bin/pip install --upgrade pip -sudo /opt/admin-portal/venv/bin/pip install -r /opt/admin-portal/django_app/requirements.txt -sudo chown -R adminportal:adminportal /opt/admin-portal/venv -``` - -### Шаг 4. Переменные окружения - -```bash -cp .env.example .env -bash scripts/generate_secrets.sh # вставить значения в .env -nano .env -``` - -Заполнить как минимум: `DJANGO_SECRET_KEY`, `DJANGO_ALLOWED_HOSTS`, -`POSTGRES_PASSWORD`, `AD_SERVER_URI`, `AD_BIND_DN`, `AD_BIND_PASSWORD`, -`AD_USER_SEARCH_BASE`, `AD_GROUP_SEARCH_BASE`, `RESOURCE_TOKEN_SECRET`. - -```bash -sudo mkdir -p /etc/admin-portal -sudo install -o root -g adminportal -m 640 .env /etc/admin-portal/admin-portal.env -``` - -### Шаг 5. PostgreSQL - -```bash -source .env # только для этой команды, читаем значения POSTGRES_* -sudo -u postgres psql -c "CREATE ROLE ${POSTGRES_USER} LOGIN PASSWORD '${POSTGRES_PASSWORD}';" -sudo -u postgres psql -c "CREATE DATABASE ${POSTGRES_DB} OWNER ${POSTGRES_USER};" -sudo systemctl enable --now postgresql -``` - -### Шаг 6. Redis - -```bash -sudo systemctl enable --now redis-server -``` - -По умолчанию Redis слушает только `127.0.0.1` — это то, что нужно, менять -`bind` в `/etc/redis/redis.conf` не требуется. - -### Шаг 7. Миграции и статика - -```bash -sudo -u adminportal bash -c \ - 'set -a; source /etc/admin-portal/admin-portal.env; set +a; \ - /opt/admin-portal/venv/bin/python /opt/admin-portal/django_app/manage.py migrate --noinput && \ - /opt/admin-portal/venv/bin/python /opt/admin-portal/django_app/manage.py collectstatic --noinput' -``` - -### Шаг 8. Gunicorn как systemd-сервис - -```bash -sudo install -m 644 systemd/admin-portal.service /etc/systemd/system/admin-portal.service -sudo systemctl daemon-reload -sudo systemctl enable --now admin-portal -sudo systemctl status admin-portal # должен быть active (running) -``` - -### Шаг 9. HAProxy - -```bash -sudo install -m 644 haproxy/haproxy.cfg /etc/haproxy/haproxy.cfg -sudo install -m 644 haproxy/group_map.cfg /etc/haproxy/group_map.cfg -sudo mkdir -p /etc/haproxy/certs && sudo chmod 700 /etc/haproxy/certs -``` - -Положить сертификат: - -```bash -cat fullchain.pem privkey.pem | sudo tee /etc/haproxy/certs/fqdn.pem >/dev/null -sudo chmod 600 /etc/haproxy/certs/fqdn.pem -``` - -Проверить и включить: - -```bash -sudo haproxy -c -f /etc/haproxy/haproxy.cfg -sudo systemctl enable --now haproxy -``` - -### Шаг 10. Демо-бэкенды (Nginx) — опционально, для сквозной проверки - -```bash -for res in resource1 resource2; do - sudo mkdir -p /var/www/admin-demo-${res} - sudo cp backend_demo/${res}/index.html /var/www/admin-demo-${res}/index.html - sudo install -m 644 backend_demo/${res}/nginx.conf /etc/nginx/sites-available/admin-demo-${res} - sudo ln -sf /etc/nginx/sites-available/admin-demo-${res} /etc/nginx/sites-enabled/admin-demo-${res} -done -sudo rm -f /etc/nginx/sites-enabled/default -sudo nginx -t && sudo systemctl enable --now nginx && sudo systemctl reload nginx -``` - -### Шаг 11. Фаервол - -```bash -sudo ufw allow 80/tcp -sudo ufw allow 443/tcp -sudo ufw enable # если ещё не включён — проверить, что не потеряете SSH (22/tcp) -``` - -### Шаг 12. Суперпользователь и каталог ресурсов - -```bash -sudo -u adminportal bash -c \ - 'set -a; source /etc/admin-portal/admin-portal.env; set +a; \ - /opt/admin-portal/venv/bin/python /opt/admin-portal/django_app/manage.py createsuperuser' -``` - -Дальше зайти на `https:///django-admin/` и создать **ResourceGroup** -(привязка к реальной AD-группе) и **Resource** (конкретный ресурс), либо -воспользоваться декларативным файлом: - -```bash -sudo -u adminportal bash -c \ - 'set -a; source /etc/admin-portal/admin-portal.env; set +a; \ - /opt/admin-portal/venv/bin/python /opt/admin-portal/django_app/manage.py \ - sync_resources /opt/admin-portal/django_app/resources.example.yaml' -``` - -## 6. Проверка - -```bash -systemctl status admin-portal haproxy nginx postgresql redis-server -curl -kI https://portal.example.com/ # ожидаем редирект на /login/ -``` - -Залогиниться доменной учёткой, убедиться, что на портале появились ресурсы, -доступные группам этого пользователя, и что клик по ресурсу открывает -демо-страницу (`Demo Resource 1/2`) — это подтверждает, что вся цепочка -HAProxy → Django → AD → выдача токена → маршрутизация на backend работает. - -## 7. Добавление нового ресурса/backend'а в дальнейшем - -1. Поднять реальную админку ресурса — на этой же ВМ (новый порт на - `127.0.0.1`) либо на отдельном хосте сети, доступном от HAProxy, но не - доступном напрямую из интернета. -2. Добавить в `/etc/haproxy/haproxy.cfg` новую `backend be_` секцию со - `server` этой админки (по образцу `be_admin_resource1`). -3. Добавить строку в `/etc/haproxy/group_map.cfg`: - `/adgroup/resource-slug/ be_`. -4. Проверить и перечитать конфиг без разрыва соединений: - `sudo haproxy -c -f /etc/haproxy/haproxy.cfg && sudo systemctl reload haproxy`. -5. Создать запись `Resource` в Django admin или через `sync_resources`. - -## 8. Эксплуатация - -- **Логи**: `journalctl -u admin-portal -f` (Django/Gunicorn), - `journalctl -u haproxy -f`, `/var/log/nginx/*.log` (демо-бэкенды). -- **Обновление кода портала**: - ```bash - sudo -u adminportal rsync -a --delete django_app/ /opt/admin-portal/django_app/ - sudo -u adminportal bash -c \ - 'set -a; source /etc/admin-portal/admin-portal.env; set +a; \ - /opt/admin-portal/venv/bin/python /opt/admin-portal/django_app/manage.py migrate --noinput && \ - /opt/admin-portal/venv/bin/python /opt/admin-portal/django_app/manage.py collectstatic --noinput' - sudo systemctl restart admin-portal - ``` -- **Резервное копирование**: штатный `pg_dump ${POSTGRES_DB}` по расписанию - (cron/systemd timer). Redis бэкапить не нужно — там только сессии/кэш. -- **Обновление TLS-сертификата**: заменить `/etc/haproxy/certs/fqdn.pem` и - выполнить `systemctl reload haproxy` (без разрыва активных соединений). -- **Масштабирование**: вынести PostgreSQL/Redis на отдельные ВМ (поменять - `POSTGRES_HOST`/`REDIS_URL` в `.env`), поднять второй Django+Gunicorn на - другой ВМ и добавить вторую строку `server` в `backend be_django`; - HAProxy балансирует между ними без изменения остальной конфигурации. - -Полное описание архитектуры, потоков данных, вариантов передачи identity в -backend и вопросов безопасности — см. `docs/architecture.md`. +# Портал администрирования: HAProxy + Django + AD → Backend admin panels +## Установка на виртуальную машину (без Docker) + +Единая точка входа для администраторов: логин через Active Directory, +персональный список доступных ресурсов по AD-группам, переход в нужную +админку по адресу `https://///...`. + +Все компоненты устанавливаются как обычные пакеты/systemd-сервисы +непосредственно на одну ВМ (Ubuntu 22.04/24.04 или Debian 12). + +## 1. Состав решения + +``` +admin-portal-vm/ +├── README.md — этот файл +├── .env.example — шаблон переменных окружения +├── haproxy/ +│ ├── haproxy.cfg — полный конфиг HAProxy (-> /etc/haproxy/haproxy.cfg) +│ └── group_map.cfg — карта "путь -> backend" (-> /etc/haproxy/group_map.cfg) +├── systemd/ +│ └── admin-portal.service — юнит Gunicorn (Django-приложение) +├── django_app/ — код портала (Django) +│ ├── manage.py +│ ├── requirements.txt +│ ├── config/ — settings.py, urls.py, wsgi.py +│ ├── portal/ — модели, вью, шаблоны, LDAP-логика +│ └── resources.example.yaml — пример декларативного каталога ресурсов +├── backend_demo/ — 2 демо-админки (Nginx) для проверки маршрутизации +│ ├── resource1/{index.html,nginx.conf} +│ └── resource2/{index.html,nginx.conf} +├── scripts/ +│ ├── install.sh — автоматизация шагов 3–9 (см. ниже) +│ └── generate_secrets.sh — генерация DJANGO_SECRET_KEY / RESOURCE_TOKEN_SECRET +└── docs/ + └── architecture.md — полное описание архитектуры и решений +``` + +## 2. Архитектура (кратко) + +``` +Браузер админа + │ HTTPS (443) + ▼ + HAProxy (systemd-сервис haproxy) ── TLS termination, ACL-роутинг по пути + │ + ├── путь портала / нет валидного resource_token ──► Gunicorn (127.0.0.1:8000) + │ └─ Django frontend + │ ├─ django-auth-ldap -> AD + │ ├─ PostgreSQL (localhost) + │ └─ Redis (localhost, сессии) + │ + └── путь /{adminusergroup}/{resource}/... + resource_token + │ + ▼ + backend_<группа> — Nginx на 127.0.0.1:808x (демо) или + реальная админка на другом хосте сети +``` + +Полное описание потоков, модели данных и вопросов безопасности — в +`docs/architecture.md`. + +## 3. Требования к ВМ + +- Ubuntu 22.04/24.04 LTS или Debian 12, root/sudo-доступ +- 2 vCPU / 2 GB RAM достаточно для старта (PostgreSQL, Redis, Gunicorn, + HAProxy, Nginx — все на одной машине) +- Сетевой доступ до контроллера домена AD (LDAP/LDAPS, порт 389/636) +- Технический пользователь AD с правом bind + поиска (service account), + без прав записи +- Валидный TLS-сертификат на FQDN портала (или self-signed для теста) +- DNS-запись FQDN, указывающая на эту ВМ +- Порты 80/443 открыты снаружи; всё остальное (5432, 6379, 8000, 808x) — + только на loopback, наружу не публикуется + +## 4. Установка — автоматически (рекомендуемый способ) + +```bash +# 1. Скопировать архив решения на ВМ и распаковать +unzip admin-portal-vm.zip +cd admin-portal-vm + +# 2. Подготовить .env +cp .env.example .env +bash scripts/generate_secrets.sh # подставить значения ключей в .env вручную +nano .env # заполнить AD_*, POSTGRES_PASSWORD и т.д. + +# 3. Запустить установку (пакеты, БД, Redis, venv, миграции, systemd, HAProxy, Nginx) +sudo bash scripts/install.sh ./.env +``` + +Скрипт последовательно: +1. ставит `haproxy`, `nginx`, `postgresql`, `redis-server`, Python и dev-библиотеки + для сборки `python-ldap`; +2. создаёт системного пользователя `adminportal` без права логина; +3. копирует `django_app/` в `/opt/admin-portal/django_app`; +4. создаёт venv и ставит зависимости из `requirements.txt`; +5. кладёт `.env` в `/etc/admin-portal/admin-portal.env` (права 640, владелец root:adminportal); +6. создаёт роль и базу PostgreSQL по значениям из `.env`, включает PostgreSQL и Redis; +7. прогоняет `migrate` и `collectstatic` от имени `adminportal`; +8. устанавливает и включает `systemd`-юнит `admin-portal.service` (Gunicorn на 127.0.0.1:8000); +9. раскладывает `haproxy.cfg`/`group_map.cfg`, поднимает 2 демо-бэкенда через Nginx + на 127.0.0.1:8081/8082, открывает 80/443 в `ufw`. + +После скрипта останутся 3 ручных шага (секреты и сертификат сознательно не +автоматизированы): + +```bash +# TLS-сертификат +cat fullchain.pem privkey.pem > /etc/haproxy/certs/fqdn.pem +haproxy -c -f /etc/haproxy/haproxy.cfg && sudo systemctl reload haproxy + +# Суперпользователь Django +sudo -u adminportal bash -c \ + 'set -a; source /etc/admin-portal/admin-portal.env; set +a; \ + /opt/admin-portal/venv/bin/python /opt/admin-portal/django_app/manage.py createsuperuser' + +# Наполнение каталога ресурсов +sudo -u adminportal bash -c \ + 'set -a; source /etc/admin-portal/admin-portal.env; set +a; \ + /opt/admin-portal/venv/bin/python /opt/admin-portal/django_app/manage.py \ + sync_resources /opt/admin-portal/django_app/resources.example.yaml' +``` + +Дальше — к разделу 6 (проверка). + +## 5. Установка — вручную, шаг за шагом + +Если нужно понимать/контролировать каждое действие (или адаптировать под +другой дистрибутив), либо не доверяете единому скрипту — то же самое руками. + +### Шаг 1. Системные пакеты + +```bash +sudo apt-get update +sudo apt-get install -y haproxy nginx postgresql redis-server \ + python3 python3-venv python3-dev \ + libldap2-dev libsasl2-dev libssl-dev build-essential ufw +``` + +### Шаг 2. Системный пользователь приложения + +```bash +sudo useradd --system --home /opt/admin-portal --shell /usr/sbin/nologin adminportal +``` + +### Шаг 3. Код приложения и виртуальное окружение + +```bash +sudo mkdir -p /opt/admin-portal +sudo cp -r django_app /opt/admin-portal/django_app +sudo chown -R adminportal:adminportal /opt/admin-portal + +sudo python3 -m venv /opt/admin-portal/venv +sudo /opt/admin-portal/venv/bin/pip install --upgrade pip +sudo /opt/admin-portal/venv/bin/pip install -r /opt/admin-portal/django_app/requirements.txt +sudo chown -R adminportal:adminportal /opt/admin-portal/venv +``` + +### Шаг 4. Переменные окружения + +```bash +cp .env.example .env +bash scripts/generate_secrets.sh # вставить значения в .env +nano .env +``` + +Заполнить как минимум: `DJANGO_SECRET_KEY`, `DJANGO_ALLOWED_HOSTS`, +`POSTGRES_PASSWORD`, `AD_SERVER_URI`, `AD_BIND_DN`, `AD_BIND_PASSWORD`, +`AD_USER_SEARCH_BASE`, `AD_GROUP_SEARCH_BASE`, `RESOURCE_TOKEN_SECRET`. + +```bash +sudo mkdir -p /etc/admin-portal +sudo install -o root -g adminportal -m 640 .env /etc/admin-portal/admin-portal.env +``` + +### Шаг 5. PostgreSQL + +```bash +source .env # только для этой команды, читаем значения POSTGRES_* +sudo -u postgres psql -c "CREATE ROLE ${POSTGRES_USER} LOGIN PASSWORD '${POSTGRES_PASSWORD}';" +sudo -u postgres psql -c "CREATE DATABASE ${POSTGRES_DB} OWNER ${POSTGRES_USER};" +sudo systemctl enable --now postgresql +``` + +### Шаг 6. Redis + +```bash +sudo systemctl enable --now redis-server +``` + +По умолчанию Redis слушает только `127.0.0.1` — это то, что нужно, менять +`bind` в `/etc/redis/redis.conf` не требуется. + +### Шаг 7. Миграции и статика + +```bash +sudo -u adminportal bash -c \ + 'set -a; source /etc/admin-portal/admin-portal.env; set +a; \ + /opt/admin-portal/venv/bin/python /opt/admin-portal/django_app/manage.py migrate --noinput && \ + /opt/admin-portal/venv/bin/python /opt/admin-portal/django_app/manage.py collectstatic --noinput' +``` + +### Шаг 8. Gunicorn как systemd-сервис + +```bash +sudo install -m 644 systemd/admin-portal.service /etc/systemd/system/admin-portal.service +sudo systemctl daemon-reload +sudo systemctl enable --now admin-portal +sudo systemctl status admin-portal # должен быть active (running) +``` + +### Шаг 9. HAProxy + +```bash +sudo install -m 644 haproxy/haproxy.cfg /etc/haproxy/haproxy.cfg +sudo install -m 644 haproxy/group_map.cfg /etc/haproxy/group_map.cfg +sudo mkdir -p /etc/haproxy/certs && sudo chmod 700 /etc/haproxy/certs +``` + +Положить сертификат: + +```bash +cat fullchain.pem privkey.pem | sudo tee /etc/haproxy/certs/fqdn.pem >/dev/null +sudo chmod 600 /etc/haproxy/certs/fqdn.pem +``` + +Для теста — self-signed: + +```bash +openssl req -x509 -newkey rsa:2048 -nodes -days 365 \ + -keyout /tmp/key.pem -out /tmp/cert.pem -subj "/CN=portal.example.com" +cat /tmp/cert.pem /tmp/key.pem | sudo tee /etc/haproxy/certs/fqdn.pem >/dev/null +``` + +Проверить и включить: + +```bash +sudo haproxy -c -f /etc/haproxy/haproxy.cfg +sudo systemctl enable --now haproxy +``` + +### Шаг 10. Демо-бэкенды (Nginx) — опционально, для сквозной проверки + +```bash +for res in resource1 resource2; do + sudo mkdir -p /var/www/admin-demo-${res} + sudo cp backend_demo/${res}/index.html /var/www/admin-demo-${res}/index.html + sudo install -m 644 backend_demo/${res}/nginx.conf /etc/nginx/sites-available/admin-demo-${res} + sudo ln -sf /etc/nginx/sites-available/admin-demo-${res} /etc/nginx/sites-enabled/admin-demo-${res} +done +sudo rm -f /etc/nginx/sites-enabled/default +sudo nginx -t && sudo systemctl enable --now nginx && sudo systemctl reload nginx +``` + +### Шаг 11. Фаервол + +```bash +sudo ufw allow 80/tcp +sudo ufw allow 443/tcp +sudo ufw enable # если ещё не включён — проверить, что не потеряете SSH (22/tcp) +``` + +### Шаг 12. Суперпользователь и каталог ресурсов + +```bash +sudo -u adminportal bash -c \ + 'set -a; source /etc/admin-portal/admin-portal.env; set +a; \ + /opt/admin-portal/venv/bin/python /opt/admin-portal/django_app/manage.py createsuperuser' +``` + +Дальше зайти на `https:///django-admin/` и создать **ResourceGroup** +(привязка к реальной AD-группе) и **Resource** (конкретный ресурс), либо +воспользоваться декларативным файлом: + +```bash +sudo -u adminportal bash -c \ + 'set -a; source /etc/admin-portal/admin-portal.env; set +a; \ + /opt/admin-portal/venv/bin/python /opt/admin-portal/django_app/manage.py \ + sync_resources /opt/admin-portal/django_app/resources.example.yaml' +``` + +## 6. Проверка + +```bash +systemctl status admin-portal haproxy nginx postgresql redis-server +curl -kI https://portal.example.com/ # ожидаем редирект на /login/ +``` + +Залогиниться доменной учёткой, убедиться, что на портале появились ресурсы, +доступные группам этого пользователя, и что клик по ресурсу открывает +демо-страницу (`Demo Resource 1/2`) — это подтверждает, что вся цепочка +HAProxy → Django → AD → выдача токена → маршрутизация на backend работает. + +## 7. Добавление нового ресурса/backend'а в дальнейшем + +1. Поднять реальную админку ресурса — на этой же ВМ (новый порт на + `127.0.0.1`) либо на отдельном хосте сети, доступном от HAProxy, но не + доступном напрямую из интернета. +2. Добавить в `/etc/haproxy/haproxy.cfg` новую `backend be_` секцию со + `server` этой админки (по образцу `be_admin_resource1`). +3. Добавить строку в `/etc/haproxy/group_map.cfg`: + `/adgroup/resource-slug/ be_`. +4. Проверить и перечитать конфиг без разрыва соединений: + `sudo haproxy -c -f /etc/haproxy/haproxy.cfg && sudo systemctl reload haproxy`. +5. Создать запись `Resource` в Django admin или через `sync_resources`. + +## 8. Эксплуатация + +- **Логи**: `journalctl -u admin-portal -f` (Django/Gunicorn), + `journalctl -u haproxy -f`, `/var/log/nginx/*.log` (демо-бэкенды). +- **Обновление кода портала**: + ```bash + sudo -u adminportal rsync -a --delete django_app/ /opt/admin-portal/django_app/ + sudo -u adminportal bash -c \ + 'set -a; source /etc/admin-portal/admin-portal.env; set +a; \ + /opt/admin-portal/venv/bin/python /opt/admin-portal/django_app/manage.py migrate --noinput && \ + /opt/admin-portal/venv/bin/python /opt/admin-portal/django_app/manage.py collectstatic --noinput' + sudo systemctl restart admin-portal + ``` +- **Резервное копирование**: штатный `pg_dump ${POSTGRES_DB}` по расписанию + (cron/systemd timer). Redis бэкапить не нужно — там только сессии/кэш. +- **Обновление TLS-сертификата**: заменить `/etc/haproxy/certs/fqdn.pem` и + выполнить `systemctl reload haproxy` (без разрыва активных соединений). +- **Масштабирование**: вынести PostgreSQL/Redis на отдельные ВМ (поменять + `POSTGRES_HOST`/`REDIS_URL` в `.env`), поднять второй Django+Gunicorn на + другой ВМ и добавить вторую строку `server` в `backend be_django`; + HAProxy балансирует между ними без изменения остальной конфигурации. + +Полное описание архитектуры, потоков данных, вариантов передачи identity в +backend и вопросов безопасности — см. `docs/architecture.md`. diff --git a/backend_demo/resource1/index.html b/backend_demo/resource1/index.html new file mode 100644 index 0000000..2bf6bf1 --- /dev/null +++ b/backend_demo/resource1/index.html @@ -0,0 +1,7 @@ + +Demo Resource 1 + +

Demo Resource 1

+

Это заглушка реальной админ-панели, доступная по пути /admins-network/resource1/. +Если вы видите эту страницу — маршрутизация HAProxy и авторизация через портал работают верно.

+ diff --git a/backend_demo/resource1/nginx.conf b/backend_demo/resource1/nginx.conf new file mode 100644 index 0000000..a4ad38d --- /dev/null +++ b/backend_demo/resource1/nginx.conf @@ -0,0 +1,13 @@ +# Копируется в /etc/nginx/sites-available/admin-demo-resource1 +# и линкуется в /etc/nginx/sites-enabled/ (см. README.md, шаг 8) +server { + listen 127.0.0.1:8081; + server_name _; + + root /var/www/admin-demo-resource1; + index index.html; + + location / { + try_files $uri $uri/ =404; + } +} diff --git a/backend_demo/resource2/index.html b/backend_demo/resource2/index.html new file mode 100644 index 0000000..f5f73b6 --- /dev/null +++ b/backend_demo/resource2/index.html @@ -0,0 +1,6 @@ + +Demo Resource 2 + +

Demo Resource 2

+

Это заглушка реальной админ-панели, доступная по пути /admins-network/resource2/.

+ diff --git a/backend_demo/resource2/nginx.conf b/backend_demo/resource2/nginx.conf new file mode 100644 index 0000000..384e7de --- /dev/null +++ b/backend_demo/resource2/nginx.conf @@ -0,0 +1,13 @@ +# Копируется в /etc/nginx/sites-available/admin-demo-resource2 +# и линкуется в /etc/nginx/sites-enabled/ (см. README.md, шаг 8) +server { + listen 127.0.0.1:8082; + server_name _; + + root /var/www/admin-demo-resource2; + index index.html; + + location / { + try_files $uri $uri/ =404; + } +} diff --git a/django_app/config/__init__.py b/django_app/config/__init__.py new file mode 100644 index 0000000..473a0f4 diff --git a/django_app/config/settings.py b/django_app/config/settings.py new file mode 100644 index 0000000..b694be0 --- /dev/null +++ b/django_app/config/settings.py @@ -0,0 +1,166 @@ +""" +Настройки портала администрирования. +Все секреты и параметры окружения читаются через python-decouple. +При запуске под systemd переменные передаются через EnvironmentFile +(/etc/admin-portal/admin-portal.env, см. systemd/admin-portal.service), +поэтому наличие .env-файла рядом с manage.py не обязательно, но +поддерживается для локальной разработки/отладки на самой ВМ. +""" +import ldap +from django_auth_ldap.config import LDAPSearch, GroupOfNamesType, LDAPGroupQuery +from decouple import Config, RepositoryEnv, config as env_config, Csv +from pathlib import Path +import os + +BASE_DIR = Path(__file__).resolve().parent.parent + +# Если рядом лежит .env (локальная разработка) — используем его, иначе +# полагаемся на переменные окружения процесса (боевой режим, systemd). +_dotenv_path = BASE_DIR / ".env" +config = Config(RepositoryEnv(_dotenv_path)) if _dotenv_path.exists() else env_config + +SECRET_KEY = config("DJANGO_SECRET_KEY") +DEBUG = config("DJANGO_DEBUG", default=False, cast=bool) +ALLOWED_HOSTS = config("DJANGO_ALLOWED_HOSTS", cast=Csv(), default="*") + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "portal", +] + +MIDDLEWARE = [ + "django.middleware.security.SecurityMiddleware", + "whitenoise.middleware.WhiteNoiseMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", + "django.middleware.clickjacking.XFrameOptionsMiddleware", +] + +ROOT_URLCONF = "config.urls" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [BASE_DIR / "portal" / "templates"], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +WSGI_APPLICATION = "config.wsgi.application" + +# --------------------------------------------------------------------------- +# База данных — локальный PostgreSQL на этой же ВМ (или указанный хост) +# --------------------------------------------------------------------------- +DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "NAME": config("POSTGRES_DB"), + "USER": config("POSTGRES_USER"), + "PASSWORD": config("POSTGRES_PASSWORD"), + "HOST": config("POSTGRES_HOST", default="127.0.0.1"), + "PORT": config("POSTGRES_PORT", default="5432"), + } +} + +# --------------------------------------------------------------------------- +# Кэш и сессии — локальный Redis на этой же ВМ (или указанный хост) +# --------------------------------------------------------------------------- +CACHES = { + "default": { + "BACKEND": "django_redis.cache.RedisCache", + "LOCATION": config("REDIS_URL", default="redis://127.0.0.1:6379/1"), + "OPTIONS": {"CLIENT_CLASS": "django_redis.client.DefaultClient"}, + } +} +SESSION_ENGINE = "django.contrib.sessions.backends.cache" +SESSION_CACHE_ALIAS = "default" +SESSION_COOKIE_NAME = "sessionid" +SESSION_COOKIE_AGE = 60 * 60 * 8 # 8 часов +SESSION_COOKIE_SECURE = True +SESSION_COOKIE_HTTPONLY = True +SESSION_COOKIE_SAMESITE = "Lax" +CSRF_COOKIE_SECURE = True + +# --------------------------------------------------------------------------- +# Авторизация через Active Directory (LDAP) +# --------------------------------------------------------------------------- +AUTH_LDAP_SERVER_URI = config("AD_SERVER_URI") +AUTH_LDAP_BIND_DN = config("AD_BIND_DN") +AUTH_LDAP_BIND_PASSWORD = config("AD_BIND_PASSWORD") + +AUTH_LDAP_USER_SEARCH = LDAPSearch( + config("AD_USER_SEARCH_BASE"), + ldap.SCOPE_SUBTREE, + "(sAMAccountName=%(user)s)", +) + +AUTH_LDAP_GROUP_SEARCH = LDAPSearch( + config("AD_GROUP_SEARCH_BASE"), + ldap.SCOPE_SUBTREE, + "(objectClass=group)", +) +AUTH_LDAP_GROUP_TYPE = GroupOfNamesType(name_attr="cn") + +# Атрибуты, которые тянем из AD в модель User при первом логине/обновлении +AUTH_LDAP_USER_ATTR_MAP = { + "first_name": "givenName", + "last_name": "sn", + "email": "mail", +} + +# В Django группы AD не мапятся на Django Group автоматически (нам это не нужно — +# группы читаются напрямую из LDAP через .ldap_user.group_names при построении +# каталога ресурсов, см. portal/services.py), но кэшируем сам LDAP-объект юзера. +AUTH_LDAP_ALWAYS_UPDATE_USER = True +AUTH_LDAP_CACHE_TIMEOUT = config("AD_GROUP_CACHE_TTL_SECONDS", default=600, cast=int) + +AUTH_LDAP_CONNECTION_OPTIONS = { + ldap.OPT_REFERRALS: 0, + ldap.OPT_NETWORK_TIMEOUT: 5, +} + +AUTHENTICATION_BACKENDS = [ + "django_auth_ldap.backend.LDAPBackend", + "django.contrib.auth.backends.ModelBackend", # для локального суперпользователя +] + +LOGIN_URL = "/login/" +LOGIN_REDIRECT_URL = "/" +LOGOUT_REDIRECT_URL = "/login/" + +# --------------------------------------------------------------------------- +# Токен для backend admin panels (см. docs/architecture.md, раздел 6) +# --------------------------------------------------------------------------- +RESOURCE_TOKEN_SECRET = config("RESOURCE_TOKEN_SECRET") +RESOURCE_TOKEN_TTL_SECONDS = config("RESOURCE_TOKEN_TTL_SECONDS", default=60, cast=int) +RESOURCE_TOKEN_COOKIE_NAME = "resource_token" + +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" +STORAGES = { + "staticfiles": { + "BACKEND": "whitenoise.storage.CompressedManifestStaticFilesStorage", + }, +} + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +LANGUAGE_CODE = "ru-ru" +TIME_ZONE = "Europe/Moscow" +USE_I18N = True +USE_TZ = True diff --git a/django_app/config/urls.py b/django_app/config/urls.py new file mode 100644 index 0000000..0ac8b0f --- /dev/null +++ b/django_app/config/urls.py @@ -0,0 +1,7 @@ +from django.contrib import admin +from django.urls import include, path + +urlpatterns = [ + path("django-admin/", admin.site.urls), + path("", include("portal.urls")), +] diff --git a/django_app/config/wsgi.py b/django_app/config/wsgi.py new file mode 100644 index 0000000..6f0a4c1 --- /dev/null +++ b/django_app/config/wsgi.py @@ -0,0 +1,7 @@ +import os + +from django.core.wsgi import get_wsgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_wsgi_application() diff --git a/django_app/manage.py b/django_app/manage.py new file mode 100644 index 0000000..0bae4ac --- /dev/null +++ b/django_app/manage.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python +import os +import sys + + +def main(): + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + try: + from django.core.management import execute_from_command_line + except ImportError as exc: + raise ImportError( + "Не удалось импортировать Django. Убедитесь, что окружение активно." + ) from exc + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/django_app/portal/__init__.py b/django_app/portal/__init__.py new file mode 100644 index 0000000..473a0f4 diff --git a/django_app/portal/admin.py b/django_app/portal/admin.py new file mode 100644 index 0000000..352882d --- /dev/null +++ b/django_app/portal/admin.py @@ -0,0 +1,26 @@ +from django.contrib import admin + +from .models import AccessLog, Resource, ResourceGroup + + +@admin.register(ResourceGroup) +class ResourceGroupAdmin(admin.ModelAdmin): + list_display = ("display_name", "ad_group_name") + search_fields = ("display_name", "ad_group_name") + + +@admin.register(Resource) +class ResourceAdmin(admin.ModelAdmin): + list_display = ("name", "group", "slug", "is_active") + list_filter = ("group", "is_active") + search_fields = ("name", "slug") + + +@admin.register(AccessLog) +class AccessLogAdmin(admin.ModelAdmin): + list_display = ("user", "resource", "ip_address", "created_at") + list_filter = ("resource",) + readonly_fields = [f.name for f in AccessLog._meta.fields] + + def has_add_permission(self, request): + return False diff --git a/django_app/portal/management/__init__.py b/django_app/portal/management/__init__.py new file mode 100644 index 0000000..473a0f4 diff --git a/django_app/portal/management/commands/__init__.py b/django_app/portal/management/commands/__init__.py new file mode 100644 index 0000000..473a0f4 diff --git a/django_app/portal/management/commands/sync_resources.py b/django_app/portal/management/commands/sync_resources.py new file mode 100644 index 0000000..3f9a097 --- /dev/null +++ b/django_app/portal/management/commands/sync_resources.py @@ -0,0 +1,56 @@ +""" +Позволяет декларативно описать группы/ресурсы в YAML-файле и синхронизировать +с базой одной командой — удобно для версионирования конфигурации в git. + +Использование: + python manage.py sync_resources resources.yaml + +Формат resources.yaml: + +groups: + - ad_group_name: admins-network + display_name: "Сетевые администраторы" + resources: + - slug: resource1 + name: "Панель управления коммутаторами" + description: "Cisco DNA Center" + - slug: resource2 + name: "Мониторинг сети" + description: "LibreNMS" +""" +import yaml +from django.core.management.base import BaseCommand, CommandError + +from portal.models import Resource, ResourceGroup + + +class Command(BaseCommand): + help = "Синхронизирует ResourceGroup/Resource с декларативным YAML-файлом" + + def add_arguments(self, parser): + parser.add_argument("yaml_path", type=str) + + def handle(self, *args, **options): + path = options["yaml_path"] + try: + with open(path, encoding="utf-8") as f: + data = yaml.safe_load(f) + except FileNotFoundError as exc: + raise CommandError(f"Файл не найден: {path}") from exc + + for group_data in data.get("groups", []): + group, _ = ResourceGroup.objects.update_or_create( + ad_group_name=group_data["ad_group_name"], + defaults={"display_name": group_data["display_name"]}, + ) + for res_data in group_data.get("resources", []): + Resource.objects.update_or_create( + group=group, + slug=res_data["slug"], + defaults={ + "name": res_data["name"], + "description": res_data.get("description", ""), + "is_active": res_data.get("is_active", True), + }, + ) + self.stdout.write(self.style.SUCCESS(f"Синхронизирована группа: {group.ad_group_name}")) diff --git a/django_app/portal/models.py b/django_app/portal/models.py new file mode 100644 index 0000000..7a81a7c --- /dev/null +++ b/django_app/portal/models.py @@ -0,0 +1,57 @@ +from django.conf import settings +from django.db import models + + +class ResourceGroup(models.Model): + """Привязка к реальной группе AD (adminusergroup из пути URL).""" + + ad_group_name = models.SlugField( + max_length=100, + unique=True, + help_text="Точное имя группы AD (cn), используется как сегмент пути в URL", + ) + display_name = models.CharField(max_length=200) + + class Meta: + verbose_name = "Группа ресурсов (AD-группа)" + verbose_name_plural = "Группы ресурсов (AD-группы)" + + def __str__(self): + return self.display_name + + +class Resource(models.Model): + """Конкретный административный ресурс внутри группы.""" + + group = models.ForeignKey(ResourceGroup, on_delete=models.CASCADE, related_name="resources") + slug = models.SlugField(max_length=100, help_text="Сегмент пути, например 'resource1'") + name = models.CharField(max_length=200) + description = models.TextField(blank=True) + is_active = models.BooleanField(default=True) + + class Meta: + unique_together = ("group", "slug") + verbose_name = "Ресурс" + verbose_name_plural = "Ресурсы" + + def __str__(self): + return f"{self.group.ad_group_name}/{self.slug}" + + @property + def path(self) -> str: + return f"/{self.group.ad_group_name}/{self.slug}/" + + +class AccessLog(models.Model): + user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.SET_NULL, null=True) + resource = models.ForeignKey(Resource, on_delete=models.SET_NULL, null=True) + ip_address = models.GenericIPAddressField(null=True, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + verbose_name = "Запись аудита доступа" + verbose_name_plural = "Аудит доступа" + ordering = ["-created_at"] + + def __str__(self): + return f"{self.user} -> {self.resource} @ {self.created_at:%Y-%m-%d %H:%M}" diff --git a/django_app/portal/services.py b/django_app/portal/services.py new file mode 100644 index 0000000..23c9065 --- /dev/null +++ b/django_app/portal/services.py @@ -0,0 +1,61 @@ +""" +Вспомогательная логика портала: + - получение списка AD-групп текущего пользователя (из объекта django-auth-ldap) + - подбор доступных Resource по этим группам + - выпуск короткоживущего подписанного токена для backend admin panel +""" +import time +from typing import Iterable + +import jwt +from django.conf import settings + +from .models import Resource, ResourceGroup + + +def get_user_ad_groups(user) -> set[str]: + """ + django-auth-ldap прикрепляет к user объект `ldap_user` с атрибутом group_names + (набор cn всех групп, в которых состоит пользователь в AD). + Для локального суперпользователя (не через LDAP) атрибута не будет — + возвращаем пустой набор, дальше он видит только ресурсы, явно открытые ModelBackend-группам. + """ + ldap_user = getattr(user, "ldap_user", None) + if ldap_user is None: + return set() + return set(ldap_user.group_names) + + +def get_available_resources(user) -> Iterable[Resource]: + if user.is_superuser: + return Resource.objects.filter(is_active=True).select_related("group") + + ad_groups = get_user_ad_groups(user) + if not ad_groups: + return Resource.objects.none() + + group_ids = ResourceGroup.objects.filter(ad_group_name__in=ad_groups).values_list("id", flat=True) + return Resource.objects.filter(group_id__in=group_ids, is_active=True).select_related("group") + + +def user_can_access_resource(user, resource: Resource) -> bool: + if user.is_superuser: + return True + return resource.group.ad_group_name in get_user_ad_groups(user) + + +def issue_resource_token(user, resource: Resource) -> str: + """ + Короткоживущий JWT, который backend admin panel может провалидировать + тем же RESOURCE_TOKEN_SECRET (см. docs/architecture.md, раздел 6). + """ + now = int(time.time()) + payload = { + "sub": user.username, + "email": getattr(user, "email", ""), + "group": resource.group.ad_group_name, + "resource": resource.slug, + "iat": now, + "exp": now + settings.RESOURCE_TOKEN_TTL_SECONDS, + } + return jwt.encode(payload, settings.RESOURCE_TOKEN_SECRET, algorithm="HS256") diff --git a/django_app/portal/templates/portal/base.html b/django_app/portal/templates/portal/base.html new file mode 100644 index 0000000..40cca0a --- /dev/null +++ b/django_app/portal/templates/portal/base.html @@ -0,0 +1,33 @@ + + + + + {% block title %}Портал администрирования{% endblock %} + + + + {% if user.is_authenticated %} +
+ Портал администрирования + {{ user.get_username }} · Выйти +
+ {% endif %} +
+ {% block content %}{% endblock %} +
+ + diff --git a/django_app/portal/templates/portal/dashboard.html b/django_app/portal/templates/portal/dashboard.html new file mode 100644 index 0000000..c4c220b --- /dev/null +++ b/django_app/portal/templates/portal/dashboard.html @@ -0,0 +1,19 @@ +{% extends "portal/base.html" %} +{% block title %}Мои ресурсы{% endblock %} +{% block content %} +

Доступные ресурсы

+ +{% if resources %} + {% for resource in resources %} +
+
+

{{ resource.name }}

+

{{ resource.group.display_name }} · {{ resource.description }}

+
+ Открыть +
+ {% endfor %} +{% else %} +

Вашей AD-группе не назначено ни одного ресурса. Обратитесь к администратору портала.

+{% endif %} +{% endblock %} diff --git a/django_app/portal/templates/portal/login.html b/django_app/portal/templates/portal/login.html new file mode 100644 index 0000000..299c99c --- /dev/null +++ b/django_app/portal/templates/portal/login.html @@ -0,0 +1,17 @@ +{% extends "portal/base.html" %} +{% block title %}Вход — Портал администрирования{% endblock %} +{% block content %} + +{% endblock %} diff --git a/django_app/portal/urls.py b/django_app/portal/urls.py new file mode 100644 index 0000000..e5b0717 --- /dev/null +++ b/django_app/portal/urls.py @@ -0,0 +1,13 @@ +from django.contrib.auth import views as auth_views +from django.urls import path + +from . import views + +urlpatterns = [ + path("login/", views.PortalLoginView.as_view(), name="login"), + path("logout/", auth_views.LogoutView.as_view(), name="logout"), + path("healthz", views.healthz, name="healthz"), + path("", views.dashboard, name="dashboard"), + # Должен идти последним — самый общий шаблон пути ресурса + path("//", views.open_resource, name="open_resource"), +] diff --git a/django_app/portal/views.py b/django_app/portal/views.py new file mode 100644 index 0000000..fe733ca --- /dev/null +++ b/django_app/portal/views.py @@ -0,0 +1,62 @@ +from django.contrib.auth import views as auth_views +from django.contrib.auth.decorators import login_required +from django.http import HttpResponse, HttpResponseForbidden, HttpResponseRedirect +from django.shortcuts import get_object_or_404, render +from django.urls import reverse_lazy +from django.views.decorators.http import require_GET + +from .models import AccessLog, Resource +from .services import get_available_resources, issue_resource_token, user_can_access_resource + + +class PortalLoginView(auth_views.LoginView): + template_name = "portal/login.html" + redirect_authenticated_user = True + + def get_success_url(self): + # Если пользователь пришёл по прямой ссылке на ресурс (?next=/group/resource/), + # HAProxy направил его на портал для логина — после успеха возвращаем туда же. + next_url = self.request.POST.get("next") or self.request.GET.get("next") + return next_url or reverse_lazy("dashboard") + + +@login_required +def dashboard(request): + resources = get_available_resources(request.user) + return render(request, "portal/dashboard.html", {"resources": resources}) + + +@login_required +@require_GET +def open_resource(request, group_slug: str, resource_slug: str): + resource = get_object_or_404( + Resource, group__ad_group_name=group_slug, slug=resource_slug, is_active=True + ) + + if not user_can_access_resource(request.user, resource): + return HttpResponseForbidden("Доступ к этому ресурсу не предоставлен вашей AD-группе.") + + AccessLog.objects.create( + user=request.user, + resource=resource, + ip_address=request.META.get("REMOTE_ADDR"), + ) + + token = issue_resource_token(request.user, resource) + + response = HttpResponseRedirect(resource.path) + response.set_cookie( + key="resource_token", + value=token, + max_age=60, # HAProxy снимет cookie на первом же запросе к backend'у + path=resource.path, # cookie видна только на пути конкретного ресурса + secure=True, + httponly=True, + samesite="Lax", + ) + return response + + +@require_GET +def healthz(request): + return HttpResponse("ok") diff --git a/django_app/requirements.txt b/django_app/requirements.txt new file mode 100644 index 0000000..09cf9dd --- /dev/null +++ b/django_app/requirements.txt @@ -0,0 +1,9 @@ +Django==5.0.9 +django-auth-ldap==4.8.0 +psycopg2-binary==2.9.9 +django-redis==5.4.0 +gunicorn==22.0.0 +python-decouple==3.8 +PyJWT==2.9.0 +PyYAML==6.0.2 +whitenoise==6.7.0 diff --git a/django_app/resources.example.yaml b/django_app/resources.example.yaml new file mode 100644 index 0000000..2479602 --- /dev/null +++ b/django_app/resources.example.yaml @@ -0,0 +1,17 @@ +groups: + - ad_group_name: admins-network + display_name: "Сетевые администраторы" + resources: + - slug: resource1 + name: "Демо-ресурс 1" + description: "Демонстрационная админка для проверки маршрутизации" + - slug: resource2 + name: "Демо-ресурс 2" + description: "Демонстрационная админка для проверки маршрутизации" + + - ad_group_name: admins-storage + display_name: "Администраторы систем хранения" + resources: + - slug: ceph-dashboard + name: "Ceph Dashboard" + description: "Панель управления кластером хранения (пример реального ресурса)" diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..3229b72 --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,224 @@ +# Архитектура решения: портал администрирования HAProxy + Django + AD +## Вариант развёртывания: одна ВМ, без контейнеризации + +## 1. Назначение + +Единая точка входа для администраторов инфраструктуры: пользователь логинится +своей доменной учётной записью, видит только те административные ресурсы, +которые открыты его AD-группе, и одним кликом переходит в конкретную +систему по адресу `https://///...`, не +запоминая адреса и не имея прямого доступа к внутренним админкам, минуя +портал. + +Этот документ описывает вариант развёртывания **без Docker** — все +компоненты ставятся как обычные пакеты дистрибутива и управляются через +`systemd` на одной виртуальной машине. Это упрощает эксплуатацию в +организациях, где контейнеризация не принята, и снимает зависимость от +Docker-инфраструктуры. + +## 2. Компоненты и то, как они запущены на ВМ + +| Компонент | Как установлен | Как запущен | Слушает | +|---|---|---|---| +| **HAProxy** | пакет `haproxy` из репозитория дистрибутива | systemd-юнит `haproxy.service` (штатный, из пакета) | `0.0.0.0:80`, `0.0.0.0:443`, `127.0.0.1:8404` (статистика) | +| **Django frontend (portal)** | Python venv в `/opt/admin-portal/venv`, код в `/opt/admin-portal/django_app` | Gunicorn под собственным systemd-юнитом `admin-portal.service`, от имени системного пользователя `adminportal` | `127.0.0.1:8000` | +| **Active Directory** | внешняя система, ничего не устанавливаем | — | LDAP/LDAPS (389/636) на контроллере домена | +| **PostgreSQL** | пакет `postgresql` | systemd-юнит `postgresql.service` (штатный) | `127.0.0.1:5432` | +| **Redis** | пакет `redis-server` | systemd-юнит `redis-server.service` (штатный) | `127.0.0.1:6379` | +| **Backend admin panels** | в решении — 2 демо-заглушки на Nginx; в проде — реальные системы | демо: `nginx.service`, отдельные `server`-блоки на `127.0.0.1:8081/8082`; прод: на других хостах сети | только внутри периметра, не публикуются наружу | + +Все процессы, кроме HAProxy, слушают исключительно `127.0.0.1` — наружу +из ВМ открыты только порты 80/443 (см. `ufw` в README, шаг 11). Это заменяет +собой изоляцию, которую в контейнерном варианте давала Docker-сеть. + +## 3. Общая схема + +``` + HTTPS +Браузер администратора ────────────────► HAProxy :443 (systemd: haproxy) + │ + TLS termination, ACL-роутинг по path, HSTS, rate-limit + │ + ┌──────────────────────────────┼───────────────────────────────┐ + │ путь НЕ ресурсный, │ путь ресурсный + нет │ путь ресурсный + есть + │ или /login /static/... │ resource_token │ resource_token + ▼ ▼ ▼ + ┌─────────────────────────────────────────┐ ┌──────────────────────┐ + │ Gunicorn 127.0.0.1:8000 (systemd: │ │ Nginx 127.0.0.1:808x │ + │ admin-portal.service) │ │ (демо) или реальная │ + │ - django-auth-ldap -> AD bind │ │ админка на др. хосте │ + │ - каталог ресурсов из PostgreSQL │ └──────────────────────┘ + │ - сессии в Redis │ ▲ + │ - выпуск JWT для перехода на ресурс │──── redirect + cookie ───┘ + └─────────────────────────────────────────┘ resource_token + │ + ▼ + PostgreSQL 127.0.0.1:5432 (ResourceGroup, Resource, AccessLog) + Redis 127.0.0.1:6379 (сессии Django, кэш групп LDAP) +``` + +## 4. Поток аутентификации + +1. Браузер отправляет запрос на `https://fqdn/`. +2. HAProxy (см. `haproxy.cfg`) не находит cookie `resource_token` на + нересурсном пути → направляет на `backend be_django` (Gunicorn, + `127.0.0.1:8000`). +3. Django рендерит форму логина. Пользователь вводит `sAMAccountName` + пароль. +4. `django-auth-ldap`: + - выполняет bind сервисным аккаунтом (`AUTH_LDAP_BIND_DN` из + `/etc/admin-portal/admin-portal.env`), + - ищет пользователя по `AUTH_LDAP_USER_SEARCH` (`sAMAccountName=%(user)s`), + - выполняет повторный bind уже от имени найденного DN с введённым паролем — + это и есть проверка пароля, + - при успехе подтягивает атрибуты (`givenName`, `sn`, `mail`) и список + групп (`AUTH_LDAP_GROUP_SEARCH`), кладёт их в `request.user.ldap_user`. +5. Django создаёт сессию в Redis (локальный, `127.0.0.1:6379`), выставляет + cookie `sessionid`. +6. Пользователь видит дашборд со списком `Resource`, чьи + `ResourceGroup.ad_group_name` входят в набор групп пользователя из AD. + +## 5. Поток перехода на ресурс + +1. Клик по карточке ресурса → `GET /{group}/{resource}/`. +2. Первый переход всегда обрабатывается Django (`open_resource` view), + поскольку у HAProxy ещё нет cookie `resource_token` для этого пути — + маршрут `is_resource_path !has_resource_token` в `haproxy.cfg` уводит + запрос на `be_django`. +3. `open_resource` view: + - проверяет, что ресурс существует, активен и открыт AD-группе + пользователя, + - пишет запись в `AccessLog` (кто, когда, какой ресурс, IP — таблица в + PostgreSQL), + - выпускает JWT (`RESOURCE_TOKEN_SECRET`, TTL по умолчанию 60 секунд), + - кладёт токен в cookie `resource_token`, ограниченную `path` конкретного + ресурса, + - делает 302 редирект на тот же путь. +4. HAProxy на повторном запросе видит `cook(resource_token) -m found` → + маршрутизация по `group_map.cfg` на нужный `backend` (демо-Nginx на + `127.0.0.1:808x` или реальная система на другом хосте). Перед + проксированием HAProxy снимает cookie в заголовок `X-Resource-Token` и + удаляет исходный `Cookie` — backend никогда не видит `sessionid` портала. +5. Backend admin panel (при наличии соответствующей интеграции) валидирует + JWT тем же `RESOURCE_TOKEN_SECRET` и на основании `sub` + логинит/находит пользователя в своей системе. + +### Важно: токен — это разовый хэндшейк, а не сессия внутри backend'а + +`resource_token` живёт всего 60 секунд и привязан к `path` конкретного +ресурса. Backend admin panel, приняв и провалидировав токен один раз, +должен завести собственную сессию для дальнейшей работы пользователя — так +делает подавляющее большинство систем при интеграции с внешним SSO. Если +полагаться на `resource_token` при каждом запросе, через 60 секунд любой +запрос без свежего токена уйдёт обратно в Django на переоформление. При +необходимости TTL можно увеличить через `RESOURCE_TOKEN_TTL_SECONDS` в +`.env`, но это не заменяет полноценную сессию backend'а. + +## 6. Варианты передачи identity в backend (сравнение) + +| Вариант | Как работает | Когда применять | +|---|---|---| +| **A. Доверенный заголовок** (`X-Remote-User`) | HAProxy/Django прокидывает имя пользователя как есть, backend доверяет заголовку | Полностью изолированный сегмент сети, backend не имеет других входов | +| **B. Подписанный JWT** (реализовано в этом решении) | Django выпускает короткоживущий токен, backend проверяет подпись | Рекомендуемый вариант по умолчанию | +| **C. Полноценный SSO (SAML/OIDC)** | Backend сам является Service Provider, IdP — AD FS/Keycloak | Backend уже умеет SAML/OIDC "из коробки" (Grafana, GitLab и т.п.) | + +## 7. Модель данных + +``` +ResourceGroup + ad_group_name (slug, unique) -- реальное имя группы AD, сегмент пути + display_name (str) + +Resource + group -> ResourceGroup + slug (str) -- второй сегмент пути + name (str) + description (text) + is_active (bool) + unique(group, slug) + +AccessLog + user -> auth.User (nullable) + resource -> Resource (nullable) + ip_address (inet) + created_at (datetime, auto) +``` + +Хранится в PostgreSQL, установленном локально на ВМ (`/var/lib/postgresql`). + +## 8. Наполнение и сопровождение каталога ресурсов + +1. **Django admin** (`https:///django-admin/`) — для точечных правок. +2. **Декларативный YAML + management-команда** `sync_resources`: + ```bash + sudo -u adminportal bash -c \ + 'set -a; source /etc/admin-portal/admin-portal.env; set +a; \ + /opt/admin-portal/venv/bin/python /opt/admin-portal/django_app/manage.py \ + sync_resources /opt/admin-portal/django_app/resources.example.yaml' + ``` + Команда идемпотентна, можно хранить YAML в git и гонять через CI/CD по SSH. + +## 9. Безопасность + +- TLS 1.2+/1.3, HSTS, современные cipher suites — терминируются на HAProxy; + все внутренние компоненты общаются по `127.0.0.1` в открытом виде (трафик + не покидает ВМ) либо по внутренней сети без выхода наружу. +- Пароли пользователей не хранятся на портале — только bind-запрос к AD. +- CSRF-защита на всех формах Django. +- Сессионные cookie: `Secure`, `HttpOnly`, `SameSite=Lax`. +- `resource_token` — отдельный секрет, TTL 60 секунд, `path`-scope, + `HttpOnly`. +- HAProxy снимает cookie сессии портала перед проксированием на backend. +- Аудит: каждый переход на ресурс логируется в `AccessLog`. +- Rate-limit на `/login/` через `stick-table` в HAProxy. +- Файл `/etc/admin-portal/admin-portal.env` с секретами — права `640`, + владелец `root:adminportal`, читать может только процесс приложения. +- systemd-юнит `admin-portal.service` запущен от непривилегированного + пользователя `adminportal` (`nologin`), с `NoNewPrivileges`, `PrivateTmp`, + `ProtectSystem=full`. +- `ufw` открывает наружу только 80/443; PostgreSQL, Redis, Gunicorn и + демо-Nginx слушают исключительно loopback — недоступны из сети в принципе, + даже без дополнительных правил фаервола. +- Backend admin panels в проде должны быть в отдельном сетевом сегменте без + прямого доступа из интернета — HAProxy как единственная точка входа + снаружи. + +## 10. Отказоустойчивость и масштабирование без Docker + +Однонодовый вариант (этот пакет) подходит для старта и для организаций +среднего размера. Пути роста при увеличении нагрузки: + +- **HAProxy**: поднять второй экземпляр HAProxy на второй ВМ + `keepalived` + (VRRP) для плавающего IP, либо использовать внешний L4-балансировщик перед + парой ВМ с HAProxy. +- **Django/Gunicorn**: поскольку сессии и кэш групп уже вынесены в Redis, + можно поднять `admin-portal.service` на второй ВМ тем же способом + (шаги 3–8 из README) и добавить в `backend be_django` вторую строку + `server django2 10.0.0.11:8000 check` — балансировка без изменения + остальной конфигурации. +- **PostgreSQL**: для прод-контура — вынести на отдельную ВМ с репликой + (потоковая репликация PostgreSQL), поменять `POSTGRES_HOST` в `.env` на + обеих Django-нодах. +- **Redis**: при росте нагрузки — Redis Sentinel (2–3 ВМ), либо вынос на + отдельную ВМ без кластеризации, если нагрузка умеренная (сессии — лёгкая + нагрузка для Redis). +- **LDAP**: кэш групп на `AUTH_LDAP_CACHE_TIMEOUT` (по умолчанию 600 секунд) + снижает нагрузку на контроллеры домена независимо от количества Django-нод. + +## 11. Ограничения и что осознанно вынесено за рамки + +- Установочный скрипт (`scripts/install.sh`) рассчитан на Ubuntu/Debian + (`apt`); для RHEL/CentOS/Alma потребуется адаптировать имена пакетов + (`dnf install haproxy nginx postgresql-server redis python3 ...`) и путь + до `python3-venv` — сама архитектура и код Django не меняются. +- TLS-сертификат от внешнего центра сертификации подкладывается вручную — + автоматический выпуск/продление (например, через `certbot`) в рамках + данного пакета не настраивается, так как процедура сильно зависит от + того, публичный это FQDN или внутренний корпоративный CA. +- Мультиузловая отказоустойчивость (второй HAProxy, реплика PostgreSQL, + Redis Sentinel) описана на уровне принципов в разделе 10, но конкретные + Ansible/Terraform-плейбуки под неё не входят в этот пакет — количество ВМ + и топология сильно зависят от инфраструктуры конкретной организации. +- Интеграция конкретных backend admin panel с валидацией JWT специфична для + каждой системы; в решении заложен универсальный контракт (заголовок + `X-Resource-Token`, алгоритм `HS256`, общий секрет `RESOURCE_TOKEN_SECRET`), + но код валидации на стороне внутренних систем — задача их владельцев. diff --git a/scripts/generate_secrets.sh b/scripts/generate_secrets.sh old mode 100644 new mode 100755 diff --git a/scripts/install.sh b/scripts/install.sh old mode 100644 new mode 100755