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

Этот коммит содержится в:
2026-05-18 06:38:31 +09:00
родитель 53bcc6567a
Коммит 7808cc4158
36 изменённых файлов: 2461 добавлений и 78 удалений
+133 -48
Просмотреть файл
@@ -1,60 +1,145 @@
# XLX Server # 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. Для полной установки панели, базы, YooKassa-заготовки и самого `xlxd` используй общий установщик.
2. **Access Gateway** — слой контроля доступа пользователей, паролей и разрешённых подключений.
3. **Web Panel** — личный кабинет и админка. ### Установка без домена
4. **Billing** — платная регистрация и продление доступа.
5. **Callsign Verifier** — проверка позывных по базе радиолюбителей. ```bash
6. **ID Allocator** — автоматическая выдача уникального ID. sudo apt update
7. **Installer/Updater** — установка, обновления, бэкапы и восстановление. 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.
## Предполагаемый стек Следующий шаг - UDP access gateway для жесткой проверки оплаченного доступа перед `xlxd`.
- 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`.
+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); 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 [ return [
'app' => [ 'app' => [
'name' => 'XLX Server', 'name' => 'XLX Server',
'env' => 'production', 'env' => getenv('XLX_APP_ENV') ?: 'production',
'debug' => false, 'debug' => (bool) (getenv('XLX_DEBUG') ?: false),
'base_url' => 'https://xlx.example.com', 'base_url' => $baseUrl,
'timezone' => 'Europe/Amsterdam', 'timezone' => getenv('XLX_TIMEZONE') ?: 'Europe/Moscow',
], ],
'database' => [ 'database' => [
'host' => '127.0.0.1', 'host' => getenv('XLX_DB_HOST') ?: '127.0.0.1',
'port' => 3306, 'port' => (int) (getenv('XLX_DB_PORT') ?: 3306),
'name' => 'xlx_server', 'name' => getenv('XLX_DB_NAME') ?: 'xlx_server',
'user' => 'xlx_user', 'user' => getenv('XLX_DB_USER') ?: 'xlx_user',
'password' => 'change_me', 'password' => getenv('XLX_DB_PASSWORD') ?: 'change_me',
'charset' => 'utf8mb4', 'charset' => 'utf8mb4',
], ],
'xlx' => [ 'xlx' => [
'reflector_name' => 'XLX000', 'reflector_name' => getenv('XLX_REFLECTOR_NAME') ?: 'XLX000',
'server_host' => 'xlx.example.com', 'server_host' => getenv('XLX_PUBLIC_HOST') ?: $host,
'dashboard_port' => 8080, 'sysop_callsign' => getenv('XLX_SYSOP_CALLSIGN') ?: 'N0CALL',
'dmr_port' => 62030, 'sysop_email' => getenv('XLX_SYSOP_EMAIL') ?: 'admin@' . $hostWithoutPort,
'default_module' => 'A', 'country' => getenv('XLX_COUNTRY') ?: 'RU',
'config_path' => '/etc/xlx', 'dashboard_port' => (int) (getenv('XLX_DASHBOARD_PORT') ?: 8080),
'service_name' => 'xlxd', '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' => [ 'id_allocator' => [
'min_id' => 9000001, 'min_id' => (int) (getenv('XLX_MIN_ID') ?: 9000001),
'max_id' => 9099999, 'max_id' => (int) (getenv('XLX_MAX_ID') ?: 9099999),
], ],
'billing' => [ 'billing' => [
'mode' => 'manual', 'mode' => getenv('XLX_BILLING_MODE') ?: 'yookassa',
'currency' => 'RUB', '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' => [ 'security' => [
'session_name' => 'xlx_session', 'session_name' => getenv('XLX_SESSION_NAME') ?: 'xlx_session',
'password_min_length' => 10, 'password_min_length' => (int) (getenv('XLX_PASSWORD_MIN_LENGTH') ?: 10),
'dmr_password_length' => 16, '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 ( CREATE TABLE IF NOT EXISTS tariffs (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, 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, price DECIMAL(10,2) NOT NULL DEFAULT 0.00,
currency CHAR(3) NOT NULL DEFAULT 'RUB', currency CHAR(3) NOT NULL DEFAULT 'RUB',
duration_days INT UNSIGNED NOT NULL DEFAULT 30, 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', currency CHAR(3) NOT NULL DEFAULT 'RUB',
provider VARCHAR(64) NOT NULL DEFAULT 'manual', provider VARCHAR(64) NOT NULL DEFAULT 'manual',
provider_payment_id VARCHAR(190) NULL, provider_payment_id VARCHAR(190) NULL,
confirmation_url TEXT NULL,
status ENUM('pending', 'paid', 'failed', 'cancelled', 'refunded') NOT NULL DEFAULT 'pending', status ENUM('pending', 'paid', 'failed', 'cancelled', 'refunded') NOT NULL DEFAULT 'pending',
paid_at DATETIME NULL, paid_at DATETIME NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, 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 CONSTRAINT fk_payments_tariff FOREIGN KEY (tariff_id) REFERENCES tariffs(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; ) 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 ( CREATE TABLE IF NOT EXISTS access_credentials (
id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY,
user_id BIGINT UNSIGNED NOT NULL UNIQUE, 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 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; ) 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) INSERT INTO tariffs (name, price, currency, duration_days)
VALUES ('Стартовый доступ 30 дней', 300.00, 'RUB', 30) 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 ### 2.3 Callsign Verifier
Модуль проверки позывного по внешним источникам. Модуль проверки позывного по внутренней базе уже зарегистрированных пользователей.
MVP-логика: MVP-логика:
- пользователь вводит позывной; - пользователь вводит позывной;
- система нормализует его в верхний регистр; - система нормализует его в верхний регистр;
- проверяет формат; - проверяет формат;
- делает запрос к настроенной базе; - проверяет, что позывной еще не занят в таблице пользователей;
- сохраняет результат проверки; - сохраняет результат проверки;
- при ошибке отправляет заявку в ручную модерацию. - при ошибке отправляет заявку в ручную модерацию.
Важно: внешний источник проверки должен быть заменяемым адаптером, чтобы не прибивать проект гвоздями к одной базе. Важно: уникальность дополнительно защищена индексом `users.callsign`, поэтому дубль не пройдет даже при одновременной регистрации.
### 2.4 ID Allocator ### 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; - [ ] автоматическая выдача 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);
}
}
}