Добавить установщик XLX системы и веб-панель

Этот коммит содержится в:
2026-05-18 06:38:31 +09:00
родитель 53bcc6567a
Коммит 7808cc4158
36 изменённых файлов: 2461 добавлений и 78 удалений
+133 -48
Просмотреть файл
@@ -1,60 +1,145 @@
# XLX Server
Собственный XLX/DMR-сервер для пользователей Pi-Star с платной регистрацией, автоматической выдачей ID, проверкой позывного по внешней базе и персональным доступом по логину/паролю.
Собственный XLX/DMR-сервер для пользователей Pi-Star.
## Цель проекта
## Что уже есть
Сделать управляемую платформу для DMR-пользователей:
- платная регистрация;
- проверка позывного по внутренней базе зарегистрированных пользователей;
- YooKassa;
- ручной перевод с подтверждением по чеку;
- автоматическая выдача внутреннего DMR ID;
- отдельный DMR-пароль для Pi-Star;
- установка и управление `xlxd`;
- главный пользовательский дашборд `/`;
- админка настроек `xlxd` `/admin`;
- экспорт активных пользователей для будущего access gateway.
- регистрация пользователя по позывному;
- автоматическая проверка позывного;
- платная активация аккаунта;
- автоматическая выдача внутреннего XLX/DMR ID;
- генерация данных подключения для Pi-Star;
- управление доступом к XLX-серверу;
- админ-панель пользователей, оплат, ID, логов и подключений;
- автообновление сервера и веб-панели.
## Компоненты
## Базовая архитектура
- `xlxd` - мультипротокольный XLX reflector.
- `public/index.php` - PHP API и web routes.
- `public/assets` - стили и JS дашбордов.
- `src/Domain` - регистрация, проверка дублей позывного, оплата, выдача ID, настройки XLX.
- `database/schema.sql` - схема MariaDB/MySQL.
- `scripts/install-xlxd.sh` - установка и сборка `xlxd`.
- `scripts/xlx-control` - start/stop/status для systemd.
Проект делится на несколько частей:
## Быстрый старт XLX
1. **XLX Core** — установленный и управляемый XLX reflector.
2. **Access Gateway** — слой контроля доступа пользователей, паролей и разрешённых подключений.
3. **Web Panel** — личный кабинет и админка.
4. **Billing** — платная регистрация и продление доступа.
5. **Callsign Verifier** — проверка позывных по базе радиолюбителей.
6. **ID Allocator** — автоматическая выдача уникального ID.
7. **Installer/Updater** — установка, обновления, бэкапы и восстановление.
Для полной установки панели, базы, YooKassa-заготовки и самого `xlxd` используй общий установщик.
### Установка без домена
```bash
sudo apt update
sudo apt install -y git
git clone https://github.com/viktor138irk/xlx.git
cd xlx
sudo bash scripts/install-system.sh
```
Установщик сам выберет адрес сервера:
1. `XLX_PUBLIC_HOST`, если переменная задана;
2. `hostname -f`;
3. первый IP из `hostname -I`;
4. `127.0.0.1`, если ничего не найдено.
В конце установки будут показаны:
- адрес сайта;
- адрес админки;
- admin token;
- webhook URL для YooKassa;
- логин и пароль БД.
### Установка с доменом
```bash
sudo apt update
sudo apt install -y git
git clone https://github.com/viktor138irk/xlx.git
cd xlx
sudo XLX_PUBLIC_HOST=xlx.example.ru bash scripts/install-system.sh
```
Если домен появится позже, его можно поменять в админке `/admin`.
### Установка с YooKassa
```bash
sudo \
XLX_PUBLIC_HOST=xlx.example.ru \
YOOKASSA_SHOP_ID=123456 \
YOOKASSA_SECRET_KEY=secret_key \
bash scripts/install-system.sh
```
Webhook для YooKassa установщик выведет в конце. Он выглядит так:
```text
http://адрес-сервера/api/webhooks/yookassa?token=секрет
```
Если сервер работает через HTTPS:
```bash
sudo \
XLX_PUBLIC_SCHEME=https \
XLX_PUBLIC_HOST=xlx.example.ru \
YOOKASSA_SHOP_ID=123456 \
YOOKASSA_SECRET_KEY=secret_key \
bash scripts/install-system.sh
```
### После установки
Открой:
- `/` - главная страница и регистрация пользователей;
- `/admin` - админка настроек `xlxd`;
- `/api/health` - проверка API.
Для Pi-Star нужен UDP порт `62030`. Также обычно открывают:
```bash
sudo bash scripts/firewall-ufw.sh
```
### Управление XLX
```bash
sudo systemctl start xlxd
sudo systemctl stop xlxd
sudo systemctl restart xlxd
sudo systemctl status xlxd
sudo tail -f /var/log/xlxd.log
```
### Только установка reflector
Если нужна только сборка и запуск `xlxd` без полной панели:
```bash
git clone https://github.com/viktor138irk/xlx.git
cd xlx
sudo mkdir -p /etc/xlx
sudo cp config/xlxd.env.example /etc/xlx/xlxd.env
sudo nano /etc/xlx/xlxd.env
sudo bash scripts/install-xlxd.sh
sudo systemctl start xlxd
```
## Документация
- `docs/INSTALL_SYSTEM.md` - установка всей системы без привязки к домену или IP.
- `docs/INSTALL_XLXD.md` - установка reflector.
- `docs/PAYMENTS_AND_REGISTRATION.md` - регистрация, YooKassa и чеки.
- `docs/ADMIN_DASHBOARD.md` - главная и админка.
## Статус
v0.1.0 — проектирование и стартовый каркас.
v0.2.0-dev.
## Предполагаемый стек
- Ubuntu 22.04/24.04 LTS;
- XLX reflector;
- PHP 8.3 или Python/FastAPI для панели/API;
- MariaDB/MySQL;
- Nginx;
- systemd services;
- YooKassa/ЮMoney/ручное подтверждение оплаты на первом этапе;
- автоустановщик shell + миграции БД.
## Pi-Star сценарий
Пользователь после оплаты получает:
- адрес сервера;
- порт;
- логин/позывной;
- пароль;
- назначенный ID;
- инструкцию подключения Pi-Star.
## Версионирование
- малые фиксы: `+0.0.1`;
- заметные функции: `+0.1.0`;
- архитектурные изменения: `+1.0.0`.
Следующий шаг - UDP access gateway для жесткой проверки оплаченного доступа перед `xlxd`.
+1 -1
Просмотреть файл
@@ -1 +1 @@
0.1.0
0.2.0-dev
+26
Просмотреть файл
@@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
spl_autoload_register(static function (string $class): void {
$prefix = 'Xlx\\';
if (strncmp($class, $prefix, strlen($prefix)) !== 0) {
return;
}
$path = __DIR__ . '/src/' . str_replace('\\', '/', substr($class, strlen($prefix))) . '.php';
if (is_file($path)) {
require $path;
}
});
$configPath = __DIR__ . '/config/config.php';
if (!is_file($configPath)) {
$configPath = __DIR__ . '/config/config.example.php';
}
$config = require $configPath;
date_default_timezone_set($config['app']['timezone'] ?? 'UTC');
return new Xlx\Support\Config($config);
+50 -23
Просмотреть файл
@@ -2,47 +2,74 @@
declare(strict_types=1);
$host = getenv('XLX_PUBLIC_HOST') ?: ($_SERVER['HTTP_HOST'] ?? '127.0.0.1');
$scheme = getenv('XLX_PUBLIC_SCHEME') ?: ((!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'https' : 'http');
$baseUrl = getenv('XLX_BASE_URL') ?: $scheme . '://' . $host;
$hostWithoutPort = preg_replace('/:\d+$/', '', $host);
return [
'app' => [
'name' => 'XLX Server',
'env' => 'production',
'debug' => false,
'base_url' => 'https://xlx.example.com',
'timezone' => 'Europe/Amsterdam',
'env' => getenv('XLX_APP_ENV') ?: 'production',
'debug' => (bool) (getenv('XLX_DEBUG') ?: false),
'base_url' => $baseUrl,
'timezone' => getenv('XLX_TIMEZONE') ?: 'Europe/Moscow',
],
'database' => [
'host' => '127.0.0.1',
'port' => 3306,
'name' => 'xlx_server',
'user' => 'xlx_user',
'password' => 'change_me',
'host' => getenv('XLX_DB_HOST') ?: '127.0.0.1',
'port' => (int) (getenv('XLX_DB_PORT') ?: 3306),
'name' => getenv('XLX_DB_NAME') ?: 'xlx_server',
'user' => getenv('XLX_DB_USER') ?: 'xlx_user',
'password' => getenv('XLX_DB_PASSWORD') ?: 'change_me',
'charset' => 'utf8mb4',
],
'xlx' => [
'reflector_name' => 'XLX000',
'server_host' => 'xlx.example.com',
'dashboard_port' => 8080,
'dmr_port' => 62030,
'default_module' => 'A',
'config_path' => '/etc/xlx',
'service_name' => 'xlxd',
'reflector_name' => getenv('XLX_REFLECTOR_NAME') ?: 'XLX000',
'server_host' => getenv('XLX_PUBLIC_HOST') ?: $host,
'sysop_callsign' => getenv('XLX_SYSOP_CALLSIGN') ?: 'N0CALL',
'sysop_email' => getenv('XLX_SYSOP_EMAIL') ?: 'admin@' . $hostWithoutPort,
'country' => getenv('XLX_COUNTRY') ?: 'RU',
'dashboard_port' => (int) (getenv('XLX_DASHBOARD_PORT') ?: 8080),
'dmr_port' => (int) (getenv('XLX_DMR_PORT') ?: 62030),
'ysf_port' => (int) (getenv('XLX_YSF_PORT') ?: 42000),
'default_module' => getenv('XLX_DEFAULT_MODULE') ?: 'A',
'modules' => getenv('XLX_MODULES') ?: 'ABCDEFGHIJKLMNOPQRSTUVWXYZ',
'config_path' => getenv('XLX_CONFIG_PATH') ?: '/etc/xlx',
'install_path' => getenv('XLX_INSTALL_PATH') ?: '/xlxd',
'source_path' => getenv('XLX_SOURCE_PATH') ?: '/usr/src/xlxd',
'log_path' => getenv('XLX_LOG_PATH') ?: '/var/log/xlxd.log',
'service_name' => getenv('XLX_SERVICE_NAME') ?: 'xlxd',
],
'id_allocator' => [
'min_id' => 9000001,
'max_id' => 9099999,
'min_id' => (int) (getenv('XLX_MIN_ID') ?: 9000001),
'max_id' => (int) (getenv('XLX_MAX_ID') ?: 9099999),
],
'billing' => [
'mode' => 'manual',
'currency' => 'RUB',
'mode' => getenv('XLX_BILLING_MODE') ?: 'yookassa',
'currency' => getenv('XLX_CURRENCY') ?: 'RUB',
'default_duration_days' => (int) (getenv('XLX_DEFAULT_DURATION_DAYS') ?: 30),
'manual_transfer_enabled' => (bool) (getenv('XLX_MANUAL_TRANSFER_ENABLED') ?: true),
'manual_transfer_instructions' => getenv('XLX_MANUAL_TRANSFER_INSTRUCTIONS') ?: 'Переведите сумму тарифа и отправьте номер или ссылку чека на проверку.',
'yookassa' => [
'shop_id' => getenv('YOOKASSA_SHOP_ID') ?: 'change_me',
'secret_key' => getenv('YOOKASSA_SECRET_KEY') ?: 'change_me',
'return_url' => getenv('YOOKASSA_RETURN_URL') ?: $baseUrl . '/payment/return',
'webhook_secret' => getenv('YOOKASSA_WEBHOOK_SECRET') ?: 'change_this_webhook_token',
'capture' => (bool) (getenv('YOOKASSA_CAPTURE') ?: true),
],
],
'security' => [
'session_name' => 'xlx_session',
'password_min_length' => 10,
'dmr_password_length' => 16,
'session_name' => getenv('XLX_SESSION_NAME') ?: 'xlx_session',
'password_min_length' => (int) (getenv('XLX_PASSWORD_MIN_LENGTH') ?: 10),
'dmr_password_length' => (int) (getenv('XLX_DMR_PASSWORD_LENGTH') ?: 16),
],
'admin' => [
'api_token' => getenv('XLX_ADMIN_TOKEN') ?: 'change_this_long_random_admin_token',
],
];
+15
Просмотреть файл
@@ -0,0 +1,15 @@
XLX_REFLECTOR_NAME=XLX000
XLX_SERVER_HOST=xlx.example.com
XLX_SYSOP_CALLSIGN=N0CALL
XLX_SYSOP_EMAIL=admin@example.com
XLX_COUNTRY=RU
XLX_DASHBOARD_PORT=8080
XLX_DMR_PORT=62030
XLX_YSF_PORT=42000
XLX_DEFAULT_MODULE=A
XLX_MODULES=ABCDEFGHIJKLMNOPQRSTUVWXYZ
XLX_INSTALL_PATH=/xlxd
XLX_SOURCE_PATH=/usr/src/xlxd
XLX_LOG_PATH=/var/log/xlxd.log
XLX_SERVICE_NAME=xlxd
XLX_REPO_URL=https://github.com/LX3JL/xlxd.git
+28 -2
Просмотреть файл
@@ -33,7 +33,7 @@ CREATE TABLE IF NOT EXISTS dmr_ids (
CREATE TABLE IF NOT EXISTS tariffs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(120) NOT NULL,
name VARCHAR(120) NOT NULL UNIQUE,
price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
currency CHAR(3) NOT NULL DEFAULT 'RUB',
duration_days INT UNSIGNED NOT NULL DEFAULT 30,
@@ -49,6 +49,7 @@ CREATE TABLE IF NOT EXISTS payments (
currency CHAR(3) NOT NULL DEFAULT 'RUB',
provider VARCHAR(64) NOT NULL DEFAULT 'manual',
provider_payment_id VARCHAR(190) NULL,
confirmation_url TEXT NULL,
status ENUM('pending', 'paid', 'failed', 'cancelled', 'refunded') NOT NULL DEFAULT 'pending',
paid_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
@@ -56,6 +57,21 @@ CREATE TABLE IF NOT EXISTS payments (
CONSTRAINT fk_payments_tariff FOREIGN KEY (tariff_id) REFERENCES tariffs(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS payment_receipts (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
payment_id BIGINT UNSIGNED NOT NULL,
user_id BIGINT UNSIGNED NOT NULL,
receipt_reference VARCHAR(255) NOT NULL,
comment TEXT NULL,
status ENUM('pending', 'approved', 'rejected') NOT NULL DEFAULT 'pending',
reviewed_by_user_id BIGINT UNSIGNED NULL,
reviewed_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_payment_receipts_payment FOREIGN KEY (payment_id) REFERENCES payments(id) ON DELETE CASCADE,
CONSTRAINT fk_payment_receipts_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
CONSTRAINT fk_payment_receipts_reviewer FOREIGN KEY (reviewed_by_user_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS access_credentials (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL UNIQUE,
@@ -101,6 +117,16 @@ CREATE TABLE IF NOT EXISTS audit_logs (
CONSTRAINT fk_audit_logs_actor FOREIGN KEY (actor_user_id) REFERENCES users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS xlx_service_events (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
event_type VARCHAR(64) NOT NULL,
status VARCHAR(64) NOT NULL,
message TEXT NULL,
payload_json JSON NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
INDEX idx_xlx_service_events_created_at (created_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
INSERT INTO tariffs (name, price, currency, duration_days)
VALUES ('Стартовый доступ 30 дней', 300.00, 'RUB', 30)
ON DUPLICATE KEY UPDATE name = name;
ON DUPLICATE KEY UPDATE price = VALUES(price), currency = VALUES(currency), duration_days = VALUES(duration_days);
+14
Просмотреть файл
@@ -0,0 +1,14 @@
<VirtualHost *:80>
ServerName _
DocumentRoot __PUBLIC_DIR__
<Directory __PUBLIC_DIR__>
Options FollowSymLinks
AllowOverride None
Require all granted
FallbackResource /index.php
</Directory>
ErrorLog ${APACHE_LOG_DIR}/xlx-panel-error.log
CustomLog ${APACHE_LOG_DIR}/xlx-panel-access.log combined
</VirtualHost>
+17
Просмотреть файл
@@ -0,0 +1,17 @@
[Unit]
Description=XLX Multiprotocol Reflector
After=network-online.target
Wants=network-online.target
[Service]
Type=forking
EnvironmentFile=/etc/xlx/xlxd.env
ExecStart=/usr/local/sbin/xlx-control start
ExecStop=/usr/local/sbin/xlx-control stop
ExecReload=/usr/local/sbin/xlx-control restart
PIDFile=/var/run/xlxd.pid
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
+33
Просмотреть файл
@@ -0,0 +1,33 @@
# Админка и главный дашборд
## Главная
Главная страница доступна по адресу `/`.
На ней есть:
- краткое описание сервиса;
- карточки статуса DMR/YooKassa/Pi-Star;
- форма регистрации пользователя;
- вывод ссылки YooKassa и инструкции для перевода по чеку.
## Админка
Админка доступна по адресу `/admin`.
Чтобы загрузить или сохранить настройки, нужно ввести `X-Admin-Token` из `config/config.php`.
Форма меняет:
- имя reflector, например `XLX138`;
- домен сервера;
- sysop callsign и email;
- страну;
- DMR/YSF/dashboard порты;
- модуль по умолчанию;
- список активных модулей;
- пути установки, исходников и логов;
- имя systemd service;
- URL репозитория `xlxd`.
При сохранении с флагом `apply` API пытается обновить `/etc/xlx/xlxd.env`. Если PHP работает без прав записи в `/etc/xlx`, настройки все равно сохранятся в БД, а в админке будет виден готовый env-preview.
+3 -3
Просмотреть файл
@@ -56,18 +56,18 @@ XLX Server — это управляемая платформа для DMR-по
### 2.3 Callsign Verifier
Модуль проверки позывного по внешним источникам.
Модуль проверки позывного по внутренней базе уже зарегистрированных пользователей.
MVP-логика:
- пользователь вводит позывной;
- система нормализует его в верхний регистр;
- проверяет формат;
- делает запрос к настроенной базе;
- проверяет, что позывной еще не занят в таблице пользователей;
- сохраняет результат проверки;
- при ошибке отправляет заявку в ручную модерацию.
Важно: внешний источник проверки должен быть заменяемым адаптером, чтобы не прибивать проект гвоздями к одной базе.
Важно: уникальность дополнительно защищена индексом `users.callsign`, поэтому дубль не пройдет даже при одновременной регистрации.
### 2.4 ID Allocator
+87
Просмотреть файл
@@ -0,0 +1,87 @@
# Установка всей системы
`scripts/install-system.sh` ставит сразу:
- Apache + PHP;
- MariaDB;
- базу и пользователя БД;
- web-панель `/`;
- админку `/admin`;
- `xlxd`;
- systemd service для reflector;
- `/etc/xlx/xlxd.env`.
## Без домена
На чистом Ubuntu/Debian сервере:
```bash
git clone https://github.com/viktor138irk/xlx.git
cd xlx
sudo bash scripts/install-system.sh
```
Установщик сам выберет адрес:
1. `XLX_PUBLIC_HOST`, если переменная задана;
2. `hostname -f`, если он похож на реальное имя;
3. первый IP из `hostname -I`;
4. `127.0.0.1` как последний fallback.
В конце он покажет:
- URL сайта;
- URL админки;
- admin token;
- webhook URL для YooKassa;
- логин и пароль БД.
## С доменом
Если домен уже есть:
```bash
sudo XLX_PUBLIC_HOST=xlx.example.ru bash scripts/install-system.sh
```
Если домен появится позже, открой `/admin` и поменяй поле `Домен`. Это обновит настройки `xlxd` без ручного редактирования конфигов.
## YooKassa
Можно передать ключи сразу:
```bash
sudo \
XLX_PUBLIC_HOST=xlx.example.ru \
YOOKASSA_SHOP_ID=123456 \
YOOKASSA_SECRET_KEY=secret \
bash scripts/install-system.sh
```
Webhook будет такого вида:
```text
http://адрес-сервера/api/webhooks/yookassa?token=секрет
```
Если включишь HTTPS, укажи:
```bash
sudo XLX_PUBLIC_SCHEME=https XLX_PUBLIC_HOST=xlx.example.ru bash scripts/install-system.sh
```
## Повторный запуск
Скрипт можно запускать повторно. Он:
- обновит `config/config.php`;
- обновит `/etc/xlx/xlxd.env`;
- применит SQL-схему;
- пересоберет `xlxd`;
- перезагрузит Apache.
Перед повторным запуском с существующей БД лучше передать старый пароль:
```bash
sudo XLX_DB_PASSWORD='старый-пароль' bash scripts/install-system.sh
```
+71
Просмотреть файл
@@ -0,0 +1,71 @@
# Установка XLX reflector
Проект состоит из двух частей:
- `xlxd` - сам мультипротокольный XLX reflector;
- PHP-панель - регистрация, YooKassa, ручной чек, выдача DMR ID и данных для Pi-Star.
## Требования
- Ubuntu 22.04/24.04 или Debian 12.
- Публичный статический IP.
- DNS-имя, например `xlx.example.com`.
- Открытый UDP `62030` для Pi-Star MMDVM.
- Root-доступ на сервер.
## Настройка XLX
```bash
git clone https://github.com/viktor138irk/xlx.git
cd xlx
sudo mkdir -p /etc/xlx
sudo cp config/xlxd.env.example /etc/xlx/xlxd.env
sudo nano /etc/xlx/xlxd.env
```
Минимально поменяй:
```bash
XLX_REFLECTOR_NAME=XLX138
XLX_SERVER_HOST=xlx.example.com
XLX_SYSOP_CALLSIGN=R0XXX
XLX_SYSOP_EMAIL=admin@example.com
XLX_COUNTRY=RU
```
## Установка сервера
```bash
sudo bash scripts/install-xlxd.sh
sudo ./scripts/firewall-ufw.sh
sudo systemctl start xlxd
sudo systemctl status xlxd
```
Логи:
```bash
sudo tail -f /var/log/xlxd.log
```
## Управление
```bash
sudo systemctl start xlxd
sudo systemctl stop xlxd
sudo systemctl restart xlxd
sudo systemctl status xlxd
```
## Pi-Star
После оплаты панель выдаст:
- сервер: `XLX_SERVER_HOST`;
- порт: `62030`;
- логин: позывной;
- пароль: отдельный DMR-пароль;
- DMR ID: выданный системой ID;
- модуль: `A` по умолчанию.
Полный DMR-пароль показывается один раз. Если пользователь потерял пароль, нужно выпустить новый.
+123
Просмотреть файл
@@ -0,0 +1,123 @@
# Регистрация и оплата
## Сценарий пользователя
1. Пользователь отправляет позывной, email и пароль кабинета.
2. Система нормализует позывной и проверяет, что такого позывного еще нет во внутренней базе.
3. Если позывной принят, создается платеж.
4. Пользователь выбирает:
- оплату через YooKassa;
- перевод с отправкой чека на ручную проверку.
5. После подтверждения оплаты система:
- активирует аккаунт;
- назначает внутренний DMR ID;
- генерирует отдельный DMR-пароль;
- отдает данные подключения Pi-Star.
## YooKassa
В `config/config.php`:
```php
'billing' => [
'mode' => 'yookassa',
'yookassa' => [
'shop_id' => '...',
'secret_key' => '...',
'return_url' => 'https://xlx.example.com/payment/return',
'webhook_secret' => 'long-random-token',
'capture' => true,
],
],
```
Webhook URL:
```text
https://xlx.example.com/api/webhooks/yookassa?token=long-random-token
```
Обрабатывается событие:
```text
payment.succeeded
```
Повторный webhook безопасен: если платеж уже оплачен, система вернет текущий доступ без повторной выдачи ID.
## Перевод с чеком
Если пользователь оплатил переводом, он отправляет номер или ссылку чека:
```http
POST /api/payments/receipt
Content-Type: application/json
{
"payment_id": 1,
"user_id": 1,
"receipt_reference": "https://bank.example/receipt/123",
"comment": "Оплата за R0XXX"
}
```
Админ подтверждает чек:
```http
POST /api/admin/receipts/approve
X-Admin-Token: change_this_long_random_admin_token
Content-Type: application/json
{
"receipt_id": 1,
"admin_user_id": 1
}
```
## Основные API
Регистрация:
```http
POST /api/register
Content-Type: application/json
{
"callsign": "R0XXX",
"email": "user@example.com",
"password": "strong-password"
}
```
Подтверждение платежа админом:
```http
POST /api/admin/payments/confirm
X-Admin-Token: change_this_long_random_admin_token
Content-Type: application/json
{
"payment_id": 1,
"admin_user_id": 1
}
```
Экспорт активных пользователей для gateway:
```http
GET /api/admin/access/export
X-Admin-Token: change_this_long_random_admin_token
```
## Pi-Star и пароль
`xlxd` поднимает сам reflector и принимает MMDVM/DMR на UDP `62030`. Персональная проверка логина/пароля на уровне Pi-Star требует отдельного access gateway/proxy или auth-слоя перед reflector.
В проекте уже есть:
- хранение отдельного DMR-пароля;
- включение/выключение доступа;
- экспорт активных пользователей;
- единая активация после YooKassa или ручного чека.
Следующий слой - UDP gateway, который будет читать экспорт активных пользователей и пропускать к `xlxd` только оплаченные подключения.
+1 -1
Просмотреть файл
@@ -20,7 +20,7 @@
- [ ] форма регистрации;
- [ ] нормализация позывного;
- [ ] проверка формата позывного;
- [ ] адаптер внешней базы позывных;
- [x] проверка дублей позывных по внутренней базе;
- [ ] ручная модерация спорных заявок;
- [ ] автоматическая выдача ID;
- [ ] защита от дублей;
+90
Просмотреть файл
@@ -0,0 +1,90 @@
const tokenInput = document.querySelector('#adminToken');
const form = document.querySelector('#settingsForm');
const envPreview = document.querySelector('#envPreview');
const adminResult = document.querySelector('#adminResult');
const loadButton = document.querySelector('#loadSettings');
const saveButton = document.querySelector('#saveSettings');
const applyButton = document.querySelector('#applySettings');
function token() {
return tokenInput.value.trim();
}
function setResult(text) {
adminResult.textContent = text;
}
function formData() {
return Object.fromEntries(new FormData(form).entries());
}
function fillForm(settings) {
for (const [key, value] of Object.entries(settings)) {
const input = form.elements.namedItem(key);
if (input) {
input.value = value ?? '';
}
}
}
async function loadSettings() {
setResult('Загружаем...');
const response = await fetch('/api/admin/xlxd/settings', {
headers: { 'X-Admin-Token': token() },
});
const payload = await response.json();
if (!payload.ok) {
setResult(payload.error || 'Ошибка загрузки');
return;
}
fillForm(payload.data.settings);
envPreview.textContent = payload.data.env;
setResult('Настройки загружены.');
}
async function saveSettings(apply) {
setResult(apply ? 'Сохраняем и применяем...' : 'Сохраняем...');
const response = await fetch('/api/admin/xlxd/settings', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-Admin-Token': token(),
},
body: JSON.stringify({ settings: formData(), apply }),
});
const payload = await response.json();
if (!payload.ok) {
setResult(payload.error || 'Ошибка сохранения');
return;
}
envPreview.textContent = payload.data.env;
setResult(payload.data.message);
}
loadButton?.addEventListener('click', loadSettings);
saveButton?.addEventListener('click', () => saveSettings(false));
applyButton?.addEventListener('click', () => saveSettings(true));
form?.addEventListener('input', () => {
const data = formData();
const lines = {
XLX_REFLECTOR_NAME: data.reflector_name,
XLX_SERVER_HOST: data.server_host,
XLX_SYSOP_CALLSIGN: data.sysop_callsign,
XLX_SYSOP_EMAIL: data.sysop_email,
XLX_COUNTRY: data.country,
XLX_DASHBOARD_PORT: data.dashboard_port,
XLX_DMR_PORT: data.dmr_port,
XLX_YSF_PORT: data.ysf_port,
XLX_DEFAULT_MODULE: data.default_module,
XLX_MODULES: data.modules,
XLX_INSTALL_PATH: data.install_path,
XLX_SOURCE_PATH: data.source_path,
XLX_LOG_PATH: data.log_path,
XLX_SERVICE_NAME: data.service_name,
XLX_REPO_URL: data.repo_url,
};
envPreview.textContent = Object.entries(lines).map(([key, value]) => `${key}=${value || ''}`).join('\n');
});
+35
Просмотреть файл
@@ -0,0 +1,35 @@
const form = document.querySelector('#registerForm');
const result = document.querySelector('#registerResult');
form?.addEventListener('submit', async (event) => {
event.preventDefault();
result.textContent = 'Отправляем заявку...';
const data = Object.fromEntries(new FormData(form).entries());
const response = await fetch('/api/register', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
const payload = await response.json();
if (!payload.ok) {
result.textContent = payload.error || 'Ошибка регистрации';
return;
}
const payment = payload.data.payment || {};
const lines = [
`Заявка создана: ${payload.data.callsign}`,
`Статус: ${payload.data.status}`,
];
if (payment.confirmation_url) {
lines.push(`YooKassa: ${payment.confirmation_url}`);
}
if (payment.manual_transfer?.instructions) {
lines.push(`Перевод по чеку: ${payment.manual_transfer.instructions}`);
lines.push(`ID платежа для чека: ${payment.id}`);
}
result.textContent = lines.join('\n');
});
+244
Просмотреть файл
@@ -0,0 +1,244 @@
:root {
color-scheme: dark;
--bg: #07080d;
--panel: #111522;
--panel-2: #171d2d;
--text: #f6f7fb;
--muted: #aeb7c9;
--line: #2a3347;
--accent: #5ee0b8;
--accent-2: #88a7ff;
--danger: #ff7d7d;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-height: 100vh;
background: radial-gradient(circle at top left, #17253c 0, transparent 34rem), var(--bg);
color: var(--text);
font-family: Arial, Helvetica, sans-serif;
}
a {
color: inherit;
}
.shell {
width: min(1180px, calc(100% - 32px));
margin: 0 auto;
padding: 36px 0 56px;
}
.hero {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(280px, .65fr);
gap: 28px;
align-items: stretch;
min-height: 420px;
}
.hero.compact {
min-height: 260px;
grid-template-columns: 1fr;
}
.eyebrow {
margin: 0 0 18px;
color: var(--accent);
font-size: 14px;
font-weight: 700;
letter-spacing: .08em;
text-transform: uppercase;
}
h1,
h2 {
margin: 0;
line-height: .98;
}
h1 {
max-width: 900px;
font-size: clamp(44px, 7vw, 86px);
letter-spacing: 0;
}
h2 {
font-size: 32px;
}
.lead {
max-width: 720px;
margin: 22px 0 0;
color: var(--muted);
font-size: 22px;
line-height: 1.35;
}
.actions {
display: flex;
flex-wrap: wrap;
gap: 12px;
margin-top: 28px;
}
.button {
border: 0;
border-radius: 8px;
padding: 14px 20px;
background: var(--accent);
color: #04120f;
font: inherit;
font-weight: 800;
text-decoration: none;
cursor: pointer;
}
.button.ghost {
border: 1px solid var(--line);
background: transparent;
color: var(--text);
}
.status-panel,
.tile,
.panel {
border: 1px solid var(--line);
border-radius: 8px;
background: color-mix(in srgb, var(--panel) 92%, transparent);
}
.status-panel {
display: grid;
align-content: end;
gap: 14px;
padding: 22px;
}
.status-panel div {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 18px;
padding-bottom: 14px;
border-bottom: 1px solid var(--line);
}
.status-panel div:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.status-panel span,
.tile p,
label {
color: var(--muted);
}
.status-panel strong {
font-size: 34px;
}
.grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 16px;
margin-top: 24px;
}
.tile {
padding: 22px;
}
.num {
display: block;
margin-bottom: 34px;
color: var(--accent-2);
font-size: 18px;
font-weight: 800;
}
.tile p {
margin: 16px 0 0;
font-size: 18px;
line-height: 1.45;
}
.panel {
margin-top: 24px;
padding: 24px;
}
.panel.split {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(340px, .8fr);
gap: 24px;
}
.form,
.settings-form {
display: grid;
gap: 14px;
}
.settings-form {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
label {
display: grid;
gap: 8px;
font-size: 14px;
font-weight: 700;
}
input {
width: 100%;
border: 1px solid var(--line);
border-radius: 8px;
padding: 14px;
background: var(--panel-2);
color: var(--text);
font: inherit;
font-size: 17px;
}
input:focus {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.result {
min-height: 72px;
overflow: auto;
margin: 0;
border: 1px solid var(--line);
border-radius: 8px;
padding: 14px;
background: #06070b;
color: var(--text);
font-size: 14px;
line-height: 1.5;
white-space: pre-wrap;
}
.env-preview {
min-height: 420px;
}
@media (max-width: 880px) {
.hero,
.grid,
.panel.split,
.settings-form {
grid-template-columns: 1fr;
}
h1 {
font-size: 44px;
}
}
+123
Просмотреть файл
@@ -0,0 +1,123 @@
<?php
declare(strict_types=1);
use Xlx\Domain\AccessCredentialService;
use Xlx\Domain\AccessExportService;
use Xlx\Domain\IdAllocator;
use Xlx\Domain\PaymentService;
use Xlx\Domain\RegistrationService;
use Xlx\Domain\XlxdSettingsService;
use Xlx\Domain\YooKassaClient;
use Xlx\Support\Database;
use Xlx\Support\Input;
use Xlx\Support\Response;
use Xlx\Support\Security;
$config = require __DIR__ . '/../bootstrap.php';
$pdo = (new Database($config))->pdo();
$method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
try {
if ($method === 'GET' && ($path === '/' || $path === '/dashboard')) {
require __DIR__ . '/views/home.php';
exit;
}
if ($method === 'GET' && $path === '/admin') {
require __DIR__ . '/views/admin.php';
exit;
}
if ($method === 'GET' && $path === '/api/health') {
Response::json(['ok' => true, 'service' => 'xlx-panel']);
}
if ($method === 'GET' && $path === '/api/callsigns/check') {
$paymentService = new PaymentService($config, new IdAllocator($config), new AccessCredentialService($config), new YooKassaClient($config));
$service = new RegistrationService($config, $paymentService);
Response::json(['ok' => true, 'data' => $service->checkCallsign($pdo, (string) ($_GET['callsign'] ?? ''))]);
}
if ($method === 'POST' && $path === '/api/register') {
$paymentService = new PaymentService($config, new IdAllocator($config), new AccessCredentialService($config), new YooKassaClient($config));
$service = new RegistrationService($config, $paymentService);
Response::json(['ok' => true, 'data' => $service->register($pdo, Input::json())], 201);
}
if ($method === 'POST' && $path === '/api/payments/receipt') {
$input = Input::json();
$service = new PaymentService($config, new IdAllocator($config), new AccessCredentialService($config), new YooKassaClient($config));
Response::json(['ok' => true, 'data' => $service->submitReceipt(
$pdo,
(int) ($input['payment_id'] ?? 0),
(int) ($input['user_id'] ?? 0),
(string) ($input['receipt_reference'] ?? ''),
isset($input['comment']) ? (string) $input['comment'] : null,
)], 201);
}
if ($method === 'POST' && $path === '/api/admin/payments/confirm') {
Security::requireAdminToken($config);
$input = Input::json();
$service = new PaymentService($config, new IdAllocator($config), new AccessCredentialService($config), new YooKassaClient($config));
Response::json(['ok' => true, 'data' => $service->confirm($pdo, (int) ($input['payment_id'] ?? 0), $input['admin_user_id'] ?? null)]);
}
if ($method === 'POST' && $path === '/api/admin/receipts/approve') {
Security::requireAdminToken($config);
$input = Input::json();
$service = new PaymentService($config, new IdAllocator($config), new AccessCredentialService($config), new YooKassaClient($config));
Response::json(['ok' => true, 'data' => $service->approveReceipt($pdo, (int) ($input['receipt_id'] ?? 0), $input['admin_user_id'] ?? null)]);
}
if ($method === 'POST' && $path === '/api/webhooks/yookassa') {
$token = $_SERVER['HTTP_X_WEBHOOK_TOKEN'] ?? ($_GET['token'] ?? '');
if (!hash_equals((string) $config->get('billing.yookassa.webhook_secret'), (string) $token)) {
Response::error('Webhook token is missing or invalid.', 401);
}
$input = Input::json();
if (($input['event'] ?? '') !== 'payment.succeeded') {
Response::json(['ok' => true, 'ignored' => true]);
}
$providerPaymentId = (string) ($input['object']['id'] ?? '');
$service = new PaymentService($config, new IdAllocator($config), new AccessCredentialService($config), new YooKassaClient($config));
Response::json(['ok' => true, 'data' => $service->confirmProviderPayment($pdo, $providerPaymentId)]);
}
if ($method === 'GET' && $path === '/api/admin/access/export') {
Security::requireAdminToken($config);
header('Content-Type: text/csv; charset=utf-8');
header('Content-Disposition: attachment; filename="xlx-active-users.csv"');
echo (new AccessExportService())->activeUsersCsv($pdo);
exit;
}
if ($method === 'GET' && $path === '/api/admin/xlxd/settings') {
Security::requireAdminToken($config);
$service = new XlxdSettingsService($config);
$settings = $service->current($pdo);
Response::json([
'ok' => true,
'data' => [
'settings' => $settings,
'env' => $service->renderEnv($settings),
],
]);
}
if ($method === 'POST' && $path === '/api/admin/xlxd/settings') {
Security::requireAdminToken($config);
$input = Input::json();
$service = new XlxdSettingsService($config);
Response::json(['ok' => true, 'data' => $service->save($pdo, $input['settings'] ?? [], (bool) ($input['apply'] ?? false))]);
}
Response::error('Route not found.', 404);
} catch (Throwable $throwable) {
Response::error($throwable->getMessage(), 400);
}
+87
Просмотреть файл
@@ -0,0 +1,87 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>XLX Admin</title>
<link rel="stylesheet" href="/assets/styles.css">
</head>
<body>
<main class="shell admin-shell">
<section class="hero compact">
<div>
<p class="eyebrow">Админка</p>
<h1>Настройка XLX без ручного редактирования конфигов</h1>
<p class="lead">Меняй имя reflector, домен, порты, модули и пути. Настройки сохраняются в БД и могут сразу применяться в `/etc/xlx/xlxd.env`.</p>
</div>
</section>
<section class="panel">
<label>Admin token
<input id="adminToken" type="password" placeholder="X-Admin-Token">
</label>
<div class="actions">
<button class="button" id="loadSettings">Загрузить настройки</button>
<button class="button ghost" id="saveSettings">Сохранить</button>
<button class="button ghost" id="applySettings">Сохранить и применить</button>
</div>
</section>
<section class="panel split">
<form class="form settings-form" id="settingsForm">
<label>Reflector
<input name="reflector_name" placeholder="XLX138">
</label>
<label>Домен
<input name="server_host" placeholder="xlx.example.com">
</label>
<label>Позывной sysop
<input name="sysop_callsign" placeholder="R0XXX">
</label>
<label>Email sysop
<input name="sysop_email" placeholder="admin@example.com">
</label>
<label>Страна
<input name="country" placeholder="RU">
</label>
<label>DMR порт
<input name="dmr_port" type="number" min="1" max="65535">
</label>
<label>YSF порт
<input name="ysf_port" type="number" min="1" max="65535">
</label>
<label>Dashboard порт
<input name="dashboard_port" type="number" min="1" max="65535">
</label>
<label>Модуль по умолчанию
<input name="default_module" maxlength="1">
</label>
<label>Активные модули
<input name="modules" placeholder="ABCDEFGHIJKLMNOPQRSTUVWXYZ">
</label>
<label>Путь установки
<input name="install_path" placeholder="/xlxd">
</label>
<label>Путь исходников
<input name="source_path" placeholder="/usr/src/xlxd">
</label>
<label>Лог
<input name="log_path" placeholder="/var/log/xlxd.log">
</label>
<label>Systemd service
<input name="service_name" placeholder="xlxd">
</label>
<label>Репозиторий xlxd
<input name="repo_url" placeholder="https://github.com/LX3JL/xlxd.git">
</label>
</form>
<div>
<p class="eyebrow">Предпросмотр env</p>
<pre class="result env-preview" id="envPreview"></pre>
<pre class="result" id="adminResult"></pre>
</div>
</section>
</main>
<script src="/assets/admin.js"></script>
</body>
</html>
+78
Просмотреть файл
@@ -0,0 +1,78 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>XLX Server</title>
<link rel="stylesheet" href="/assets/styles.css">
</head>
<body>
<main class="shell">
<section class="hero">
<div>
<p class="eyebrow">XLX / DMR / Pi-Star</p>
<h1>Личный доступ к DMR-серверу без ручной настройки</h1>
<p class="lead">Регистрация по позывному без дублей, оплата через YooKassa или чек перевода, автоматический DMR ID и пароль для Pi-Star.</p>
<div class="actions">
<a class="button" href="#register">Подключиться</a>
<a class="button ghost" href="/admin">Админка</a>
</div>
</div>
<div class="status-panel">
<div>
<span>DMR порт</span>
<strong>62030</strong>
</div>
<div>
<span>Модуль</span>
<strong>A</strong>
</div>
<div>
<span>Оплата</span>
<strong>YooKassa</strong>
</div>
</div>
</section>
<section class="grid">
<article class="tile">
<span class="num">01</span>
<h2>Проверка позывного</h2>
<p>Система проверяет формат и занятость позывного во внутренней базе.</p>
</article>
<article class="tile">
<span class="num">02</span>
<h2>Оплата доступа</h2>
<p>Платеж через YooKassa или перевод с отправкой чека на подтверждение.</p>
</article>
<article class="tile">
<span class="num">03</span>
<h2>Данные Pi-Star</h2>
<p>После оплаты выдаются сервер, порт, логин, пароль, DMR ID и модуль.</p>
</article>
</section>
<section class="panel" id="register">
<div>
<p class="eyebrow">Регистрация</p>
<h2>Создать заявку</h2>
<p>После отправки появится ссылка YooKassa и инструкция для оплаты переводом.</p>
</div>
<form class="form" id="registerForm">
<label>Позывной
<input name="callsign" placeholder="R0XXX" required>
</label>
<label>Email
<input name="email" type="email" placeholder="user@example.com" required>
</label>
<label>Пароль кабинета
<input name="password" type="password" minlength="10" required>
</label>
<button class="button" type="submit">Отправить</button>
<pre class="result" id="registerResult"></pre>
</form>
</section>
</main>
<script src="/assets/app.js"></script>
</body>
</html>
+15
Просмотреть файл
@@ -0,0 +1,15 @@
#!/usr/bin/env bash
set -euo pipefail
ufw allow 22/tcp
ufw allow 80/tcp
ufw allow 443/tcp
ufw allow 8880/udp
ufw allow 10001/udp
ufw allow 10002/udp
ufw allow 30001/udp
ufw allow 30051/udp
ufw allow 42000/udp
ufw allow 62030/udp
ufw status verbose
+170
Просмотреть файл
@@ -0,0 +1,170 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ "${EUID}" -ne 0 ]]; then
echo "Run as root: sudo bash scripts/install-system.sh"
exit 1
fi
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
PUBLIC_DIR="$REPO_ROOT/public"
detect_host() {
if [[ -n "${XLX_PUBLIC_HOST:-}" ]]; then
echo "$XLX_PUBLIC_HOST"
return
fi
local fqdn
fqdn="$(hostname -f 2>/dev/null || true)"
if [[ -n "$fqdn" && "$fqdn" != "localhost" && "$fqdn" != "localhost.localdomain" ]]; then
echo "$fqdn"
return
fi
local ip
ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
if [[ -n "$ip" ]]; then
echo "$ip"
return
fi
echo "127.0.0.1"
}
random_token() {
local token
token="$(tr -dc 'A-Za-z0-9' </dev/urandom | head -c "${1:-40}" || true)"
printf "%s" "$token"
}
php_escape() {
printf "%s" "$1" | sed "s/\\\\/\\\\\\\\/g; s/'/\\\\'/g"
}
sql_escape() {
printf "%s" "$1" | sed "s/'/''/g"
}
PUBLIC_HOST="$(detect_host)"
PUBLIC_SCHEME="${XLX_PUBLIC_SCHEME:-http}"
BASE_URL="${XLX_BASE_URL:-${PUBLIC_SCHEME}://${PUBLIC_HOST}}"
REFLECTOR_NAME="${XLX_REFLECTOR_NAME:-XLX000}"
SYSOP_CALLSIGN="${XLX_SYSOP_CALLSIGN:-N0CALL}"
SYSOP_EMAIL="${XLX_SYSOP_EMAIL:-admin@${PUBLIC_HOST%%:*}}"
COUNTRY="${XLX_COUNTRY:-RU}"
DB_NAME="${XLX_DB_NAME:-xlx_server}"
DB_USER="${XLX_DB_USER:-xlx_user}"
DB_PASSWORD="${XLX_DB_PASSWORD:-$(random_token 32)}"
ADMIN_TOKEN="${XLX_ADMIN_TOKEN:-$(random_token 48)}"
WEBHOOK_SECRET="${YOOKASSA_WEBHOOK_SECRET:-$(random_token 48)}"
DB_PASSWORD_SQL="$(sql_escape "$DB_PASSWORD")"
echo "Installing XLX system for host: $PUBLIC_HOST"
apt-get update
apt-get install -y apache2 libapache2-mod-php php php-mysql mariadb-server git ca-certificates curl
systemctl enable --now mariadb
mysql <<SQL
CREATE DATABASE IF NOT EXISTS \`${DB_NAME}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER IF NOT EXISTS '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASSWORD_SQL}';
ALTER USER '${DB_USER}'@'localhost' IDENTIFIED BY '${DB_PASSWORD_SQL}';
GRANT ALL PRIVILEGES ON \`${DB_NAME}\`.* TO '${DB_USER}'@'localhost';
FLUSH PRIVILEGES;
SQL
mysql "$DB_NAME" < "$REPO_ROOT/database/schema.sql"
PHP_PUBLIC_HOST="$(php_escape "$PUBLIC_HOST")"
PHP_PUBLIC_SCHEME="$(php_escape "$PUBLIC_SCHEME")"
PHP_DB_NAME="$(php_escape "$DB_NAME")"
PHP_DB_USER="$(php_escape "$DB_USER")"
PHP_DB_PASSWORD="$(php_escape "$DB_PASSWORD")"
PHP_REFLECTOR_NAME="$(php_escape "$REFLECTOR_NAME")"
PHP_SYSOP_CALLSIGN="$(php_escape "$SYSOP_CALLSIGN")"
PHP_SYSOP_EMAIL="$(php_escape "$SYSOP_EMAIL")"
PHP_COUNTRY="$(php_escape "$COUNTRY")"
PHP_ADMIN_TOKEN="$(php_escape "$ADMIN_TOKEN")"
PHP_YOOKASSA_SHOP_ID="$(php_escape "${YOOKASSA_SHOP_ID:-change_me}")"
PHP_YOOKASSA_SECRET_KEY="$(php_escape "${YOOKASSA_SECRET_KEY:-change_me}")"
PHP_YOOKASSA_RETURN_URL="$(php_escape "${YOOKASSA_RETURN_URL:-${BASE_URL}/payment/return}")"
PHP_WEBHOOK_SECRET="$(php_escape "$WEBHOOK_SECRET")"
cat > "$REPO_ROOT/config/config.php" <<PHP
<?php
declare(strict_types=1);
\$_SERVER['HTTP_HOST'] = \$_SERVER['HTTP_HOST'] ?? '${PHP_PUBLIC_HOST}';
putenv('XLX_PUBLIC_SCHEME=${PHP_PUBLIC_SCHEME}');
putenv('XLX_DB_HOST=127.0.0.1');
putenv('XLX_DB_PORT=3306');
putenv('XLX_DB_NAME=${PHP_DB_NAME}');
putenv('XLX_DB_USER=${PHP_DB_USER}');
putenv('XLX_DB_PASSWORD=${PHP_DB_PASSWORD}');
putenv('XLX_REFLECTOR_NAME=${PHP_REFLECTOR_NAME}');
putenv('XLX_SYSOP_CALLSIGN=${PHP_SYSOP_CALLSIGN}');
putenv('XLX_SYSOP_EMAIL=${PHP_SYSOP_EMAIL}');
putenv('XLX_COUNTRY=${PHP_COUNTRY}');
putenv('XLX_ADMIN_TOKEN=${PHP_ADMIN_TOKEN}');
putenv('YOOKASSA_SHOP_ID=${PHP_YOOKASSA_SHOP_ID}');
putenv('YOOKASSA_SECRET_KEY=${PHP_YOOKASSA_SECRET_KEY}');
putenv('YOOKASSA_RETURN_URL=${PHP_YOOKASSA_RETURN_URL}');
putenv('YOOKASSA_WEBHOOK_SECRET=${PHP_WEBHOOK_SECRET}');
return require __DIR__ . '/config.example.php';
PHP
mkdir -p /etc/xlx
cat > /etc/xlx/xlxd.env <<EOF
XLX_REFLECTOR_NAME=${REFLECTOR_NAME}
XLX_SERVER_HOST=${PUBLIC_HOST}
XLX_SYSOP_CALLSIGN=${SYSOP_CALLSIGN}
XLX_SYSOP_EMAIL=${SYSOP_EMAIL}
XLX_COUNTRY=${COUNTRY}
XLX_DASHBOARD_PORT=8080
XLX_DMR_PORT=62030
XLX_YSF_PORT=42000
XLX_DEFAULT_MODULE=A
XLX_MODULES=ABCDEFGHIJKLMNOPQRSTUVWXYZ
XLX_INSTALL_PATH=/xlxd
XLX_SOURCE_PATH=/usr/src/xlxd
XLX_LOG_PATH=/var/log/xlxd.log
XLX_SERVICE_NAME=xlxd
XLX_REPO_URL=https://github.com/LX3JL/xlxd.git
EOF
sed "s#__PUBLIC_DIR__#${PUBLIC_DIR}#g" "$REPO_ROOT/deploy/apache/xlx-panel.conf" > /etc/apache2/sites-available/xlx-panel.conf
a2dissite 000-default.conf >/dev/null 2>&1 || true
a2ensite xlx-panel.conf
systemctl reload apache2
bash "$REPO_ROOT/scripts/install-xlxd.sh"
systemctl start xlxd || true
cat <<EOF
XLX system installed.
Site:
${BASE_URL}/
Admin:
${BASE_URL}/admin
Admin token:
${ADMIN_TOKEN}
YooKassa webhook:
${BASE_URL}/api/webhooks/yookassa?token=${WEBHOOK_SECRET}
Database:
name=${DB_NAME}
user=${DB_USER}
password=${DB_PASSWORD}
If you later attach a domain, open /admin and change "Домен", or rerun with:
sudo XLX_PUBLIC_HOST=xlx.your-domain.ru bash scripts/install-system.sh
EOF
+67
Просмотреть файл
@@ -0,0 +1,67 @@
#!/usr/bin/env bash
set -euo pipefail
if [[ "${EUID}" -ne 0 ]]; then
echo "Run as root: sudo bash scripts/install-xlxd.sh"
exit 1
fi
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
ENV_TARGET="/etc/xlx/xlxd.env"
mkdir -p /etc/xlx
if [[ ! -f "$ENV_TARGET" ]]; then
cp "$REPO_ROOT/config/xlxd.env.example" "$ENV_TARGET"
echo "Created $ENV_TARGET. Edit it before production use."
fi
# shellcheck disable=SC1090
source "$ENV_TARGET"
XLX_REPO_URL="${XLX_REPO_URL:-https://github.com/LX3JL/xlxd.git}"
XLX_SOURCE_PATH="${XLX_SOURCE_PATH:-/usr/src/xlxd}"
XLX_INSTALL_PATH="${XLX_INSTALL_PATH:-/xlxd}"
XLX_SERVICE_NAME="${XLX_SERVICE_NAME:-xlxd}"
apt-get update
apt-get install -y git build-essential make g++ apache2 php libapache2-mod-php wget ca-certificates
if [[ -d "$XLX_SOURCE_PATH/.git" ]]; then
git -C "$XLX_SOURCE_PATH" pull --ff-only
else
rm -rf "$XLX_SOURCE_PATH"
git clone "$XLX_REPO_URL" "$XLX_SOURCE_PATH"
fi
make -C "$XLX_SOURCE_PATH/src" clean
make -C "$XLX_SOURCE_PATH/src"
make -C "$XLX_SOURCE_PATH/src" install
mkdir -p "$XLX_INSTALL_PATH"
if [[ -x "$XLX_SOURCE_PATH/src/xlxd" ]]; then
install -m 0755 "$XLX_SOURCE_PATH/src/xlxd" "$XLX_INSTALL_PATH/xlxd"
fi
if [[ -d "$XLX_SOURCE_PATH/dashboard" ]]; then
rm -rf /var/www/html/xlxd
cp -R "$XLX_SOURCE_PATH/dashboard" /var/www/html/xlxd
chown -R www-data:www-data /var/www/html/xlxd
fi
install -m 0755 "$REPO_ROOT/scripts/xlx-control" /usr/local/sbin/xlx-control
install -m 0644 "$REPO_ROOT/deploy/systemd/xlxd.service" "/etc/systemd/system/${XLX_SERVICE_NAME}.service"
/usr/local/sbin/xlx-control render-modules
systemctl daemon-reload
systemctl enable "${XLX_SERVICE_NAME}.service"
cat <<EOF
XLX reflector installed.
Next commands:
sudo systemctl start ${XLX_SERVICE_NAME}
sudo systemctl status ${XLX_SERVICE_NAME}
sudo tail -f ${XLX_LOG_PATH:-/var/log/xlxd.log}
Open firewall/NAT for at least UDP ${XLX_DMR_PORT:-62030} for Pi-Star MMDVM.
EOF
+69
Просмотреть файл
@@ -0,0 +1,69 @@
#!/usr/bin/env bash
set -euo pipefail
ENV_FILE="${ENV_FILE:-/etc/xlx/xlxd.env}"
if [[ -f "$ENV_FILE" ]]; then
# shellcheck disable=SC1090
source "$ENV_FILE"
fi
XLX_INSTALL_PATH="${XLX_INSTALL_PATH:-/xlxd}"
XLX_LOG_PATH="${XLX_LOG_PATH:-/var/log/xlxd.log}"
XLX_MODULES="${XLX_MODULES:-ABCDEFGHIJKLMNOPQRSTUVWXYZ}"
PID_FILE="/var/run/xlxd.pid"
usage() {
echo "Usage: xlx-control {start|stop|restart|status|render-modules}"
}
render_modules() {
mkdir -p "$XLX_INSTALL_PATH"
local module_file="$XLX_INSTALL_PATH/xlxd.module"
: > "$module_file"
local i char
for ((i = 0; i < ${#XLX_MODULES}; i++)); do
char="${XLX_MODULES:$i:1}"
printf "%s\n" "$char" >> "$module_file"
done
}
start() {
render_modules
if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
echo "xlxd already running with pid $(cat "$PID_FILE")"
exit 0
fi
mkdir -p "$(dirname "$XLX_LOG_PATH")"
cd "$XLX_INSTALL_PATH"
./xlxd -d -p "$PID_FILE" >> "$XLX_LOG_PATH" 2>&1
}
stop() {
if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
kill "$(cat "$PID_FILE")"
rm -f "$PID_FILE"
return
fi
pkill -f "$XLX_INSTALL_PATH/xlxd" || true
rm -f "$PID_FILE"
}
status() {
if [[ -f "$PID_FILE" ]] && kill -0 "$(cat "$PID_FILE")" 2>/dev/null; then
echo "xlxd is running with pid $(cat "$PID_FILE")"
exit 0
fi
echo "xlxd is not running"
exit 3
}
case "${1:-}" in
start) start ;;
stop) stop ;;
restart) stop; start ;;
status) status ;;
render-modules) render_modules ;;
*) usage; exit 2 ;;
esac
+44
Просмотреть файл
@@ -0,0 +1,44 @@
<?php
declare(strict_types=1);
namespace Xlx\Domain;
use PDO;
use Xlx\Support\Config;
use Xlx\Support\Security;
final class AccessCredentialService
{
public function __construct(private readonly Config $config)
{
}
public function enableForUser(PDO $pdo, int $userId, string $callsign): array
{
$length = (int) $this->config->get('security.dmr_password_length', 16);
$password = Security::randomPassword($length);
$hash = password_hash($password, PASSWORD_DEFAULT);
$hint = Security::passwordHint($password);
$sql = <<<'SQL'
INSERT INTO access_credentials (user_id, username, dmr_password_hash, dmr_password_hint, is_enabled, last_rotated_at)
VALUES (?, ?, ?, ?, 1, NOW())
ON DUPLICATE KEY UPDATE
username = VALUES(username),
dmr_password_hash = VALUES(dmr_password_hash),
dmr_password_hint = VALUES(dmr_password_hint),
is_enabled = 1,
last_rotated_at = NOW()
SQL;
$statement = $pdo->prepare($sql);
$statement->execute([$userId, $callsign, $hash, $hint]);
return [
'username' => $callsign,
'password' => $password,
'password_hint' => $hint,
];
}
}
+33
Просмотреть файл
@@ -0,0 +1,33 @@
<?php
declare(strict_types=1);
namespace Xlx\Domain;
use PDO;
final class AccessExportService
{
public function activeUsersCsv(PDO $pdo): string
{
$rows = $pdo->query(
"SELECT u.callsign, d.dmr_id, c.username, c.dmr_password_hint, u.access_expires_at
FROM users u
JOIN dmr_ids d ON d.user_id = u.id
JOIN access_credentials c ON c.user_id = u.id
WHERE u.status = 'active'
AND c.is_enabled = 1
AND (u.access_expires_at IS NULL OR u.access_expires_at > NOW())
ORDER BY u.callsign"
)->fetchAll();
$handle = fopen('php://temp', 'r+');
fputcsv($handle, ['callsign', 'dmr_id', 'username', 'password_hint', 'access_expires_at']);
foreach ($rows as $row) {
fputcsv($handle, $row);
}
rewind($handle);
return (string) stream_get_contents($handle);
}
}
+47
Просмотреть файл
@@ -0,0 +1,47 @@
<?php
declare(strict_types=1);
namespace Xlx\Domain;
use PDO;
use RuntimeException;
use Xlx\Support\Config;
final class IdAllocator
{
public function __construct(private readonly Config $config)
{
}
public function allocate(PDO $pdo, int $userId): int
{
$existing = $pdo->prepare('SELECT dmr_id FROM dmr_ids WHERE user_id = ? LIMIT 1');
$existing->execute([$userId]);
$dmrId = $existing->fetchColumn();
if ($dmrId !== false) {
return (int) $dmrId;
}
$min = (int) $this->config->get('id_allocator.min_id', 9000001);
$max = (int) $this->config->get('id_allocator.max_id', 9099999);
$next = $pdo->query('SELECT COALESCE(MAX(dmr_id), 0) + 1 FROM dmr_ids')->fetchColumn();
$candidate = max($min, (int) $next);
while ($candidate <= $max) {
try {
$insert = $pdo->prepare('INSERT INTO dmr_ids (user_id, dmr_id, status) VALUES (?, ?, ?)');
$insert->execute([$userId, $candidate, 'active']);
return $candidate;
} catch (\PDOException $exception) {
if ($exception->getCode() !== '23000') {
throw $exception;
}
$candidate++;
}
}
throw new RuntimeException('DMR ID range is exhausted.');
}
}
+244
Просмотреть файл
@@ -0,0 +1,244 @@
<?php
declare(strict_types=1);
namespace Xlx\Domain;
use PDO;
use RuntimeException;
use Xlx\Support\Config;
final class PaymentService
{
public function __construct(
private readonly Config $config,
private readonly IdAllocator $idAllocator,
private readonly AccessCredentialService $accessCredentialService,
private readonly ?YooKassaClient $yooKassaClient = null,
) {
}
public function createPendingPayment(PDO $pdo, int $userId): ?array
{
$tariff = $pdo->query('SELECT * FROM tariffs WHERE is_active = 1 ORDER BY id ASC LIMIT 1')->fetch();
if (!$tariff) {
return null;
}
$provider = (string) $this->config->get('billing.mode', 'manual');
$statement = $pdo->prepare('INSERT INTO payments (user_id, tariff_id, amount, currency, provider, status) VALUES (?, ?, ?, ?, ?, ?)');
$statement->execute([
$userId,
$tariff['id'],
$tariff['price'],
$tariff['currency'],
$provider,
'pending',
]);
$paymentId = (int) $pdo->lastInsertId();
$result = [
'id' => $paymentId,
'amount' => (float) $tariff['price'],
'currency' => $tariff['currency'],
'tariff' => $tariff['name'],
'provider' => $provider,
];
if ($provider === 'yookassa' && $this->yooKassaClient !== null) {
$user = $this->findUser($pdo, $userId);
$created = $this->yooKassaClient->createPayment([
'id' => $paymentId,
'amount' => $tariff['price'],
'currency' => $tariff['currency'],
], $user, $tariff);
$confirmationUrl = $created['confirmation']['confirmation_url'] ?? null;
$pdo->prepare('UPDATE payments SET provider_payment_id = ?, confirmation_url = ? WHERE id = ?')
->execute([$created['id'] ?? null, $confirmationUrl, $paymentId]);
$result['provider_payment_id'] = $created['id'] ?? null;
$result['confirmation_url'] = $confirmationUrl;
}
if ((bool) $this->config->get('billing.manual_transfer_enabled', false)) {
$result['manual_transfer'] = [
'enabled' => true,
'instructions' => $this->config->get('billing.manual_transfer_instructions'),
];
}
return $result;
}
public function confirm(PDO $pdo, int $paymentId, ?int $adminUserId = null): array
{
$pdo->beginTransaction();
try {
$paymentStatement = $pdo->prepare(
'SELECT p.*, t.duration_days, u.callsign FROM payments p JOIN users u ON u.id = p.user_id LEFT JOIN tariffs t ON t.id = p.tariff_id WHERE p.id = ? FOR UPDATE'
);
$paymentStatement->execute([$paymentId]);
$payment = $paymentStatement->fetch();
if (!$payment) {
throw new RuntimeException('Payment was not found.');
}
if ($payment['status'] === 'paid') {
$pdo->commit();
return $this->currentAccess($pdo, (int) $payment['user_id']);
}
if ($payment['status'] !== 'pending') {
throw new RuntimeException('Payment is not pending.');
}
$durationDays = (int) ($payment['duration_days'] ?? $this->config->get('billing.default_duration_days', 30));
$expiresAt = (new \DateTimeImmutable('now'))->modify('+' . $durationDays . ' days')->format('Y-m-d H:i:s');
$pdo->prepare('UPDATE payments SET status = ?, paid_at = NOW() WHERE id = ?')->execute(['paid', $paymentId]);
$pdo->prepare('UPDATE users SET status = ?, access_expires_at = ? WHERE id = ?')->execute(['active', $expiresAt, $payment['user_id']]);
$dmrId = $this->idAllocator->allocate($pdo, (int) $payment['user_id']);
$credential = $this->accessCredentialService->enableForUser($pdo, (int) $payment['user_id'], $payment['callsign']);
$pdo->prepare(
'INSERT INTO audit_logs (actor_user_id, action, target_type, target_id, payload_json) VALUES (?, ?, ?, ?, ?)'
)->execute([
$adminUserId,
'payment.confirmed',
'payment',
$paymentId,
json_encode(['user_id' => (int) $payment['user_id'], 'dmr_id' => $dmrId], JSON_UNESCAPED_SLASHES),
]);
$pdo->commit();
return [
'user_id' => (int) $payment['user_id'],
'callsign' => $payment['callsign'],
'dmr_id' => $dmrId,
'access_expires_at' => $expiresAt,
'connection' => [
'server_host' => $this->config->get('xlx.server_host'),
'dmr_port' => (int) $this->config->get('xlx.dmr_port', 62030),
'default_module' => $this->config->get('xlx.default_module', 'A'),
'username' => $credential['username'],
'password' => $credential['password'],
],
];
} catch (\Throwable $throwable) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $throwable;
}
}
public function submitReceipt(PDO $pdo, int $paymentId, int $userId, string $receiptReference, ?string $comment): array
{
$payment = $pdo->prepare('SELECT id, user_id, status FROM payments WHERE id = ? AND user_id = ? LIMIT 1');
$payment->execute([$paymentId, $userId]);
$row = $payment->fetch();
if (!$row) {
throw new RuntimeException('Payment was not found for this user.');
}
if ($row['status'] !== 'pending') {
throw new RuntimeException('Only pending payments can receive receipts.');
}
if (trim($receiptReference) === '') {
throw new RuntimeException('Receipt reference is required.');
}
$statement = $pdo->prepare(
'INSERT INTO payment_receipts (payment_id, user_id, receipt_reference, comment) VALUES (?, ?, ?, ?)'
);
$statement->execute([$paymentId, $userId, trim($receiptReference), $comment]);
return [
'receipt_id' => (int) $pdo->lastInsertId(),
'payment_id' => $paymentId,
'status' => 'pending',
];
}
public function approveReceipt(PDO $pdo, int $receiptId, ?int $adminUserId = null): array
{
$receipt = $pdo->prepare('SELECT * FROM payment_receipts WHERE id = ? LIMIT 1');
$receipt->execute([$receiptId]);
$row = $receipt->fetch();
if (!$row) {
throw new RuntimeException('Receipt was not found.');
}
if ($row['status'] !== 'pending') {
throw new RuntimeException('Receipt is not pending.');
}
$result = $this->confirm($pdo, (int) $row['payment_id'], $adminUserId);
$pdo->prepare('UPDATE payment_receipts SET status = ?, reviewed_by_user_id = ?, reviewed_at = NOW() WHERE id = ?')
->execute(['approved', $adminUserId, $receiptId]);
return $result;
}
public function confirmProviderPayment(PDO $pdo, string $providerPaymentId): array
{
if ($this->yooKassaClient !== null) {
$providerPayment = $this->yooKassaClient->fetchPayment($providerPaymentId);
if (($providerPayment['status'] ?? null) !== 'succeeded' || ($providerPayment['paid'] ?? false) !== true) {
throw new RuntimeException('YooKassa payment is not succeeded.');
}
}
$statement = $pdo->prepare('SELECT id FROM payments WHERE provider = ? AND provider_payment_id = ? LIMIT 1');
$statement->execute(['yookassa', $providerPaymentId]);
$paymentId = $statement->fetchColumn();
if ($paymentId === false) {
throw new RuntimeException('Local payment was not found.');
}
return $this->confirm($pdo, (int) $paymentId);
}
private function findUser(PDO $pdo, int $userId): array
{
$statement = $pdo->prepare('SELECT id, callsign, email FROM users WHERE id = ? LIMIT 1');
$statement->execute([$userId]);
$user = $statement->fetch();
if (!$user) {
throw new RuntimeException('User was not found.');
}
return $user;
}
private function currentAccess(PDO $pdo, int $userId): array
{
$statement = $pdo->prepare(
'SELECT u.id AS user_id, u.callsign, u.access_expires_at, d.dmr_id, c.username, c.dmr_password_hint
FROM users u
LEFT JOIN dmr_ids d ON d.user_id = u.id
LEFT JOIN access_credentials c ON c.user_id = u.id
WHERE u.id = ?
LIMIT 1'
);
$statement->execute([$userId]);
$row = $statement->fetch();
if (!$row) {
throw new RuntimeException('User was not found.');
}
return [
'user_id' => (int) $row['user_id'],
'callsign' => $row['callsign'],
'dmr_id' => $row['dmr_id'] !== null ? (int) $row['dmr_id'] : null,
'access_expires_at' => $row['access_expires_at'],
'connection' => [
'server_host' => $this->config->get('xlx.server_host'),
'dmr_port' => (int) $this->config->get('xlx.dmr_port', 62030),
'default_module' => $this->config->get('xlx.default_module', 'A'),
'username' => $row['username'],
'password_hint' => $row['dmr_password_hint'],
],
];
}
}
+122
Просмотреть файл
@@ -0,0 +1,122 @@
<?php
declare(strict_types=1);
namespace Xlx\Domain;
use PDO;
use RuntimeException;
use Xlx\Support\Config;
use Xlx\Support\Security;
final class RegistrationService
{
public function __construct(
private readonly Config $config,
private readonly PaymentService $paymentService,
) {
}
public function checkCallsign(PDO $pdo, string $callsign): array
{
$callsign = Security::normalizeCallsign($callsign);
if (!Security::isValidCallsign($callsign)) {
return [
'callsign' => $callsign,
'available' => false,
'status' => 'invalid_format',
'message' => 'Invalid callsign format.',
];
}
$statement = $pdo->prepare('SELECT id FROM users WHERE callsign = ? LIMIT 1');
$statement->execute([$callsign]);
return [
'callsign' => $callsign,
'available' => $statement->fetch() === false,
'status' => 'internal_registry',
'message' => 'Checked against local registered users.',
];
}
public function register(PDO $pdo, array $input): array
{
$callsign = Security::normalizeCallsign((string) ($input['callsign'] ?? ''));
$email = strtolower(trim((string) ($input['email'] ?? '')));
$password = (string) ($input['password'] ?? '');
if (!Security::isValidCallsign($callsign)) {
throw new RuntimeException('Invalid callsign format.');
}
if (!filter_var($email, FILTER_VALIDATE_EMAIL)) {
throw new RuntimeException('Invalid email address.');
}
if (strlen($password) < (int) $this->config->get('security.password_min_length', 10)) {
throw new RuntimeException('Password is too short.');
}
$callsignCheck = $this->checkCallsign($pdo, $callsign);
if (!$callsignCheck['available']) {
throw new RuntimeException('Callsign is already registered.');
}
$emailCheck = $pdo->prepare('SELECT id FROM users WHERE email = ? LIMIT 1');
$emailCheck->execute([$email]);
if ($emailCheck->fetch()) {
throw new RuntimeException('Email is already registered.');
}
$verification = [
'status' => 'verified',
'source' => 'internal_registry',
'payload' => [
'message' => 'Callsign is not present in local registered users.',
],
];
$status = 'payment_pending';
$pdo->beginTransaction();
try {
$insertUser = $pdo->prepare(
'INSERT INTO users (callsign, email, password_hash, status) VALUES (?, ?, ?, ?)'
);
$insertUser->execute([$callsign, $email, password_hash($password, PASSWORD_DEFAULT), $status]);
$userId = (int) $pdo->lastInsertId();
$insertCheck = $pdo->prepare(
'INSERT INTO callsign_checks (user_id, callsign, source, status, response_json, checked_at) VALUES (?, ?, ?, ?, ?, NOW())'
);
$insertCheck->execute([
$userId,
$callsign,
$verification['source'],
$verification['status'],
json_encode($verification['payload'], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES),
]);
$payment = null;
if ($status === 'payment_pending') {
$payment = $this->paymentService->createPendingPayment($pdo, $userId);
}
$pdo->commit();
return [
'user_id' => $userId,
'callsign' => $callsign,
'status' => $status,
'verification' => [
'status' => $verification['status'],
'source' => $verification['source'],
],
'payment' => $payment,
];
} catch (\Throwable $throwable) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
throw $throwable;
}
}
}
+153
Просмотреть файл
@@ -0,0 +1,153 @@
<?php
declare(strict_types=1);
namespace Xlx\Domain;
use PDO;
use RuntimeException;
use Xlx\Support\Config;
final class XlxdSettingsService
{
private const FIELD_MAP = [
'reflector_name' => 'XLX_REFLECTOR_NAME',
'server_host' => 'XLX_SERVER_HOST',
'sysop_callsign' => 'XLX_SYSOP_CALLSIGN',
'sysop_email' => 'XLX_SYSOP_EMAIL',
'country' => 'XLX_COUNTRY',
'dashboard_port' => 'XLX_DASHBOARD_PORT',
'dmr_port' => 'XLX_DMR_PORT',
'ysf_port' => 'XLX_YSF_PORT',
'default_module' => 'XLX_DEFAULT_MODULE',
'modules' => 'XLX_MODULES',
'install_path' => 'XLX_INSTALL_PATH',
'source_path' => 'XLX_SOURCE_PATH',
'log_path' => 'XLX_LOG_PATH',
'service_name' => 'XLX_SERVICE_NAME',
'repo_url' => 'XLX_REPO_URL',
];
public function __construct(private readonly Config $config)
{
}
public function current(PDO $pdo): array
{
$settings = [
'reflector_name' => $this->config->get('xlx.reflector_name', 'XLX000'),
'server_host' => $this->config->get('xlx.server_host', 'xlx.example.com'),
'sysop_callsign' => $this->config->get('xlx.sysop_callsign', 'N0CALL'),
'sysop_email' => $this->config->get('xlx.sysop_email', 'admin@example.com'),
'country' => $this->config->get('xlx.country', 'RU'),
'dashboard_port' => (string) $this->config->get('xlx.dashboard_port', 8080),
'dmr_port' => (string) $this->config->get('xlx.dmr_port', 62030),
'ysf_port' => (string) $this->config->get('xlx.ysf_port', 42000),
'default_module' => $this->config->get('xlx.default_module', 'A'),
'modules' => $this->config->get('xlx.modules', 'ABCDEFGHIJKLMNOPQRSTUVWXYZ'),
'install_path' => $this->config->get('xlx.install_path', '/xlxd'),
'source_path' => $this->config->get('xlx.source_path', '/usr/src/xlxd'),
'log_path' => $this->config->get('xlx.log_path', '/var/log/xlxd.log'),
'service_name' => $this->config->get('xlx.service_name', 'xlxd'),
'repo_url' => 'https://github.com/LX3JL/xlxd.git',
];
$rows = $pdo->query("SELECT setting_key, setting_value FROM server_settings WHERE setting_key LIKE 'xlxd.%'")->fetchAll();
foreach ($rows as $row) {
$key = substr((string) $row['setting_key'], 5);
if (array_key_exists($key, $settings)) {
$settings[$key] = (string) $row['setting_value'];
}
}
return $settings;
}
public function save(PDO $pdo, array $input, bool $apply): array
{
$settings = $this->current($pdo);
foreach (self::FIELD_MAP as $key => $_) {
if (array_key_exists($key, $input)) {
$settings[$key] = $this->sanitize($key, (string) $input[$key]);
}
}
$statement = $pdo->prepare(
'INSERT INTO server_settings (setting_key, setting_value) VALUES (?, ?)
ON DUPLICATE KEY UPDATE setting_value = VALUES(setting_value), updated_at = NOW()'
);
foreach ($settings as $key => $value) {
$statement->execute(['xlxd.' . $key, $value]);
}
$env = $this->renderEnv($settings);
$applied = false;
$message = 'Settings saved to database.';
if ($apply) {
$path = rtrim((string) $this->config->get('xlx.config_path', '/etc/xlx'), '/') . '/xlxd.env';
$this->writeEnvFile($path, $env);
$applied = true;
$message = 'Settings saved and xlxd.env updated.';
}
return [
'settings' => $settings,
'env' => $env,
'applied' => $applied,
'message' => $message,
];
}
public function renderEnv(array $settings): string
{
$lines = [];
foreach (self::FIELD_MAP as $key => $envKey) {
$lines[] = $envKey . '=' . $this->escapeEnv((string) ($settings[$key] ?? ''));
}
return implode("\n", $lines) . "\n";
}
private function sanitize(string $key, string $value): string
{
$value = trim($value);
if (in_array($key, ['dashboard_port', 'dmr_port', 'ysf_port'], true)) {
$port = (int) $value;
if ($port < 1 || $port > 65535) {
throw new RuntimeException('Invalid port for ' . $key . '.');
}
return (string) $port;
}
if ($key === 'reflector_name' && !preg_match('/^XLX[A-Z0-9]{3}$/', $value)) {
throw new RuntimeException('Reflector name must look like XLX138.');
}
if ($key === 'default_module' && !preg_match('/^[A-Z]$/', $value)) {
throw new RuntimeException('Default module must be A-Z.');
}
if ($key === 'modules' && !preg_match('/^[A-Z]+$/', $value)) {
throw new RuntimeException('Modules must contain only letters A-Z.');
}
return $value;
}
private function writeEnvFile(string $path, string $env): void
{
$directory = dirname($path);
if (!is_dir($directory) && !mkdir($directory, 0755, true) && !is_dir($directory)) {
throw new RuntimeException('Cannot create config directory: ' . $directory);
}
if (file_put_contents($path, $env, LOCK_EX) === false) {
throw new RuntimeException('Cannot write xlxd env file: ' . $path);
}
}
private function escapeEnv(string $value): string
{
if ($value === '' || preg_match('/\s/', $value) || strpbrk($value, '"\'\\$`') !== false) {
return '"' . str_replace(['\\', '"', '$', '`'], ['\\\\', '\\"', '\\$', '\\`'], $value) . '"';
}
return $value;
}
}
+91
Просмотреть файл
@@ -0,0 +1,91 @@
<?php
declare(strict_types=1);
namespace Xlx\Domain;
use RuntimeException;
use Xlx\Support\Config;
final class YooKassaClient
{
private const API_URL = 'https://api.yookassa.ru/v3';
public function __construct(private readonly Config $config)
{
}
public function createPayment(array $payment, array $user, array $tariff): array
{
$payload = [
'amount' => [
'value' => number_format((float) $payment['amount'], 2, '.', ''),
'currency' => $payment['currency'],
],
'capture' => (bool) $this->config->get('billing.yookassa.capture', true),
'confirmation' => [
'type' => 'redirect',
'return_url' => $this->config->get('billing.yookassa.return_url'),
],
'description' => sprintf('XLX доступ для %s, платеж #%d', $user['callsign'], $payment['id']),
'metadata' => [
'payment_id' => (string) $payment['id'],
'user_id' => (string) $user['id'],
'callsign' => $user['callsign'],
'tariff_id' => (string) $tariff['id'],
],
];
return $this->request('POST', '/payments', $payload, 'xlx-payment-' . $payment['id']);
}
public function fetchPayment(string $providerPaymentId): array
{
return $this->request('GET', '/payments/' . rawurlencode($providerPaymentId));
}
private function request(string $method, string $path, ?array $payload = null, ?string $idempotenceKey = null): array
{
$shopId = (string) $this->config->get('billing.yookassa.shop_id');
$secretKey = (string) $this->config->get('billing.yookassa.secret_key');
if ($shopId === '' || $shopId === 'change_me' || $secretKey === '' || $secretKey === 'change_me') {
throw new RuntimeException('YooKassa credentials are not configured.');
}
$headers = [
'Authorization: Basic ' . base64_encode($shopId . ':' . $secretKey),
'Content-Type: application/json',
'Accept: application/json',
];
if ($idempotenceKey !== null) {
$headers[] = 'Idempotence-Key: ' . substr($idempotenceKey, 0, 64);
}
$options = [
'http' => [
'method' => $method,
'header' => implode("\r\n", $headers) . "\r\n",
'ignore_errors' => true,
'timeout' => 15,
],
];
if ($payload !== null) {
$options['http']['content'] = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
}
$body = @file_get_contents(self::API_URL . $path, false, stream_context_create($options));
if ($body === false) {
throw new RuntimeException('YooKassa request failed.');
}
$decoded = json_decode($body, true);
if (!is_array($decoded)) {
throw new RuntimeException('YooKassa returned invalid JSON.');
}
if (isset($decoded['type']) && $decoded['type'] === 'error') {
throw new RuntimeException((string) ($decoded['description'] ?? 'YooKassa error.'));
}
return $decoded;
}
}
+25
Просмотреть файл
@@ -0,0 +1,25 @@
<?php
declare(strict_types=1);
namespace Xlx\Support;
final class Config
{
public function __construct(private readonly array $values)
{
}
public function get(string $key, mixed $default = null): mixed
{
$value = $this->values;
foreach (explode('.', $key) as $part) {
if (!is_array($value) || !array_key_exists($part, $value)) {
return $default;
}
$value = $value[$part];
}
return $value;
}
}
+32
Просмотреть файл
@@ -0,0 +1,32 @@
<?php
declare(strict_types=1);
namespace Xlx\Support;
use PDO;
final class Database
{
public function __construct(private readonly Config $config)
{
}
public function pdo(): PDO
{
$charset = $this->config->get('database.charset', 'utf8mb4');
$dsn = sprintf(
'mysql:host=%s;port=%d;dbname=%s;charset=%s',
$this->config->get('database.host', '127.0.0.1'),
(int) $this->config->get('database.port', 3306),
$this->config->get('database.name'),
$charset
);
return new PDO($dsn, (string) $this->config->get('database.user'), (string) $this->config->get('database.password'), [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
PDO::ATTR_EMULATE_PREPARES => false,
]);
}
}
+23
Просмотреть файл
@@ -0,0 +1,23 @@
<?php
declare(strict_types=1);
namespace Xlx\Support;
final class Input
{
public static function json(): array
{
$raw = file_get_contents('php://input');
if ($raw === false || trim($raw) === '') {
return [];
}
$decoded = json_decode($raw, true);
if (!is_array($decoded)) {
Response::error('Invalid JSON request body.', 400);
}
return $decoded;
}
}
+21
Просмотреть файл
@@ -0,0 +1,21 @@
<?php
declare(strict_types=1);
namespace Xlx\Support;
final class Response
{
public static function json(array $payload, int $status = 200): never
{
http_response_code($status);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRETTY_PRINT);
exit;
}
public static function error(string $message, int $status = 400, array $extra = []): never
{
self::json(['ok' => false, 'error' => $message] + $extra, $status);
}
}
+46
Просмотреть файл
@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
namespace Xlx\Support;
final class Security
{
public static function normalizeCallsign(string $callsign): string
{
return strtoupper(trim($callsign));
}
public static function isValidCallsign(string $callsign): bool
{
return (bool) preg_match('/^[A-Z0-9][A-Z0-9\/-]{2,18}$/', $callsign);
}
public static function randomPassword(int $length): string
{
$alphabet = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz23456789';
$password = '';
$max = strlen($alphabet) - 1;
for ($i = 0; $i < $length; $i++) {
$password .= $alphabet[random_int(0, $max)];
}
return $password;
}
public static function passwordHint(string $password): string
{
return substr($password, 0, 3) . '...' . substr($password, -3);
}
public static function requireAdminToken(Config $config): void
{
$expected = (string) $config->get('admin.api_token', '');
$actual = $_SERVER['HTTP_X_ADMIN_TOKEN'] ?? '';
if ($expected === '' || !hash_equals($expected, $actual)) {
Response::error('Admin token is missing or invalid.', 401);
}
}
}