Итерация 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:
mirivlad 2026-05-26 07:26:55 +08:00
commit e7732f5cee
31 changed files with 8790 additions and 0 deletions

17
.env.example Normal file
View File

@ -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

3
.gitignore vendored Normal file
View File

@ -0,0 +1,3 @@
/vendor/
.env
.phpunit.result.cache

393
PLAN.md Normal file
View File

@ -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, фикс замечаний,
и только потом следующая итерация.
Никогда не давай больше одной итерации за раз.

74
README.md Normal file
View File

@ -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

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

25
app/Models/User.php Normal file
View File

@ -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;
}
}

View File

@ -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'),
]);
}
}

View File

@ -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();
}
}

57
bin/console Executable file
View File

@ -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;
}

42
composer.json Normal file
View File

@ -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"
}
}
}

5123
composer.lock generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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();
}
}

49
docker-compose.yml Normal file
View File

@ -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:

29
docker/app/Dockerfile Normal file
View File

@ -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"]

21
docker/web/default.conf Normal file
View File

@ -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;
}
}

31
phinx.php Normal file
View File

@ -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',
];

22
public/assets/css/app.css Normal file
View File

@ -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;
}

99
public/index.php Normal file
View File

@ -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();

28
scripts/check.sh Executable file
View File

@ -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
storage/logs/.gitkeep Normal file
View File

0
storage/reports/.gitkeep Normal file
View File

0
storage/scans/.gitkeep Normal file
View File

31
templates/auth/login.php Normal file
View File

@ -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'; ?>

30
templates/auth/setup.php Normal file
View File

@ -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'; ?>

View File

@ -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'; ?>

67
templates/layout.php Normal file
View File

@ -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