test repo

This commit is contained in:
Нехорошев Михаил Владимирович committed 2026-08-31 16:18:11 +03:00
1 parent 7c0a85f00f
commit 1eff3aa2ea
27 files changed
+843 -4

No files matched your search

+12 -4
View File
@@ -1,12 +1,12 @@
# Портал администрирования: HAProxy + Django + AD → Backend admin panels # Портал администрирования: HAProxy + Django + AD → Backend admin panels
## Общее описание реализованного проекта ## Установка на виртуальную машину (без Docker)
Единая точка входа для администраторов: логин через Active Directory, Единая точка входа для администраторов: логин через Active Directory,
персональный список доступных ресурсов по AD-группам, переход в нужную персональный список доступных ресурсов по AD-группам, переход в нужную
админку по адресу `https://<FQDN>/<adminusergroup>/<resource>/...`. админку по адресу `https://<FQDN>/<adminusergroup>/<resource>/...`.
Все компоненты устанавливаются как обычные пакеты/systemd-сервисы Все компоненты устанавливаются как обычные пакеты/systemd-сервисы
непосредственно на одну ВМ. непосредственно на одну ВМ (Ubuntu 22.04/24.04 или Debian 12).
## 1. Состав решения ## 1. Состав решения
@@ -61,8 +61,8 @@ admin-portal-vm/
## 3. Требования к ВМ ## 3. Требования к ВМ
- РедОС либо АстраЛинукс - Ubuntu 22.04/24.04 LTS или Debian 12, root/sudo-доступ
- 2 vCPU / 4 GB RAM достаточно для старта (PostgreSQL, Redis, Gunicorn, - 2 vCPU / 2 GB RAM достаточно для старта (PostgreSQL, Redis, Gunicorn,
HAProxy, Nginx — все на одной машине) HAProxy, Nginx — все на одной машине)
- Сетевой доступ до контроллера домена AD (LDAP/LDAPS, порт 389/636) - Сетевой доступ до контроллера домена AD (LDAP/LDAPS, порт 389/636)
- Технический пользователь AD с правом bind + поиска (service account), - Технический пользователь AD с правом bind + поиска (service account),
@@ -224,6 +224,14 @@ cat fullchain.pem privkey.pem | sudo tee /etc/haproxy/certs/fqdn.pem >/dev/null
sudo chmod 600 /etc/haproxy/certs/fqdn.pem 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 ```bash
+7
View File
@@ -0,0 +1,7 @@
<!DOCTYPE html>
<html lang="ru"><head><meta charset="UTF-8"><title>Demo Resource 1</title></head>
<body style="font-family:sans-serif;padding:40px;">
<h1>Demo Resource 1</h1>
<p>Это заглушка реальной админ-панели, доступная по пути /admins-network/resource1/.
Если вы видите эту страницу — маршрутизация HAProxy и авторизация через портал работают верно.</p>
</body></html>
+13
View File
@@ -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;
}
}
+6
View File
@@ -0,0 +1,6 @@
<!DOCTYPE html>
<html lang="ru"><head><meta charset="UTF-8"><title>Demo Resource 2</title></head>
<body style="font-family:sans-serif;padding:40px;">
<h1>Demo Resource 2</h1>
<p>Это заглушка реальной админ-панели, доступная по пути /admins-network/resource2/.</p>
</body></html>
+13
View File
@@ -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;
}
}
View File
Whitespace-only changes.
+166
View File
@@ -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
+7
View File
@@ -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")),
]
+7
View File
@@ -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()
+18
View File
@@ -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()
View File
Whitespace-only changes.
+26
View File
@@ -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
Whitespace-only changes.
Whitespace-only changes.
@@ -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}"))
+57
View File
@@ -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}"
+61
View File
@@ -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")
@@ -0,0 +1,33 @@
<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>{% block title %}Портал администрирования{% endblock %}</title>
<style>
body { font-family: -apple-system, Segoe UI, Roboto, sans-serif; background:#f4f5f7; margin:0; }
header { background:#1f2937; color:#fff; padding:16px 32px; display:flex; justify-content:space-between; align-items:center; }
header a { color:#fff; text-decoration:none; font-size:14px; }
main { max-width:960px; margin:32px auto; padding:0 16px; }
.card { background:#fff; border-radius:10px; padding:20px; margin-bottom:14px; box-shadow:0 1px 3px rgba(0,0,0,.08); display:flex; justify-content:space-between; align-items:center; }
.card h3 { margin:0 0 4px; font-size:16px; }
.card p { margin:0; color:#6b7280; font-size:13px; }
.btn { background:#2563eb; color:#fff; padding:8px 16px; border-radius:6px; text-decoration:none; font-size:13px; }
.empty { color:#6b7280; text-align:center; margin-top:64px; }
form.login { max-width:340px; margin:80px auto; background:#fff; padding:32px; border-radius:10px; box-shadow:0 1px 3px rgba(0,0,0,.08); }
form.login input { width:100%; padding:10px; margin-bottom:12px; border:1px solid #d1d5db; border-radius:6px; box-sizing:border-box; }
form.login button { width:100%; padding:10px; background:#2563eb; color:#fff; border:0; border-radius:6px; cursor:pointer; }
.errors { color:#dc2626; font-size:13px; margin-bottom:12px; }
</style>
</head>
<body>
{% if user.is_authenticated %}
<header>
<span>Портал администрирования</span>
<span>{{ user.get_username }} &middot; <a href="{% url 'logout' %}">Выйти</a></span>
</header>
{% endif %}
<main>
{% block content %}{% endblock %}
</main>
</body>
</html>
@@ -0,0 +1,19 @@
{% extends "portal/base.html" %}
{% block title %}Мои ресурсы{% endblock %}
{% block content %}
<h2>Доступные ресурсы</h2>
{% if resources %}
{% for resource in resources %}
<div class="card">
<div>
<h3>{{ resource.name }}</h3>
<p>{{ resource.group.display_name }} &middot; {{ resource.description }}</p>
</div>
<a class="btn" href="{{ resource.path }}">Открыть</a>
</div>
{% endfor %}
{% else %}
<p class="empty">Вашей AD-группе не назначено ни одного ресурса. Обратитесь к администратору портала.</p>
{% endif %}
{% endblock %}
@@ -0,0 +1,17 @@
{% extends "portal/base.html" %}
{% block title %}Вход — Портал администрирования{% endblock %}
{% block content %}
<form class="login" method="post">
{% csrf_token %}
<h2>Вход через AD</h2>
{% if form.errors %}
<div class="errors">Неверный логин или пароль.</div>
{% endif %}
<input type="text" name="username" placeholder="Логин AD" autofocus required>
<input type="password" name="password" placeholder="Пароль" required>
{% if request.GET.next %}
<input type="hidden" name="next" value="{{ request.GET.next }}">
{% endif %}
<button type="submit">Войти</button>
</form>
{% endblock %}
+13
View File
@@ -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("<slug:group_slug>/<slug:resource_slug>/", views.open_resource, name="open_resource"),
]
+62
View File
@@ -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")
+9
View File
@@ -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
+17
View File
@@ -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: "Панель управления кластером хранения (пример реального ресурса)"
+224
View File
@@ -0,0 +1,224 @@
# Архитектура решения: портал администрирования HAProxy + Django + AD
## Вариант развёртывания: одна ВМ, без контейнеризации
## 1. Назначение
Единая точка входа для администраторов инфраструктуры: пользователь логинится
своей доменной учётной записью, видит только те административные ресурсы,
которые открыты его AD-группе, и одним кликом переходит в конкретную
систему по адресу `https://<FQDN>/<adminusergroup>/<resource>/...`, не
запоминая адреса и не имея прямого доступа к внутренним админкам, минуя
портал.
Этот документ описывает вариант развёртывания **без 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://<FQDN>/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`),
но код валидации на стороне внутренних систем — задача их владельцев.
Regular → Executable
View File
File mode changed.
Regular → Executable
View File
File mode changed.