Итерация 1: каркас Slim 4 приложения
- Slim 4 + php-di контейнер с маршрутизацией - Docker Compose: app (PHP 8.3-FPM) + web (nginx) + db (MariaDB) - Phinx миграция: таблица users (uuid, username, password_hash) - Auth: login/logout с сессиями - Setup: создание первого пользователя - Dashboard с layout (Bootstrap 5.3) - CLI: bin/console, bin/run-scan-worker.php - Smoke test: scripts/check.sh - README.md, PLAN.md
This commit is contained in:
commit
e7732f5cee
|
|
@ -0,0 +1,17 @@
|
||||||
|
APP_ENV=development
|
||||||
|
APP_SECRET=change-this-to-random-string
|
||||||
|
ENCRYPTION_KEY=change-this-to-32-byte-hex
|
||||||
|
|
||||||
|
DB_HOST=db
|
||||||
|
DB_PORT=3306
|
||||||
|
DB_DATABASE=domovoy
|
||||||
|
DB_USERNAME=domovoy
|
||||||
|
DB_PASSWORD=domovoy
|
||||||
|
|
||||||
|
MYSQL_ROOT_PASSWORD=rootpass
|
||||||
|
|
||||||
|
SSH_CONNECT_TIMEOUT_SECONDS=5
|
||||||
|
SSH_AUTH_TIMEOUT_SECONDS=10
|
||||||
|
SSH_COMMAND_TIMEOUT_SECONDS=8
|
||||||
|
SSH_TOTAL_SCAN_TIMEOUT_SECONDS=60
|
||||||
|
SSH_RETRY_COUNT=0
|
||||||
|
|
@ -0,0 +1,3 @@
|
||||||
|
/vendor/
|
||||||
|
.env
|
||||||
|
.phpunit.result.cache
|
||||||
|
|
@ -0,0 +1,393 @@
|
||||||
|
# План реализации проекта "Домовой"
|
||||||
|
|
||||||
|
Документ представляет собой пошаговый план разработки.
|
||||||
|
Каждая итерация — самостоятельный кусок функционала, который
|
||||||
|
можно реализовать, протестировать и запустить независимо.
|
||||||
|
|
||||||
|
После каждой итерации запускается scripts/check.sh.
|
||||||
|
Без успешного smoke test итерация не считается завершённой.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итерация 1. Каркас приложения
|
||||||
|
|
||||||
|
Цель: приложение открывается, есть логин, база, миграции, layout.
|
||||||
|
|
||||||
|
Что делаем:
|
||||||
|
- Slim 4 приложение с маршрутами
|
||||||
|
- Docker Compose: app (PHP 8.3) + MariaDB
|
||||||
|
- .env.example со всеми переменными
|
||||||
|
- Phinx: конфигурация, миграция users
|
||||||
|
- Регистрация первого пользователя (CLI setup)
|
||||||
|
- Логин/Logout с сессиями
|
||||||
|
- Bootstrap layout с левым меню и верхней панелью
|
||||||
|
- Dashboard с заглушками счётчиков
|
||||||
|
- scripts/check.sh с базовыми проверками
|
||||||
|
|
||||||
|
Файлы:
|
||||||
|
composer.json
|
||||||
|
phinx.php
|
||||||
|
docker-compose.yml
|
||||||
|
docker/Dockerfile
|
||||||
|
.env.example
|
||||||
|
public/index.php
|
||||||
|
public/assets/css/app.css
|
||||||
|
app/Controllers/AuthController.php
|
||||||
|
app/Controllers/DashboardController.php
|
||||||
|
app/Controllers/SetupController.php
|
||||||
|
app/Middleware/AuthMiddleware.php
|
||||||
|
app/Repositories/UserRepository.php
|
||||||
|
app/Services/AuthService.php
|
||||||
|
templates/layout.php
|
||||||
|
templates/auth/login.php
|
||||||
|
templates/dashboard/index.php
|
||||||
|
migrations/20250526000001CreateUsers.php
|
||||||
|
bin/console
|
||||||
|
scripts/check.sh
|
||||||
|
README.md
|
||||||
|
|
||||||
|
Проверка:
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose exec app php vendor/bin/phinx migrate
|
||||||
|
docker compose exec app php bin/console setup:user
|
||||||
|
curl -I http://localhost:8080/login
|
||||||
|
# Открыть в браузере, залогиниться, увидеть dashboard
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итерация 2. Сканирование сети
|
||||||
|
|
||||||
|
Цель: добавить диапазон, запустить scan, увидеть найденные хосты.
|
||||||
|
|
||||||
|
Что делаем:
|
||||||
|
- Миграции: network_ranges, scan_jobs, discovered_hosts
|
||||||
|
- NetworkRanges CRUD (вкладка Discovery)
|
||||||
|
- NetworkScanner: ping sweep + TCP connect + ARP + reverse DNS
|
||||||
|
- CLI worker bin/run-scan-worker.php
|
||||||
|
- Страница Discovery: таблица discovered_hosts
|
||||||
|
- htmx для запуска scan и обновления статуса scan_jobs
|
||||||
|
- Audit log для действий сканрования
|
||||||
|
|
||||||
|
Файлы (новые/изменённые):
|
||||||
|
migrations/20250526000002CreateNetworkRanges.php
|
||||||
|
migrations/20250526000003CreateScanJobs.php
|
||||||
|
migrations/20250526000004CreateDiscoveredHosts.php
|
||||||
|
migrations/20250526000005CreateAuditLog.php
|
||||||
|
app/Controllers/DiscoveryController.php
|
||||||
|
app/Controllers/NetworkRangeController.php
|
||||||
|
app/Services/Discovery/NetworkScanner.php
|
||||||
|
app/Services/Discovery/PingScanner.php
|
||||||
|
app/Services/Discovery/TcpPortScanner.php
|
||||||
|
app/Services/Discovery/ArpTableReader.php
|
||||||
|
app/Services/Discovery/HostFingerprintService.php
|
||||||
|
app/Services/Jobs/ScanJobRunner.php
|
||||||
|
app/Services/Jobs/JobQueue.php
|
||||||
|
app/Repositories/ScanJobRepository.php
|
||||||
|
app/Repositories/DiscoveredHostRepository.php
|
||||||
|
app/Repositories/NetworkRangeRepository.php
|
||||||
|
app/Repositories/AuditLogRepository.php
|
||||||
|
templates/discovery/index.php
|
||||||
|
templates/discovery/ranges.php
|
||||||
|
templates/discovery/hosts.php
|
||||||
|
bin/run-scan-worker.php
|
||||||
|
|
||||||
|
Проверка:
|
||||||
|
# Добавить диапазон 192.168.1.0/24
|
||||||
|
# Запустить scan через UI
|
||||||
|
# В другом терминале:
|
||||||
|
docker compose exec app php bin/run-scan-worker.php
|
||||||
|
# Увидеть найденные хосты в таблице Discovery
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итерация 3. Инвентарь устройств
|
||||||
|
|
||||||
|
Цель: создавать карточки устройств руками и из найденных хостов.
|
||||||
|
|
||||||
|
Что делаем:
|
||||||
|
- Миграция devices
|
||||||
|
- Devices CRUD (список, создание, редактирование, карточка)
|
||||||
|
- Создание device из discovered_host (кнопка "Создать устройство")
|
||||||
|
- Игнорирование discovered_host (кнопка "Игнорировать")
|
||||||
|
- Карточка устройства со всеми полями
|
||||||
|
- Merge suggestions (по MAC → высокая, hostname → средняя, IP → низкая)
|
||||||
|
- Обновление Dashboard: реальные счётчики устройств
|
||||||
|
|
||||||
|
Файлы (новые/изменённые):
|
||||||
|
migrations/...CreateDevices.php
|
||||||
|
app/Controllers/DeviceController.php
|
||||||
|
app/Services/Inventory/DeviceService.php
|
||||||
|
app/Services/Inventory/MergeSuggestionService.php
|
||||||
|
app/Repositories/DeviceRepository.php
|
||||||
|
templates/devices/index.php
|
||||||
|
templates/devices/create.php
|
||||||
|
templates/devices/edit.php
|
||||||
|
templates/devices/show.php
|
||||||
|
templates/dashboard/index.php (обновить счётчики)
|
||||||
|
|
||||||
|
Проверка:
|
||||||
|
# Создать устройство вручную
|
||||||
|
# Создать устройство из discovered_host
|
||||||
|
# Открыть карточку, проверить все поля
|
||||||
|
# Проверить merge suggestions
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итерация 4. SSH-доступы
|
||||||
|
|
||||||
|
Цель: добавить SSH-доступ к устройству, проверить подключение.
|
||||||
|
|
||||||
|
Что делаем:
|
||||||
|
- Миграция credentials
|
||||||
|
- Credentials CRUD (только SSH для MVP)
|
||||||
|
- Шифрование секретов (defuse/php-encryption)
|
||||||
|
- Тест подключения через phpseclib
|
||||||
|
- Блок доступов на карточке устройства
|
||||||
|
- CredentialVault для шифрования/дешифрования
|
||||||
|
- Сохранение результата теста (last_test_status, last_test_at)
|
||||||
|
|
||||||
|
Файлы (новые/изменённые):
|
||||||
|
migrations/...CreateCredentials.php
|
||||||
|
app/Controllers/CredentialController.php
|
||||||
|
app/Services/Security/CredentialVault.php
|
||||||
|
app/Services/HostScan/SshClientFactory.php
|
||||||
|
app/Repositories/CredentialRepository.php
|
||||||
|
templates/credentials/_form.php
|
||||||
|
templates/credentials/_test_result.php
|
||||||
|
templates/devices/show.php (добавить блок доступов)
|
||||||
|
.env.example (добавить ENCRYPTION_KEY)
|
||||||
|
|
||||||
|
Проверка:
|
||||||
|
# Добавить SSH-доступ к устройству
|
||||||
|
# Нажать "Тест" — увидеть результат подключения
|
||||||
|
# Секрет не отображается в UI открытым текстом
|
||||||
|
# В базе хранится зашифрованное значение
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итерация 5. Deep scan Linux-хоста
|
||||||
|
|
||||||
|
Цель: по SSH собрать read-only информацию с Linux-хоста.
|
||||||
|
|
||||||
|
Что делаем:
|
||||||
|
- Миграции: host_scans
|
||||||
|
- LinuxHostScanner: hostname, os, ip, ports, disk, systemd, cron
|
||||||
|
- CommandWhitelist с массивами аргументов
|
||||||
|
- SshClientFactory с таймаутами (connect=5s, auth=10s, cmd=8s)
|
||||||
|
- Сохранение raw JSON в storage/scans/
|
||||||
|
- Summary в host_scans.summary_json
|
||||||
|
- Кнопка "Глубокий скан" на карточке устройства
|
||||||
|
- Страница результата скана
|
||||||
|
- Worker поддерживает тип host_deep_scan
|
||||||
|
|
||||||
|
Файлы (новые/изменённые):
|
||||||
|
migrations/...CreateHostScans.php
|
||||||
|
app/Controllers/HostScanController.php
|
||||||
|
app/Services/HostScan/LinuxHostScanner.php
|
||||||
|
app/Services/HostScan/CommandWhitelist.php
|
||||||
|
app/Repositories/HostScanRepository.php
|
||||||
|
templates/host_scans/show.php
|
||||||
|
templates/devices/show.php (кнопка deep scan)
|
||||||
|
bin/run-scan-worker.php (поддержка host_deep_scan)
|
||||||
|
config/scan_commands.php (CommandWhitelist mapping)
|
||||||
|
.env.example (добавить SSH_* таймауты)
|
||||||
|
|
||||||
|
Проверка:
|
||||||
|
# Добавить SSH-доступ к устройству
|
||||||
|
# Нажать "Глубокий скан"
|
||||||
|
# В другом терминале:
|
||||||
|
docker compose exec app php bin/run-scan-worker.php
|
||||||
|
# Увидеть результат: hostname, os, порты, диски, systemd, cron
|
||||||
|
# raw JSON сохранён в storage/scans/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итерация 6. Docker scan + detected_services
|
||||||
|
|
||||||
|
Цель: найти контейнеры и создать предложения сервисов.
|
||||||
|
|
||||||
|
Что делаем:
|
||||||
|
- Миграция detected_services
|
||||||
|
- DockerScanner: docker ps, inspect, volumes, networks
|
||||||
|
- SecretMasker для env-переменных (PASSWORD, TOKEN, KEY...)
|
||||||
|
- Создание detected_services kind=docker_container
|
||||||
|
- Страница "Найденные сервисы" (detected services)
|
||||||
|
- Действия: Создать сервис, Объединить, Игнорировать, Детали
|
||||||
|
- Host scan обогащается docker-сканом
|
||||||
|
|
||||||
|
Файлы (новые/изменённые):
|
||||||
|
migrations/...CreateDetectedServices.php
|
||||||
|
app/Controllers/DetectedServiceController.php
|
||||||
|
app/Services/HostScan/DockerScanner.php
|
||||||
|
app/Services/Security/SecretMasker.php
|
||||||
|
app/Repositories/DetectedServiceRepository.php
|
||||||
|
templates/services/detected.php
|
||||||
|
templates/services/_detected_row.php
|
||||||
|
app/Services/HostScan/LinuxHostScanner.php (добавить docker)
|
||||||
|
|
||||||
|
Проверка:
|
||||||
|
# Запустить deep scan на хосте с Docker
|
||||||
|
# Увидеть detected_services с kind=docker_container
|
||||||
|
# Секретные env-переменные замаскированы
|
||||||
|
# Страница найденных сервисов показывает таблицу
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итерация 7. Создание сервисов + связи
|
||||||
|
|
||||||
|
Цель: превратить найденный сервис в карточку с отношениями.
|
||||||
|
|
||||||
|
Что делаем:
|
||||||
|
- Миграции: services, service_endpoints, relations
|
||||||
|
- Service CRUD
|
||||||
|
- Создание service из detected_service (с выбором типа)
|
||||||
|
- Автоматическое создание service_endpoints (port, url)
|
||||||
|
- Создание связей (Device runs_on Service, Service exposes Endpoint)
|
||||||
|
- Карточка сервиса
|
||||||
|
- Обновление detected_service.status = accepted
|
||||||
|
|
||||||
|
Файлы (новые/изменённые):
|
||||||
|
migrations/...CreateServices.php
|
||||||
|
migrations/...CreateServiceEndpoints.php
|
||||||
|
migrations/...CreateRelations.php
|
||||||
|
app/Controllers/ServiceController.php
|
||||||
|
app/Services/Inventory/ServiceInventoryService.php
|
||||||
|
app/Services/Inventory/RelationService.php
|
||||||
|
app/Repositories/ServiceRepository.php
|
||||||
|
app/Repositories/ServiceEndpointRepository.php
|
||||||
|
app/Repositories/RelationRepository.php
|
||||||
|
templates/services/index.php
|
||||||
|
templates/services/create.php
|
||||||
|
templates/services/show.php
|
||||||
|
templates/services/detected.php (кнопка "Создать сервис")
|
||||||
|
|
||||||
|
Проверка:
|
||||||
|
# Нажать "Создать сервис" на detected_service
|
||||||
|
# Выбрать тип (web_app, database...)
|
||||||
|
# Сервис создан, endpoint-ы созданы, связи созданы
|
||||||
|
# detected_service.status = accepted
|
||||||
|
# Карточка сервиса показывает связи
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итерация 8. Nginx/Cron/Backup сканеры
|
||||||
|
|
||||||
|
Цель: находить nginx vhost-ы, cron-задачи, backup-подсказки.
|
||||||
|
|
||||||
|
Что делаем:
|
||||||
|
- NginxScanner: парсинг server_name, listen, proxy_pass, root
|
||||||
|
- CronScanner: crontab -l, /etc/crontab, /etc/cron.d/*
|
||||||
|
- BackupHintScanner: поиск rsync, borg, restic, tar, mysqldump...
|
||||||
|
- detected_services kind=nginx_vhost, cron_job, backup_hint
|
||||||
|
- Миграция domains
|
||||||
|
- Создание domain suggestions из nginx server_name
|
||||||
|
- Host scan обогащается nginx/cron/backup сканами
|
||||||
|
|
||||||
|
Файлы (новые/изменённые):
|
||||||
|
migrations/...CreateDomains.php
|
||||||
|
app/Services/HostScan/NginxScanner.php
|
||||||
|
app/Services/HostScan/CronScanner.php
|
||||||
|
app/Services/HostScan/BackupHintScanner.php
|
||||||
|
app/Repositories/DomainRepository.php
|
||||||
|
templates/host_scans/show.php (добавить nginx/cron/backup)
|
||||||
|
templates/devices/show.php (найденные сервисы)
|
||||||
|
|
||||||
|
Проверка:
|
||||||
|
# Deep scan на хосте с nginx + cron + backup scripts
|
||||||
|
# Увидеть detected_services: nginx_vhost, cron_job, backup_hint
|
||||||
|
# Формулировка backup_hint: "Похоже на backup job"
|
||||||
|
# Domains созданы из nginx server_name
|
||||||
|
# Приватные ключи сертификатов НЕ прочитаны
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итерация 9. Риски + Dashboard + История
|
||||||
|
|
||||||
|
Цель: предупреждения на dashboard, история сканов, diff.
|
||||||
|
|
||||||
|
Что делаем:
|
||||||
|
- RiskAnalyzer: сервис без backup, публичный endpoint, SSH открыт,
|
||||||
|
нет свежего scan, TLS истекает
|
||||||
|
- Dashboard: реальные предупреждения
|
||||||
|
- История scan jobs
|
||||||
|
- DiffAnalyzer: новые/пропавшие хосты, порты, контейнеры между сканами
|
||||||
|
- DiffAnalyzer: новые/пропавшие хосты, порты, контейнеры между сканами
|
||||||
|
- Documents CRUD (заметки/runbooks)
|
||||||
|
|
||||||
|
Файлы (новые/изменённые):
|
||||||
|
migrations/...CreateDocuments.php
|
||||||
|
app/Controllers/DocumentController.php
|
||||||
|
app/Controllers/ScanHistoryController.php
|
||||||
|
app/Controllers/DiffController.php
|
||||||
|
app/Services/Analysis/RiskAnalyzer.php
|
||||||
|
app/Services/Analysis/DiffAnalyzer.php
|
||||||
|
app/Repositories/DocumentRepository.php
|
||||||
|
templates/dashboard/index.php (real warnings)
|
||||||
|
templates/scans/history.php
|
||||||
|
templates/scans/diff.php
|
||||||
|
templates/documents/index.php
|
||||||
|
templates/documents/create.php
|
||||||
|
|
||||||
|
Проверка:
|
||||||
|
# Dashboard показывает предупреждения ( service без backup и т.д. )
|
||||||
|
# История scan jobs с статусами
|
||||||
|
# Diff между двумя сканами: новые/пропавшие хосты
|
||||||
|
# Документы: создать runbook, привязать к устройству
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Итерация 10. Полировка + документация
|
||||||
|
|
||||||
|
Цель: финальная доводка проекта.
|
||||||
|
|
||||||
|
Что делаем:
|
||||||
|
- Полный audit log для всех действий
|
||||||
|
- Документация в README полностью
|
||||||
|
- Скриншоты в README (опционально)
|
||||||
|
- Проверка всех security-правил
|
||||||
|
- scripts/check.sh обновлён под все итерации
|
||||||
|
- Финальный smoke test: весь цикл scan → device → service
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Сводная таблица
|
||||||
|
|
||||||
|
Итерация | Что делаем | Зависимости
|
||||||
|
1 | Каркас: Slim, Docker, auth, layout | —
|
||||||
|
2 | Network scan | 1
|
||||||
|
3 | Devices CRUD | 1
|
||||||
|
4 | SSH credentials | 1, 3
|
||||||
|
5 | Deep scan Linux | 1, 3, 4
|
||||||
|
6 | Docker scan + detected_services | 1, 5
|
||||||
|
7 | Services + relations | 1, 6
|
||||||
|
8 | Nginx/Cron/Backup scanners | 1, 5, 7
|
||||||
|
9 | Risks + Dashboard + History + Docs | 1-8
|
||||||
|
10 | Полировка + README | 1-9
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Правила для каждой итерации
|
||||||
|
|
||||||
|
1. Не переходить к следующей, пока check.sh не прошёл.
|
||||||
|
2. Показывать реальный вывод smoke test, а не "готово".
|
||||||
|
3. Маленькие атомарные коммиты.
|
||||||
|
4. Контроллеры — только роутинг, бизнес-логика в Services.
|
||||||
|
5. SQL только в Repository-классах.
|
||||||
|
6. Shell-команды только через CommandWhitelist (массивы аргументов).
|
||||||
|
7. Никакого произвольного shell executor из UI.
|
||||||
|
8. Никакого "чистый JS потом допишем" — весь JS заявлен заранее.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Порядок выдачи задания owl-alpha
|
||||||
|
|
||||||
|
Рекомендуется выдавать по одной итерации:
|
||||||
|
|
||||||
|
Шаг 1: "Прочитай ТЗ и PLAN.md. Сделай Итерацию 1."
|
||||||
|
Шаг 2: "Прочитай текущий код. Сделай Итерацию 2 по PLAN.md."
|
||||||
|
...
|
||||||
|
|
||||||
|
После каждой итерации — ревью, smoke test, фикс замечаний,
|
||||||
|
и только потом следующая итерация.
|
||||||
|
|
||||||
|
Никогда не давай больше одной итерации за раз.
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
# Домовой
|
||||||
|
|
||||||
|
Self-hosted система инвентаризации домашней и малой серверной инфраструктуры.
|
||||||
|
|
||||||
|
## Что делает
|
||||||
|
|
||||||
|
- Сканирует заданные локальные сетевые диапазоны
|
||||||
|
- Находит устройства и предлагает создать карточки
|
||||||
|
- После добавления SSH-доступа — read-only deep scan хоста
|
||||||
|
- Находит сервисы, Docker-контейнеры, nginx vhost-ы, cron-задачи, backup-подсказки
|
||||||
|
- Создаёт карточки сервисов и связи между устройствами и сервисами
|
||||||
|
|
||||||
|
## Стек
|
||||||
|
|
||||||
|
- PHP 8.3, Slim Framework 4, PDO
|
||||||
|
- MariaDB
|
||||||
|
- Bootstrap 5.3, htmx
|
||||||
|
- Docker Compose
|
||||||
|
- Phinx (миграции)
|
||||||
|
|
||||||
|
## Предупреждение
|
||||||
|
|
||||||
|
Сканируйте только свои сети. Приложение read-only и ничего не меняет на удалённых хостах.
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repo>
|
||||||
|
cd domovoy
|
||||||
|
cp .env.example .env
|
||||||
|
# Отредактируйте .env: задайте APP_SECRET, ENCRYPTION_KEY, DB_PASSWORD
|
||||||
|
docker compose up -d --build
|
||||||
|
docker compose exec app php vendor/bin/phinx migrate
|
||||||
|
docker compose exec app php bin/setup first-admin
|
||||||
|
# Откройте http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
## Запуск
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d
|
||||||
|
# http://localhost:8080
|
||||||
|
```
|
||||||
|
|
||||||
|
## Миграции
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose exec app php vendor/bin/phinx migrate
|
||||||
|
docker compose exec app php vendor/bin/phinx rollback
|
||||||
|
docker compose exec app php vendor/bin/phinx create MigrationName
|
||||||
|
```
|
||||||
|
|
||||||
|
## Smoke test
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./scripts/check.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
## Структура
|
||||||
|
|
||||||
|
```
|
||||||
|
domovoy/
|
||||||
|
app/ # Controllers, Services, Repositories, Middleware
|
||||||
|
db/migrations/ # Phinx миграции
|
||||||
|
public/ # Точка входа, assets
|
||||||
|
templates/ # PHP шаблоны
|
||||||
|
bin/ # CLI команды
|
||||||
|
docker/ # Dockerfile, nginx config
|
||||||
|
storage/ # логи, scan results
|
||||||
|
```
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
MIT
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Domovoy\Controllers;
|
||||||
|
|
||||||
|
use Domovoy\Services\AuthService;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
|
||||||
|
class AuthController
|
||||||
|
{
|
||||||
|
private AuthService $authService;
|
||||||
|
|
||||||
|
public function __construct(AuthService $authService)
|
||||||
|
{
|
||||||
|
$this->authService = $authService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function loginForm(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||||
|
{
|
||||||
|
if (isset($_SESSION['user_id'])) {
|
||||||
|
return $response
|
||||||
|
->withHeader('Location', '/dashboard')
|
||||||
|
->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
require dirname(__DIR__, 2) . '/templates/auth/login.php';
|
||||||
|
$body = ob_get_clean();
|
||||||
|
$response->getBody()->write($body);
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function login(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||||
|
{
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
$username = $data['username'] ?? '';
|
||||||
|
$password = $data['password'] ?? '';
|
||||||
|
|
||||||
|
$user = $this->authService->authenticate($username, $password);
|
||||||
|
|
||||||
|
if ($user === null) {
|
||||||
|
ob_start();
|
||||||
|
$error = 'Invalid username or password';
|
||||||
|
require dirname(__DIR__, 2) . '/templates/auth/login.php';
|
||||||
|
$body = ob_get_clean();
|
||||||
|
$response->getBody()->write($body);
|
||||||
|
return $response->withStatus(401);
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SESSION['user_id'] = $user->id;
|
||||||
|
$_SESSION['username'] = $user->username;
|
||||||
|
|
||||||
|
return $response
|
||||||
|
->withHeader('Location', '/dashboard')
|
||||||
|
->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function logout(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||||
|
{
|
||||||
|
session_destroy();
|
||||||
|
return $response
|
||||||
|
->withHeader('Location', '/login')
|
||||||
|
->withStatus(302);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Domovoy\Controllers;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
|
||||||
|
class DashboardController
|
||||||
|
{
|
||||||
|
public function index(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||||
|
{
|
||||||
|
ob_start();
|
||||||
|
$username = $_SESSION['username'] ?? 'User';
|
||||||
|
require dirname(__DIR__, 2) . '/templates/dashboard/index.php';
|
||||||
|
$body = ob_get_clean();
|
||||||
|
$response->getBody()->write($body);
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Domovoy\Controllers;
|
||||||
|
|
||||||
|
use Domovoy\Services\AuthService;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
|
||||||
|
class SetupController
|
||||||
|
{
|
||||||
|
private AuthService $authService;
|
||||||
|
|
||||||
|
public function __construct(AuthService $authService)
|
||||||
|
{
|
||||||
|
$this->authService = $authService;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function form(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||||
|
{
|
||||||
|
if ($this->authService->getUserCount() > 0) {
|
||||||
|
return $response
|
||||||
|
->withHeader('Location', '/login')
|
||||||
|
->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
ob_start();
|
||||||
|
$error = null;
|
||||||
|
require dirname(__DIR__, 2) . '/templates/auth/setup.php';
|
||||||
|
$body = ob_get_clean();
|
||||||
|
$response->getBody()->write($body);
|
||||||
|
return $response;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function create(ServerRequestInterface $request, ResponseInterface $response): ResponseInterface
|
||||||
|
{
|
||||||
|
if ($this->authService->getUserCount() > 0) {
|
||||||
|
return $response
|
||||||
|
->withHeader('Location', '/login')
|
||||||
|
->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
$data = $request->getParsedBody();
|
||||||
|
$username = trim($data['username'] ?? '');
|
||||||
|
$password = $data['password'] ?? '';
|
||||||
|
|
||||||
|
if (strlen($username) < 2 || strlen($password) < 8) {
|
||||||
|
ob_start();
|
||||||
|
$error = 'Username must be at least 2 chars, password at least 8';
|
||||||
|
require dirname(__DIR__, 2) . '/templates/auth/setup.php';
|
||||||
|
$body = ob_get_clean();
|
||||||
|
$response->getBody()->write($body);
|
||||||
|
return $response->withStatus(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$user = $this->authService->createUser($username, $password);
|
||||||
|
} catch (\RuntimeException $e) {
|
||||||
|
ob_start();
|
||||||
|
$error = $e->getMessage();
|
||||||
|
require dirname(__DIR__, 2) . '/templates/auth/setup.php';
|
||||||
|
$body = ob_get_clean();
|
||||||
|
$response->getBody()->write($body);
|
||||||
|
return $response->withStatus(400);
|
||||||
|
}
|
||||||
|
|
||||||
|
$_SESSION['user_id'] = $user->id;
|
||||||
|
$_SESSION['username'] = $user->username;
|
||||||
|
|
||||||
|
return $response
|
||||||
|
->withHeader('Location', '/dashboard')
|
||||||
|
->withStatus(302);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,39 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Domovoy\Middleware;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Slim\Routing\RouteContext;
|
||||||
|
|
||||||
|
class AuthMiddleware
|
||||||
|
{
|
||||||
|
public function __invoke(
|
||||||
|
ServerRequestInterface $request,
|
||||||
|
\Psr\Http\Server\RequestHandlerInterface $handler
|
||||||
|
): ResponseInterface {
|
||||||
|
$path = $request->getUri()->getPath();
|
||||||
|
|
||||||
|
$publicPaths = ['/login', '/setup'];
|
||||||
|
$isPublic = in_array($path, $publicPaths, true);
|
||||||
|
|
||||||
|
if (!isset($_SESSION['user_id']) && !$isPublic) {
|
||||||
|
$response = new \Slim\Psr7\Response();
|
||||||
|
return $response
|
||||||
|
->withHeader('Location', '/login')
|
||||||
|
->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If logged in and trying to access login/setup, redirect to dashboard
|
||||||
|
if (isset($_SESSION['user_id']) && $isPublic) {
|
||||||
|
$response = new \Slim\Psr7\Response();
|
||||||
|
return $response
|
||||||
|
->withHeader('Location', '/dashboard')
|
||||||
|
->withStatus(302);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $handler->handle($request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Domovoy\Models;
|
||||||
|
|
||||||
|
class User
|
||||||
|
{
|
||||||
|
public ?string $id = null;
|
||||||
|
public string $username = '';
|
||||||
|
public string $passwordHash = '';
|
||||||
|
public ?\DateTimeImmutable $createdAt = null;
|
||||||
|
public ?\DateTimeImmutable $updatedAt = null;
|
||||||
|
|
||||||
|
public static function fromArray(array $data): self
|
||||||
|
{
|
||||||
|
$user = new self();
|
||||||
|
$user->id = $data['id'];
|
||||||
|
$user->username = $data['username'];
|
||||||
|
$user->passwordHash = $data['password_hash'];
|
||||||
|
$user->createdAt = new \DateTimeImmutable($data['created_at']);
|
||||||
|
$user->updatedAt = new \DateTimeImmutable($data['updated_at']);
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,75 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Domovoy\Repositories;
|
||||||
|
|
||||||
|
use PDO;
|
||||||
|
use Domovoy\Models\User;
|
||||||
|
|
||||||
|
class UserRepository
|
||||||
|
{
|
||||||
|
private PDO $pdo;
|
||||||
|
|
||||||
|
public function __construct(PDO $pdo)
|
||||||
|
{
|
||||||
|
$this->pdo = $pdo;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findById(string $id): ?User
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare('SELECT * FROM users WHERE id = :id');
|
||||||
|
$stmt->execute(['id' => $id]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
return $row ? User::fromArray($row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findByUsername(string $username): ?User
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->prepare('SELECT * FROM users WHERE username = :username');
|
||||||
|
$stmt->execute(['username' => $username]);
|
||||||
|
$row = $stmt->fetch();
|
||||||
|
return $row ? User::fromArray($row) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function findAll(): array
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->query('SELECT * FROM users ORDER BY created_at DESC');
|
||||||
|
$users = [];
|
||||||
|
while ($row = $stmt->fetch()) {
|
||||||
|
$users[] = User::fromArray($row);
|
||||||
|
}
|
||||||
|
return $users;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function count(): int
|
||||||
|
{
|
||||||
|
$stmt = $this->pdo->query('SELECT COUNT(*) FROM users');
|
||||||
|
return (int) $stmt->fetchColumn();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function save(User $user): void
|
||||||
|
{
|
||||||
|
if ($user->id === null) {
|
||||||
|
$user->id = \Ramsey\Uuid\Uuid::uuid4()->toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
$now = (new \DateTimeImmutable())->format('Y-m-d H:i:s');
|
||||||
|
$user->updatedAt = new \DateTimeImmutable($now);
|
||||||
|
if ($user->createdAt === null) {
|
||||||
|
$user->createdAt = new \DateTimeImmutable($now);
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $this->pdo->prepare(
|
||||||
|
'INSERT INTO users (id, username, password_hash, created_at, updated_at)
|
||||||
|
VALUES (:id, :username, :password_hash, :created_at, :updated_at)'
|
||||||
|
);
|
||||||
|
$stmt->execute([
|
||||||
|
'id' => $user->id,
|
||||||
|
'username' => $user->username,
|
||||||
|
'password_hash' => $user->passwordHash,
|
||||||
|
'created_at' => $user->createdAt->format('Y-m-d H:i:s'),
|
||||||
|
'updated_at' => $user->updatedAt->format('Y-m-d H:i:s'),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Domovoy\Services;
|
||||||
|
|
||||||
|
use Domovoy\Models\User;
|
||||||
|
use Domovoy\Repositories\UserRepository;
|
||||||
|
|
||||||
|
class AuthService
|
||||||
|
{
|
||||||
|
private UserRepository $userRepository;
|
||||||
|
|
||||||
|
public function __construct(UserRepository $userRepository)
|
||||||
|
{
|
||||||
|
$this->userRepository = $userRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function authenticate(string $username, string $password): ?User
|
||||||
|
{
|
||||||
|
$user = $this->userRepository->findByUsername($username);
|
||||||
|
if ($user === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!password_verify($password, $user->passwordHash)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createUser(string $username, string $password): User
|
||||||
|
{
|
||||||
|
if ($this->userRepository->findByUsername($username) !== null) {
|
||||||
|
throw new \RuntimeException("User '{$username}' already exists");
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = new User();
|
||||||
|
$user->username = $username;
|
||||||
|
$user->passwordHash = password_hash($password, PASSWORD_ARGON2ID);
|
||||||
|
$user->createdAt = new \DateTimeImmutable();
|
||||||
|
$user->updatedAt = new \DateTimeImmutable();
|
||||||
|
|
||||||
|
$this->userRepository->save($user);
|
||||||
|
return $user;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUserCount(): int
|
||||||
|
{
|
||||||
|
return $this->userRepository->count();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
use Domovoy\Services\AuthService;
|
||||||
|
use Domovoy\Repositories\UserRepository;
|
||||||
|
|
||||||
|
$dotenv = \Dotenv\Dotenv::createImmutable(dirname(__DIR__));
|
||||||
|
$dotenv->safeLoad();
|
||||||
|
|
||||||
|
$pdo = new PDO(
|
||||||
|
sprintf(
|
||||||
|
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
|
||||||
|
getenv('DB_HOST') ?: 'db',
|
||||||
|
getenv('DB_PORT') ?: 3306,
|
||||||
|
getenv('DB_DATABASE') ?: 'domovoy'
|
||||||
|
),
|
||||||
|
getenv('DB_USERNAME') ?: 'domovoy',
|
||||||
|
getenv('DB_PASSWORD') ?: 'domovoy',
|
||||||
|
[
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
|
$authService = new AuthService(new UserRepository($pdo));
|
||||||
|
|
||||||
|
$command = $argv[1] ?? 'help';
|
||||||
|
|
||||||
|
switch ($command) {
|
||||||
|
case 'setup:user':
|
||||||
|
if ($argv[2] ?? false) {
|
||||||
|
// CLI mode: bin/console setup:user username password
|
||||||
|
$username = $argv[2];
|
||||||
|
$password = $argv[3] ?? 'password123';
|
||||||
|
} else {
|
||||||
|
// Interactive mode
|
||||||
|
echo "Username: ";
|
||||||
|
$username = trim(fgets(STDIN));
|
||||||
|
echo "Password: ";
|
||||||
|
$password = trim(fgets(STDIN));
|
||||||
|
}
|
||||||
|
|
||||||
|
$user = $authService->createUser($username, $password);
|
||||||
|
echo "User '{$user->username}' created successfully.\n";
|
||||||
|
break;
|
||||||
|
|
||||||
|
case 'help':
|
||||||
|
default:
|
||||||
|
echo "Domovoy CLI\n\n";
|
||||||
|
echo "Commands:\n";
|
||||||
|
echo " setup:user [username] [password] Create first admin user\n";
|
||||||
|
echo " help Show this help\n";
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
||||||
|
{
|
||||||
|
"name": "domovoy/domovoy",
|
||||||
|
"description": "Self-hosted inventory system for home/small infrastructure",
|
||||||
|
"type": "project",
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.3",
|
||||||
|
"slim/slim": "^4.14",
|
||||||
|
"slim/psr7": "^1.7",
|
||||||
|
"php-di/php-di": "^7.0",
|
||||||
|
"monolog/monolog": "^3.0",
|
||||||
|
"vlucas/phpdotenv": "^5.6",
|
||||||
|
"ramsey/uuid": "^4.7",
|
||||||
|
"symfony/process": "^7.0",
|
||||||
|
"phpseclib/phpseclib": "~3.0",
|
||||||
|
"defuse/php-encryption": "^2.4",
|
||||||
|
"robmorgan/phinx": "^0.15"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"phpunit/phpunit": "^11.0"
|
||||||
|
},
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"Domovoy\\": "app/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"autoload-dev": {
|
||||||
|
"psr-4": {
|
||||||
|
"Domovoy\\Tests\\": "tests/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"migrate": "php vendor/bin/phinx migrate",
|
||||||
|
"migrate:rollback": "php vendor/bin/phinx rollback",
|
||||||
|
"migrate:create": "php vendor/bin/phinx create"
|
||||||
|
},
|
||||||
|
"config": {
|
||||||
|
"sort-packages": true,
|
||||||
|
"platform": {
|
||||||
|
"php": "8.3.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,21 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phinx\Migration\AbstractMigration;
|
||||||
|
|
||||||
|
final class CreateUsers extends AbstractMigration
|
||||||
|
{
|
||||||
|
public function change(): void
|
||||||
|
{
|
||||||
|
$table = $this->table('users', ['id' => false, 'primary_key' => ['id']]);
|
||||||
|
$table
|
||||||
|
->addColumn('id', 'uuid', ['null' => false])
|
||||||
|
->addColumn('username', 'string', ['limit' => 255, 'null' => false])
|
||||||
|
->addColumn('password_hash', 'string', ['limit' => 255, 'null' => false])
|
||||||
|
->addColumn('created_at', 'datetime', ['null' => false, 'default' => 'CURRENT_TIMESTAMP'])
|
||||||
|
->addColumn('updated_at', 'datetime', ['null' => false, 'default' => 'CURRENT_TIMESTAMP'])
|
||||||
|
->addIndex(['username'], ['unique' => true])
|
||||||
|
->create();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,49 @@
|
||||||
|
services:
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: docker/app/Dockerfile
|
||||||
|
container_name: domovoy-app
|
||||||
|
volumes:
|
||||||
|
- .:/var/www/html
|
||||||
|
- ./storage:/var/www/html/storage
|
||||||
|
expose:
|
||||||
|
- "9000"
|
||||||
|
depends_on:
|
||||||
|
- db
|
||||||
|
environment:
|
||||||
|
- APP_ENV=${APP_ENV:-development}
|
||||||
|
- APP_SECRET=${APP_SECRET}
|
||||||
|
- ENCRYPTION_KEY=${ENCRYPTION_KEY}
|
||||||
|
- DB_HOST=${DB_HOST:-db}
|
||||||
|
- DB_PORT=${DB_PORT:-3306}
|
||||||
|
- DB_DATABASE=${DB_DATABASE:-domovoy}
|
||||||
|
- DB_USERNAME=${DB_USERNAME:-domovoy}
|
||||||
|
- DB_PASSWORD=${DB_PASSWORD:-domovoy}
|
||||||
|
|
||||||
|
web:
|
||||||
|
image: nginx:alpine
|
||||||
|
container_name: domovoy-web
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
volumes:
|
||||||
|
- .:/var/www/html
|
||||||
|
- ./docker/web/default.conf:/etc/nginx/conf.d/default.conf
|
||||||
|
depends_on:
|
||||||
|
- app
|
||||||
|
|
||||||
|
db:
|
||||||
|
image: mariadb:11
|
||||||
|
container_name: domovoy-db
|
||||||
|
ports:
|
||||||
|
- "3307:3306"
|
||||||
|
volumes:
|
||||||
|
- db_data:/var/lib/mysql
|
||||||
|
environment:
|
||||||
|
- MYSQL_ROOT_PASSWORD=${MYSQL_ROOT_PASSWORD:-rootpass}
|
||||||
|
- MYSQL_DATABASE=${DB_DATABASE:-domovoy}
|
||||||
|
- MYSQL_USER=${DB_USERNAME:-domovoy}
|
||||||
|
- MYSQL_PASSWORD=${DB_PASSWORD:-domovoy}
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
db_data:
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
FROM php:8.3-fpm
|
||||||
|
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
mariadb-client \
|
||||||
|
iproute2 \
|
||||||
|
iputils-ping \
|
||||||
|
net-tools \
|
||||||
|
dnsutils \
|
||||||
|
openssh-client \
|
||||||
|
openssl \
|
||||||
|
unzip \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
RUN docker-php-ext-install pdo pdo_mysql
|
||||||
|
|
||||||
|
COPY --from=mlocati/php-extension-installer /usr/bin/install-php-extensions /usr/local/bin/
|
||||||
|
RUN install-php-extensions zip mbstring xml curl
|
||||||
|
|
||||||
|
WORKDIR /var/www/html
|
||||||
|
|
||||||
|
# Copy vendor from host (pre-installed with correct PHP version)
|
||||||
|
COPY vendor/ vendor/
|
||||||
|
COPY composer.json composer.lock ./
|
||||||
|
|
||||||
|
RUN mkdir -p storage/logs storage/scans storage/reports \
|
||||||
|
&& chown -R www-data:www-data storage
|
||||||
|
|
||||||
|
EXPOSE 9000
|
||||||
|
CMD ["php-fpm"]
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name localhost;
|
||||||
|
root /var/www/html/public;
|
||||||
|
index index.php;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri /index.php?$query_string;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ \.php$ {
|
||||||
|
fastcgi_pass app:9000;
|
||||||
|
fastcgi_index index.php;
|
||||||
|
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||||
|
include fastcgi_params;
|
||||||
|
}
|
||||||
|
|
||||||
|
location ~ /\. {
|
||||||
|
deny all;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
return [
|
||||||
|
'paths' => [
|
||||||
|
'migrations' => __DIR__ . '/db/migrations',
|
||||||
|
'seeds' => __DIR__ . '/db/seeds',
|
||||||
|
],
|
||||||
|
'environments' => [
|
||||||
|
'default_migration_table' => 'phinxlog',
|
||||||
|
'default_environment' => 'development',
|
||||||
|
'production' => [
|
||||||
|
'adapter' => 'mysql',
|
||||||
|
'host' => getenv('DB_HOST') ?: 'db',
|
||||||
|
'port' => getenv('DB_PORT') ?: 3306,
|
||||||
|
'name' => getenv('DB_DATABASE') ?: 'domovoy',
|
||||||
|
'user' => getenv('DB_USERNAME') ?: 'domovoy',
|
||||||
|
'pass' => getenv('DB_PASSWORD') ?: 'domovoy',
|
||||||
|
'charset' => 'utf8mb4',
|
||||||
|
],
|
||||||
|
'development' => [
|
||||||
|
'adapter' => 'mysql',
|
||||||
|
'host' => getenv('DB_HOST') ?: 'db',
|
||||||
|
'port' => getenv('DB_PORT') ?: 3306,
|
||||||
|
'name' => getenv('DB_DATABASE') ?: 'domovoy',
|
||||||
|
'user' => getenv('DB_USERNAME') ?: 'domovoy',
|
||||||
|
'pass' => getenv('DB_PASSWORD') ?: 'domovoy',
|
||||||
|
'charset' => 'utf8mb4',
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'version_order' => 'creation',
|
||||||
|
];
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
/* Domovoy custom styles */
|
||||||
|
body {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar-wrapper .list-group-item:hover {
|
||||||
|
background-color: #343a40 !important;
|
||||||
|
color: #fff !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
#sidebar-wrapper .list-group-item.active {
|
||||||
|
background-color: #0d6efd !important;
|
||||||
|
border-color: #0d6efd !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
box-shadow: 0 1px 3px rgba(0,0,0,0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
session_start();
|
||||||
|
|
||||||
|
require dirname(__DIR__) . '/vendor/autoload.php';
|
||||||
|
|
||||||
|
use DI\ContainerBuilder;
|
||||||
|
use Slim\Factory\AppFactory;
|
||||||
|
|
||||||
|
$dotenv = \Dotenv\Dotenv::createImmutable(dirname(__DIR__));
|
||||||
|
$dotenv->safeLoad();
|
||||||
|
|
||||||
|
// Build container
|
||||||
|
$containerBuilder = new ContainerBuilder();
|
||||||
|
$containerBuilder->addDefinitions([
|
||||||
|
'settings' => [
|
||||||
|
'db' => [
|
||||||
|
'host' => getenv('DB_HOST') ?: 'db',
|
||||||
|
'port' => (int)(getenv('DB_PORT') ?: 3306),
|
||||||
|
'database' => getenv('DB_DATABASE') ?: 'domovoy',
|
||||||
|
'username' => getenv('DB_USERNAME') ?: 'domovoy',
|
||||||
|
'password' => getenv('DB_PASSWORD') ?: 'domovoy',
|
||||||
|
],
|
||||||
|
'app' => [
|
||||||
|
'env' => getenv('APP_ENV') ?: 'development',
|
||||||
|
'secret' => getenv('APP_SECRET') ?: 'change-me',
|
||||||
|
'encryption_key' => getenv('ENCRYPTION_KEY') ?: '',
|
||||||
|
],
|
||||||
|
'ssh' => [
|
||||||
|
'connect_timeout' => (int)(getenv('SSH_CONNECT_TIMEOUT_SECONDS') ?: 5),
|
||||||
|
'auth_timeout' => (int)(getenv('SSH_AUTH_TIMEOUT_SECONDS') ?: 10),
|
||||||
|
'command_timeout' => (int)(getenv('SSH_COMMAND_TIMEOUT_SECONDS') ?: 8),
|
||||||
|
'total_scan_timeout' => (int)(getenv('SSH_TOTAL_SCAN_TIMEOUT_SECONDS') ?: 60),
|
||||||
|
'retry_count' => (int)(getenv('SSH_RETRY_COUNT') ?: 0),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'logger' => function () {
|
||||||
|
$logger = new \Monolog\Logger('domovoy');
|
||||||
|
$logger->pushHandler(new \Monolog\Handler\StreamHandler(
|
||||||
|
dirname(__DIR__) . '/storage/logs/app.log',
|
||||||
|
\Monolog\Level::Debug
|
||||||
|
));
|
||||||
|
return $logger;
|
||||||
|
},
|
||||||
|
PDO::class => function ($c) {
|
||||||
|
$db = $c->get('settings')['db'];
|
||||||
|
$dsn = sprintf(
|
||||||
|
'mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4',
|
||||||
|
$db['host'],
|
||||||
|
$db['port'],
|
||||||
|
$db['database']
|
||||||
|
);
|
||||||
|
return new PDO($dsn, $db['username'], $db['password'], [
|
||||||
|
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
|
||||||
|
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
|
||||||
|
PDO::ATTR_EMULATE_PREPARES => false,
|
||||||
|
]);
|
||||||
|
},
|
||||||
|
\Domovoy\Repositories\UserRepository::class => function ($c) {
|
||||||
|
return new \Domovoy\Repositories\UserRepository($c->get(PDO::class));
|
||||||
|
},
|
||||||
|
\Domovoy\Services\AuthService::class => function ($c) {
|
||||||
|
return new \Domovoy\Services\AuthService($c->get(\Domovoy\Repositories\UserRepository::class));
|
||||||
|
},
|
||||||
|
\Domovoy\Controllers\AuthController::class => function ($c) {
|
||||||
|
return new \Domovoy\Controllers\AuthController($c->get(\Domovoy\Services\AuthService::class));
|
||||||
|
},
|
||||||
|
\Domovoy\Controllers\SetupController::class => function ($c) {
|
||||||
|
return new \Domovoy\Controllers\SetupController($c->get(\Domovoy\Services\AuthService::class));
|
||||||
|
},
|
||||||
|
\Domovoy\Controllers\DashboardController::class => function () {
|
||||||
|
return new \Domovoy\Controllers\DashboardController();
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
$container = $containerBuilder->build();
|
||||||
|
|
||||||
|
// Build Slim app
|
||||||
|
AppFactory::setContainer($container);
|
||||||
|
$app = AppFactory::create();
|
||||||
|
|
||||||
|
$app->addRoutingMiddleware();
|
||||||
|
$app->addBodyParsingMiddleware();
|
||||||
|
$app->add(new \Domovoy\Middleware\AuthMiddleware());
|
||||||
|
$app->addErrorMiddleware(true, true, true);
|
||||||
|
|
||||||
|
// Routes
|
||||||
|
$app->get('/login', [\Domovoy\Controllers\AuthController::class, 'loginForm'])->setName('login');
|
||||||
|
$app->post('/login', [\Domovoy\Controllers\AuthController::class, 'login'])->setName('login.post');
|
||||||
|
$app->get('/setup', [\Domovoy\Controllers\SetupController::class, 'form'])->setName('setup');
|
||||||
|
$app->post('/setup', [\Domovoy\Controllers\SetupController::class, 'create'])->setName('setup.post');
|
||||||
|
|
||||||
|
$app->group('', function (\Slim\Routing\RouteCollectorProxy $group) {
|
||||||
|
$group->get('/dashboard', [\Domovoy\Controllers\DashboardController::class, 'index'])->setName('dashboard');
|
||||||
|
$group->post('/logout', [\Domovoy\Controllers\AuthController::class, 'logout'])->setName('logout');
|
||||||
|
});
|
||||||
|
|
||||||
|
$app->run();
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
echo "=== composer validate ==="
|
||||||
|
composer validate --no-check-publish
|
||||||
|
|
||||||
|
echo "=== composer install ==="
|
||||||
|
composer install --no-interaction --prefer-dist
|
||||||
|
|
||||||
|
echo "=== PHP syntax check ==="
|
||||||
|
find app public bin -name "*.php" -print0 | xargs -0 -n1 php -l
|
||||||
|
|
||||||
|
echo "=== docker compose config ==="
|
||||||
|
docker compose config > /dev/null
|
||||||
|
|
||||||
|
echo "=== docker compose up ==="
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
echo "=== wait for db ==="
|
||||||
|
sleep 5
|
||||||
|
|
||||||
|
echo "=== phinx migrate ==="
|
||||||
|
docker compose exec app php vendor/bin/phinx migrate
|
||||||
|
|
||||||
|
echo "=== HTTP health check ==="
|
||||||
|
curl -sI http://localhost:8080/login | head -1
|
||||||
|
|
||||||
|
echo "=== ALL CHECKS PASSED ==="
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php ob_start(); ?>
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card mt-5">
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="card-title text-center mb-4">Вход в Домовой</h4>
|
||||||
|
<?php if (isset($error)): ?>
|
||||||
|
<div class="alert alert-danger"><?= htmlspecialchars($error) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<form method="POST" action="/login">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="username" class="form-label">Имя пользователя</label>
|
||||||
|
<input type="text" class="form-control" id="username" name="username" required autofocus>
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Пароль</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">Войти</button>
|
||||||
|
</form>
|
||||||
|
<div class="text-center mt-3">
|
||||||
|
<small class="text-muted">
|
||||||
|
Нет аккаунта? <a href="/setup">Создать первого пользователя</a>
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php $content = ob_get_clean(); ?>
|
||||||
|
<?php require dirname(__DIR__) . '/layout.php'; ?>
|
||||||
|
|
@ -0,0 +1,30 @@
|
||||||
|
<?php ob_start(); ?>
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card mt-5">
|
||||||
|
<div class="card-body">
|
||||||
|
<h4 class="card-title text-center mb-4">Создание первого пользователя</h4>
|
||||||
|
<p class="text-muted text-center">Это будет администратор системы "Домовой"</p>
|
||||||
|
<?php if (isset($error)): ?>
|
||||||
|
<div class="alert alert-danger"><?= htmlspecialchars($error) ?></div>
|
||||||
|
<?php endif; ?>
|
||||||
|
<form method="POST" action="/setup">
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="username" class="form-label">Имя пользователя</label>
|
||||||
|
<input type="text" class="form-control" id="username" name="username" required autofocus
|
||||||
|
minlength="2" maxlength="255">
|
||||||
|
</div>
|
||||||
|
<div class="mb-3">
|
||||||
|
<label for="password" class="form-label">Пароль</label>
|
||||||
|
<input type="password" class="form-control" id="password" name="password" required
|
||||||
|
minlength="8">
|
||||||
|
<div class="form-text">Минимум 8 символов</div>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn btn-primary w-100">Создать аккаунт</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php $content = ob_get_clean(); ?>
|
||||||
|
<?php require dirname(__DIR__) . '/layout.php'; ?>
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
<?php ob_start(); ?>
|
||||||
|
<h2>Dashboard</h2>
|
||||||
|
|
||||||
|
<div class="row mb-4">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-white bg-primary">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Устройства</h5>
|
||||||
|
<p class="card-text display-6">0</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-white bg-success">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Сервисы</h5>
|
||||||
|
<p class="card-text display-6">0</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-white bg-info">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Новые находки</h5>
|
||||||
|
<p class="card-text display-6">0</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<div class="card text-white bg-warning">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5 class="card-title">Требуют внимания</h5>
|
||||||
|
<p class="card-text display-6">0</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
Последние найденные хосты
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">Нет данных. Запустите сканирование сети.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
Последние события
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">Нет событий.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
Последний скан сети
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<p class="text-muted">Сканирование ещё не запускалось.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?php $content = ob_get_clean(); ?>
|
||||||
|
<?php require dirname(__DIR__) . '/layout.php'; ?>
|
||||||
|
|
@ -0,0 +1,67 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="ru">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Домовой</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<link href="/assets/css/app.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="d-flex" id="wrapper">
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<nav class="bg-dark text-white border-end" id="sidebar-wrapper" style="width: 250px; min-height: 100vh;">
|
||||||
|
<div class="sidebar-heading p-3 border-bottom border-secondary">
|
||||||
|
<a href="/dashboard" class="text-white text-decoration-none">
|
||||||
|
<h4 class="mb-0">Домовой</h4>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div class="list-group list-group-flush">
|
||||||
|
<a href="/dashboard" class="list-group-item list-group-item-action bg-dark text-white border-0">
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
<a href="/discovery" class="list-group-item list-group-item-action bg-dark text-white border-0">
|
||||||
|
Сканирование сети
|
||||||
|
</a>
|
||||||
|
<a href="/devices" class="list-group-item list-group-item-action bg-dark text-white border-0">
|
||||||
|
Устройства
|
||||||
|
</a>
|
||||||
|
<a href="/services" class="list-group-item list-group-item-action bg-dark text-white border-0">
|
||||||
|
Сервисы
|
||||||
|
</a>
|
||||||
|
<a href="/documents" class="list-group-item list-group-item-action bg-dark text-white border-0">
|
||||||
|
Документы
|
||||||
|
</a>
|
||||||
|
<a href="/settings" class="list-group-item list-group-item-action bg-dark text-white border-0">
|
||||||
|
Настройки
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Page Content -->
|
||||||
|
<div id="page-content-wrapper" class="w-100">
|
||||||
|
<!-- Top Navbar -->
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-dark bg-dark border-bottom border-secondary">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<span class="navbar-brand text-light">Домовой</span>
|
||||||
|
<div class="ms-auto d-flex align-items-center">
|
||||||
|
<span class="text-light me-3"><?= htmlspecialchars($username ?? 'User') ?></span>
|
||||||
|
<form method="POST" action="/logout" class="d-inline">
|
||||||
|
<button type="submit" class="btn btn-outline-light btn-sm">Logout</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="container-fluid p-4">
|
||||||
|
<?php if (isset($content)): ?>
|
||||||
|
<?= $content ?>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue