Enhance Docker setup and user notification features
CI / Backend (build + test) (push) Failing after 1m35s
CI / Frontend (lint + typecheck + build) (push) Successful in 35s

- Updated docker-compose.yml to include environment variables and health checks for the app service.
- Modified Dockerfile to install curl for health checks and adjusted the build process for backend services.
- Improved user notification handling in activation, blocking, and config revocation commands by integrating Telegram notifications.
- Added new test cases to validate the updated command handlers and Telegram notifier functionality.
- Enhanced documentation to reflect the new Telegram bot features and user management improvements.
This commit is contained in:
Leonid Pershin
2026-07-02 01:16:53 +03:00
parent 7b6fe9ad78
commit ed07221ca5
15 changed files with 404 additions and 34 deletions
+65
View File
@@ -0,0 +1,65 @@
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
backend:
name: Backend (build + test)
runs-on: ubuntu-latest
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore
run: dotnet restore PnvPanel.slnx
- name: Build
run: dotnet build PnvPanel.slnx -c Release --no-restore
# Docker доступен на ubuntu-latest — интеграционные тесты (Testcontainers.PostgreSql) реально
# поднимают Postgres и проверяют HTTP-контракт + pg_advisory_xact_lock под нагрузкой.
- name: Test
run: dotnet test PnvPanel.slnx -c Release --no-build --logger "console;verbosity=normal"
frontend:
name: Frontend (lint + typecheck + build)
runs-on: ubuntu-latest
defaults:
run:
working-directory: frontend
steps:
- uses: actions/checkout@v4
- name: Enable corepack
run: corepack enable
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: '22'
- name: Setup pnpm
run: corepack prepare pnpm@11.9.0 --activate
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Lint
run: pnpm lint
- name: Typecheck
run: pnpm typecheck
- name: Build
run: pnpm build
+8 -3
View File
@@ -12,13 +12,13 @@ RUN pnpm build
# ── Stage 2: publish бэкенда, статика фронта в wwwroot ──────────────────── # ── Stage 2: publish бэкенда, статика фронта в wwwroot ────────────────────
FROM mcr.microsoft.com/dotnet/sdk:10.0 AS backend FROM mcr.microsoft.com/dotnet/sdk:10.0 AS backend
WORKDIR /src WORKDIR /src
# Сначала манифесты для кэша restore # Сначала манифесты для кэша restore (только цепочка Api → ... → Domain, без tests/)
COPY backend/PnvPanel.sln backend/Directory.Build.props backend/Directory.Packages.props ./backend/ COPY backend/Directory.Build.props backend/Directory.Packages.props ./backend/
COPY backend/src/PnvPanel.Domain/PnvPanel.Domain.csproj ./backend/src/PnvPanel.Domain/ COPY backend/src/PnvPanel.Domain/PnvPanel.Domain.csproj ./backend/src/PnvPanel.Domain/
COPY backend/src/PnvPanel.Application/PnvPanel.Application.csproj ./backend/src/PnvPanel.Application/ COPY backend/src/PnvPanel.Application/PnvPanel.Application.csproj ./backend/src/PnvPanel.Application/
COPY backend/src/PnvPanel.Infrastructure/PnvPanel.Infrastructure.csproj ./backend/src/PnvPanel.Infrastructure/ COPY backend/src/PnvPanel.Infrastructure/PnvPanel.Infrastructure.csproj ./backend/src/PnvPanel.Infrastructure/
COPY backend/src/PnvPanel.Api/PnvPanel.Api.csproj ./backend/src/PnvPanel.Api/ COPY backend/src/PnvPanel.Api/PnvPanel.Api.csproj ./backend/src/PnvPanel.Api/
RUN dotnet restore backend/PnvPanel.sln RUN dotnet restore backend/src/PnvPanel.Api/PnvPanel.Api.csproj
# Исходники бэкенда # Исходники бэкенда
COPY backend/ ./backend/ COPY backend/ ./backend/
# Сид каталога приложений (PnvPanel.Api.csproj ссылается на него через ../../../seed/) # Сид каталога приложений (PnvPanel.Api.csproj ссылается на него через ../../../seed/)
@@ -30,8 +30,13 @@ RUN dotnet publish backend/src/PnvPanel.Api/PnvPanel.Api.csproj -c Release -o /a
# ── Stage 3: runtime ────────────────────────────────────────────────────── # ── Stage 3: runtime ──────────────────────────────────────────────────────
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app WORKDIR /app
# curl — только для HEALTHCHECK в docker-compose (GET /health), в образе его нет по умолчанию.
RUN apt-get update && apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
ENV ASPNETCORE_ENVIRONMENT=Production \ ENV ASPNETCORE_ENVIRONMENT=Production \
ASPNETCORE_HTTP_PORTS=8080 ASPNETCORE_HTTP_PORTS=8080
EXPOSE 8080 EXPOSE 8080
COPY --from=backend /app/publish ./ COPY --from=backend /app/publish ./
HEALTHCHECK --interval=15s --timeout=5s --start-period=20s --retries=5 \
CMD curl -f http://localhost:8080/health || exit 1
ENTRYPOINT ["dotnet", "PnvPanel.Api.dll"] ENTRYPOINT ["dotnet", "PnvPanel.Api.dll"]
@@ -262,7 +262,7 @@ public sealed class PnvBotUpdateHandler(
{ {
const string text = "Привет! Это бот PnvPanel.\n\n" const string text = "Привет! Это бот PnvPanel.\n\n"
+ "/configs — мои конфиги\n" + "/configs — мои конфиги\n"
+ "/login — войти на сайт без пароля\n" + "Вход без пароля запускается кнопкой «Войти через Telegram» на сайте — бот пришлёт запрос на подтверждение.\n"
+ "/unlink — отвязать Telegram\n" + "/unlink — отвязать Telegram\n"
+ "/help — эта справка"; + "/help — эта справка";
await botClient.SendMessage(chatId, text, cancellationToken: cancellationToken); await botClient.SendMessage(chatId, text, cancellationToken: cancellationToken);
@@ -9,7 +9,8 @@ using PnvPanel.Domain.Activation;
namespace PnvPanel.Application.Admin.Activation; namespace PnvPanel.Application.Admin.Activation;
public sealed class ApproveActivationCommandHandler( public sealed class ApproveActivationCommandHandler(
IAppDbContext dbContext, IIdentityService identityService, IRealtimeNotifier notifier, ICurrentUser currentUser) IAppDbContext dbContext, IIdentityService identityService, IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
: ICommandHandler<ApproveActivationCommand, Result> : ICommandHandler<ApproveActivationCommand, Result>
{ {
public async Task<Result> Handle(ApproveActivationCommand command, CancellationToken cancellationToken) public async Task<Result> Handle(ApproveActivationCommand command, CancellationToken cancellationToken)
@@ -33,6 +34,7 @@ public sealed class ApproveActivationCommandHandler(
return activateResult; return activateResult;
await notifier.NotifyUserActivatedAsync(request.UserId, cancellationToken); await notifier.NotifyUserActivatedAsync(request.UserId, cancellationToken);
await telegramNotifier.NotifyUserAsync(request.UserId, "✅ Ваш аккаунт активирован администратором.", cancellationToken);
return Result.Success(); return Result.Success();
} }
} }
@@ -10,7 +10,7 @@ namespace PnvPanel.Application.Admin.Users;
/// <summary>Блокировка гасит все активные конфиги в 3x-ui (см. architecture.md).</summary> /// <summary>Блокировка гасит все активные конфиги в 3x-ui (см. architecture.md).</summary>
public sealed class BlockUserCommandHandler( public sealed class BlockUserCommandHandler(
IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway, IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway,
IRealtimeNotifier notifier, ICurrentUser currentUser) IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
: ICommandHandler<BlockUserCommand, Result> : ICommandHandler<BlockUserCommand, Result>
{ {
public async Task<Result> Handle(BlockUserCommand command, CancellationToken cancellationToken) public async Task<Result> Handle(BlockUserCommand command, CancellationToken cancellationToken)
@@ -44,6 +44,8 @@ public sealed class BlockUserCommandHandler(
dbContext.AuditLogs.Add(AuditLog.Create( dbContext.AuditLogs.Add(AuditLog.Create(
currentUser.UserId, "UserBlocked", "User", command.UserId.ToString(), metadata: null, AuditSource.Web)); currentUser.UserId, "UserBlocked", "User", command.UserId.ToString(), metadata: null, AuditSource.Web));
await telegramNotifier.NotifyUserAsync(command.UserId, "⛔ Ваш аккаунт заблокирован администратором.", cancellationToken);
return Result.Success(); return Result.Success();
} }
} }
@@ -9,7 +9,8 @@ using PnvPanel.Domain.Configs;
namespace PnvPanel.Application.Admin.Users; namespace PnvPanel.Application.Admin.Users;
public sealed class ForceRevokeConfigCommandHandler( public sealed class ForceRevokeConfigCommandHandler(
IAppDbContext dbContext, IXuiPanelGateway gateway, IRealtimeNotifier notifier, ICurrentUser currentUser) IAppDbContext dbContext, IXuiPanelGateway gateway, IRealtimeNotifier notifier,
ITelegramNotifier telegramNotifier, ICurrentUser currentUser)
: ICommandHandler<ForceRevokeConfigCommand, Result> : ICommandHandler<ForceRevokeConfigCommand, Result>
{ {
public async Task<Result> Handle(ForceRevokeConfigCommand command, CancellationToken cancellationToken) public async Task<Result> Handle(ForceRevokeConfigCommand command, CancellationToken cancellationToken)
@@ -35,6 +36,9 @@ public sealed class ForceRevokeConfigCommandHandler(
dbContext.AuditLogs.Add(AuditLog.Create( dbContext.AuditLogs.Add(AuditLog.Create(
currentUser.UserId, "ConfigForceRevoked", "VpnConfig", config.Id.ToString(), metadata: null, AuditSource.Web)); currentUser.UserId, "ConfigForceRevoked", "VpnConfig", config.Id.ToString(), metadata: null, AuditSource.Web));
await telegramNotifier.NotifyUserAsync(
config.UserId, $"⚠️ Администратор отозвал ваш конфиг «{config.Label ?? config.ClientEmail}».", cancellationToken);
return Result.Success(); return Result.Success();
} }
} }
@@ -14,6 +14,7 @@ public class ApproveActivationCommandHandlerTests
{ {
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>(); private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>(); private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>(); private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
[Fact] [Fact]
@@ -28,7 +29,7 @@ public class ApproveActivationCommandHandlerTests
_currentUser.UserId.Returns(adminId); _currentUser.UserId.Returns(adminId);
_identityService.ActivateUserAsync(request.UserId, adminId, Arg.Any<CancellationToken>()).Returns(Result.Success()); _identityService.ActivateUserAsync(request.UserId, adminId, Arg.Any<CancellationToken>()).Returns(Result.Success());
var handler = new ApproveActivationCommandHandler(dbContext, _identityService, _notifier, _currentUser); var handler = new ApproveActivationCommandHandler(dbContext, _identityService, _notifier, _telegramNotifier, _currentUser);
var result = await handler.Handle(new ApproveActivationCommand(request.Id), CancellationToken.None); var result = await handler.Handle(new ApproveActivationCommand(request.Id), CancellationToken.None);
@@ -43,7 +44,7 @@ public class ApproveActivationCommandHandlerTests
using var dbContext = InMemoryDbContextFactory.Create(); using var dbContext = InMemoryDbContextFactory.Create();
_currentUser.UserId.Returns(Guid.NewGuid()); _currentUser.UserId.Returns(Guid.NewGuid());
var handler = new ApproveActivationCommandHandler(dbContext, _identityService, _notifier, _currentUser); var handler = new ApproveActivationCommandHandler(dbContext, _identityService, _notifier, _telegramNotifier, _currentUser);
var result = await handler.Handle(new ApproveActivationCommand(Guid.NewGuid()), CancellationToken.None); var result = await handler.Handle(new ApproveActivationCommand(Guid.NewGuid()), CancellationToken.None);
@@ -62,7 +63,7 @@ public class ApproveActivationCommandHandlerTests
_currentUser.UserId.Returns(Guid.NewGuid()); _currentUser.UserId.Returns(Guid.NewGuid());
var handler = new ApproveActivationCommandHandler(dbContext, _identityService, _notifier, _currentUser); var handler = new ApproveActivationCommandHandler(dbContext, _identityService, _notifier, _telegramNotifier, _currentUser);
var result = await handler.Handle(new ApproveActivationCommand(request.Id), CancellationToken.None); var result = await handler.Handle(new ApproveActivationCommand(request.Id), CancellationToken.None);
@@ -83,7 +84,7 @@ public class ApproveActivationCommandHandlerTests
var failure = Error.NotFound("User.NotFound", "Пользователь не найден."); var failure = Error.NotFound("User.NotFound", "Пользователь не найден.");
_identityService.ActivateUserAsync(request.UserId, adminId, Arg.Any<CancellationToken>()).Returns(Result.Failure(failure)); _identityService.ActivateUserAsync(request.UserId, adminId, Arg.Any<CancellationToken>()).Returns(Result.Failure(failure));
var handler = new ApproveActivationCommandHandler(dbContext, _identityService, _notifier, _currentUser); var handler = new ApproveActivationCommandHandler(dbContext, _identityService, _notifier, _telegramNotifier, _currentUser);
var result = await handler.Handle(new ApproveActivationCommand(request.Id), CancellationToken.None); var result = await handler.Handle(new ApproveActivationCommand(request.Id), CancellationToken.None);
@@ -15,6 +15,7 @@ public class BlockUserCommandHandlerTests
private readonly IIdentityService _identityService = Substitute.For<IIdentityService>(); private readonly IIdentityService _identityService = Substitute.For<IIdentityService>();
private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>(); private readonly IXuiPanelGateway _gateway = Substitute.For<IXuiPanelGateway>();
private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>(); private readonly IRealtimeNotifier _notifier = Substitute.For<IRealtimeNotifier>();
private readonly ITelegramNotifier _telegramNotifier = Substitute.For<ITelegramNotifier>();
private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>(); private readonly ICurrentUser _currentUser = Substitute.For<ICurrentUser>();
[Fact] [Fact]
@@ -25,7 +26,7 @@ public class BlockUserCommandHandlerTests
var failure = UserErrors.NotFound; var failure = UserErrors.NotFound;
_identityService.BlockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Failure(failure)); _identityService.BlockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Failure(failure));
var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _currentUser); var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, _currentUser);
var result = await handler.Handle(new BlockUserCommand(userId), CancellationToken.None); var result = await handler.Handle(new BlockUserCommand(userId), CancellationToken.None);
@@ -55,7 +56,7 @@ public class BlockUserCommandHandlerTests
_identityService.BlockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success()); _identityService.BlockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success());
_currentUser.UserId.Returns(adminId); _currentUser.UserId.Returns(adminId);
var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _currentUser); var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, _currentUser);
var result = await handler.Handle(new BlockUserCommand(userId), CancellationToken.None); var result = await handler.Handle(new BlockUserCommand(userId), CancellationToken.None);
@@ -81,7 +82,7 @@ public class BlockUserCommandHandlerTests
_identityService.BlockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success()); _identityService.BlockUserAsync(userId, Arg.Any<CancellationToken>()).Returns(Result.Success());
_currentUser.UserId.Returns(Guid.NewGuid()); _currentUser.UserId.Returns(Guid.NewGuid());
var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _currentUser); var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, _currentUser);
var result = await handler.Handle(new BlockUserCommand(userId), CancellationToken.None); var result = await handler.Handle(new BlockUserCommand(userId), CancellationToken.None);
@@ -0,0 +1,76 @@
using System.Net;
using PnvPanel.IntegrationTests.TestSupport;
using Xunit;
using static PnvPanel.IntegrationTests.TestSupport.AuthTestHelper;
namespace PnvPanel.IntegrationTests.Activation;
[Collection(IntegrationTestCollection.Name)]
public class ActivationFlowTests(PnvPanelWebApplicationFactory factory)
{
private sealed record ActivationRequestResponse(Guid Id, string? Comment, DateTimeOffset CreatedAt);
[Fact]
public async Task RequestThenAdminApprove_ActivatesUser()
{
using var userClient = factory.CreateClient();
var userName = $"dave_{Guid.NewGuid():N}"[..20];
var (userId, userToken) = await RegisterAndLoginAsync(userClient, userName, "P@ssw0rd123");
userClient.UseBearerToken(userToken);
var requestResponse = await userClient.PostJsonAsync("/api/activation/request", new { comment = "Please activate me" });
Assert.Equal(HttpStatusCode.OK, requestResponse.StatusCode);
var request = await requestResponse.ReadAsAsync<ActivationRequestResponse>();
Assert.NotNull(request);
using var adminClient = factory.CreateClient();
var adminToken = await LoginAsAdminAsync(adminClient);
adminClient.UseBearerToken(adminToken);
var approveResponse = await adminClient.PostAsync($"/api/admin/activation-requests/{request!.Id}/approve", content: null);
Assert.Equal(HttpStatusCode.NoContent, approveResponse.StatusCode);
var meResponse = await userClient.GetAsync("/api/auth/me");
var me = await meResponse.ReadAsAsync<CurrentUserResponse>();
Assert.True(me!.IsActivated);
Assert.Equal(userId, me.Id);
}
[Fact]
public async Task RequestThenAdminReject_KeepsUserNotActivated()
{
using var userClient = factory.CreateClient();
var userName = $"erin_{Guid.NewGuid():N}"[..20];
var (_, userToken) = await RegisterAndLoginAsync(userClient, userName, "P@ssw0rd123");
userClient.UseBearerToken(userToken);
var requestResponse = await userClient.PostJsonAsync("/api/activation/request", new { comment = (string?)null });
var request = await requestResponse.ReadAsAsync<ActivationRequestResponse>();
using var adminClient = factory.CreateClient();
var adminToken = await LoginAsAdminAsync(adminClient);
adminClient.UseBearerToken(adminToken);
var rejectResponse = await adminClient.PostJsonAsync($"/api/admin/activation-requests/{request!.Id}/reject", new { reason = "not enough info" });
Assert.Equal(HttpStatusCode.NoContent, rejectResponse.StatusCode);
var meResponse = await userClient.GetAsync("/api/auth/me");
var me = await meResponse.ReadAsAsync<CurrentUserResponse>();
Assert.False(me!.IsActivated);
}
[Fact]
public async Task Request_WhenAlreadyPending_ReturnsConflict()
{
using var userClient = factory.CreateClient();
var userName = $"frank_{Guid.NewGuid():N}"[..20];
var (_, userToken) = await RegisterAndLoginAsync(userClient, userName, "P@ssw0rd123");
userClient.UseBearerToken(userToken);
var first = await userClient.PostJsonAsync("/api/activation/request", new { comment = (string?)null });
Assert.Equal(HttpStatusCode.OK, first.StatusCode);
var second = await userClient.PostJsonAsync("/api/activation/request", new { comment = (string?)null });
Assert.Equal(HttpStatusCode.Conflict, second.StatusCode);
}
}
@@ -0,0 +1,82 @@
using System.Net;
using PnvPanel.IntegrationTests.TestSupport;
using Xunit;
using static PnvPanel.IntegrationTests.TestSupport.AuthTestHelper;
namespace PnvPanel.IntegrationTests.Admin;
[Collection(IntegrationTestCollection.Name)]
public class NodeInboundCrudTests(PnvPanelWebApplicationFactory factory)
{
private sealed record NodeResponse(Guid Id, string Name, string BaseAddress, string Username, string? Location, int Status, bool IsEnabled);
private sealed record InboundResponse(
Guid Id, Guid NodeId, string RemoteInboundId, int Protocol, string Remark, int Port,
bool IsPublished, string? DisplayName, int? MaxClients, IReadOnlyList<Guid> AllowedRoleIds);
private sealed record SyncNodeResponse(int InboundsSynced, int Status);
[Fact]
public async Task RegisterSyncListPublish_FullNodeInboundLifecycle_Succeeds()
{
using var adminClient = factory.CreateClient();
var adminToken = await LoginAsAdminAsync(adminClient);
adminClient.UseBearerToken(adminToken);
var registerResponse = await adminClient.PostJsonAsync("/api/admin/nodes", new
{
name = $"Node-{Guid.NewGuid():N}"[..20],
baseAddress = "https://node.example.com:2053",
username = "admin",
password = "node-panel-password",
location = "Germany",
});
Assert.Equal(HttpStatusCode.OK, registerResponse.StatusCode);
var node = await registerResponse.ReadAsAsync<NodeResponse>();
Assert.NotNull(node);
var listNodesResponse = await adminClient.GetAsync("/api/admin/nodes");
Assert.Equal(HttpStatusCode.OK, listNodesResponse.StatusCode);
var nodes = await listNodesResponse.ReadAsAsync<List<NodeResponse>>();
Assert.Contains(nodes!, n => n.Id == node!.Id);
var syncResponse = await adminClient.PostAsync($"/api/admin/nodes/{node!.Id}/sync", content: null);
Assert.Equal(HttpStatusCode.OK, syncResponse.StatusCode);
var sync = await syncResponse.ReadAsAsync<SyncNodeResponse>();
Assert.Equal(1, sync!.InboundsSynced);
var listInboundsResponse = await adminClient.GetAsync($"/api/admin/inbounds?nodeId={node.Id}");
Assert.Equal(HttpStatusCode.OK, listInboundsResponse.StatusCode);
var inbounds = await listInboundsResponse.ReadAsAsync<List<InboundResponse>>();
var inbound = Assert.Single(inbounds!);
Assert.False(inbound.IsPublished);
var publishResponse = await adminClient.SendPutJsonAsync($"/api/admin/inbounds/{inbound.Id}/publish", new
{
isPublished = true,
displayName = "Germany (VLESS)",
allowedRoleIds = Array.Empty<Guid>(),
maxClients = (int?)null,
});
Assert.Equal(HttpStatusCode.OK, publishResponse.StatusCode);
var published = await publishResponse.ReadAsAsync<InboundResponse>();
Assert.True(published!.IsPublished);
Assert.Equal("Germany (VLESS)", published.DisplayName);
}
[Fact]
public async Task RegisterNode_AsNonAdmin_ReturnsForbidden()
{
using var userClient = factory.CreateClient();
var userName = $"greg_{Guid.NewGuid():N}"[..20];
var (_, userToken) = await RegisterAndLoginAsync(userClient, userName, "P@ssw0rd123");
userClient.UseBearerToken(userToken);
var response = await userClient.PostJsonAsync("/api/admin/nodes", new
{
name = "Node", baseAddress = "https://node.example.com", username = "admin", password = "pw", location = (string?)null,
});
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
}
}
@@ -0,0 +1,104 @@
using System.Net;
using PnvPanel.IntegrationTests.TestSupport;
using Xunit;
using static PnvPanel.IntegrationTests.TestSupport.AuthTestHelper;
namespace PnvPanel.IntegrationTests.Configs;
[Collection(IntegrationTestCollection.Name)]
public class ConfigQuotaTests(PnvPanelWebApplicationFactory factory)
{
private const int Quota = 2;
private const int ConcurrentAttempts = 6;
private sealed record RoleResponse(Guid Id, string Name, int MaxConfigs, bool IsSystem);
private sealed record NodeResponse(Guid Id, string Name);
private sealed record SyncNodeResponse(int InboundsSynced, int Status);
private sealed record InboundResponse(Guid Id, Guid NodeId, string RemoteInboundId, int Protocol, string Remark, int Port, bool IsPublished);
private sealed record ActivationRequestResponse(Guid Id, string? Comment, DateTimeOffset CreatedAt);
/// <summary>
/// Доказывает, что pg_advisory_xact_lock в CreateVpnConfigCommandHandler реально защищает
/// от гонки: при параллельных запросах ровно Quota проходят, остальные — 409 QuotaExceeded.
/// Это поведение невозможно проверить unit-тестами на InMemory-провайдере (см. Application.Tests).
/// </summary>
[Fact]
public async Task ConcurrentConfigCreation_AllowsExactlyQuotaSuccesses()
{
using var adminClient = factory.CreateClient();
var adminToken = await LoginAsAdminAsync(adminClient);
adminClient.UseBearerToken(adminToken);
var rolesResponse = await adminClient.GetAsync("/api/admin/roles");
Assert.Equal(HttpStatusCode.OK, rolesResponse.StatusCode);
var roles = await rolesResponse.ReadAsAsync<List<RoleResponse>>();
var userRole = roles!.Single(r => r.Name == "user");
var updateRoleResponse = await adminClient.SendPutJsonAsync($"/api/admin/roles/{userRole.Id}", new { maxConfigs = Quota });
Assert.Equal(HttpStatusCode.OK, updateRoleResponse.StatusCode);
var registerNodeResponse = await adminClient.PostJsonAsync("/api/admin/nodes", new
{
name = $"QuotaNode-{Guid.NewGuid():N}"[..24],
baseAddress = "https://quota-node.example.com",
username = "admin",
password = "node-panel-password",
location = (string?)null,
});
var node = await registerNodeResponse.ReadAsAsync<NodeResponse>();
var syncResponse = await adminClient.PostAsync($"/api/admin/nodes/{node!.Id}/sync", content: null);
var sync = await syncResponse.ReadAsAsync<SyncNodeResponse>();
Assert.Equal(1, sync!.InboundsSynced);
var inboundsResponse = await adminClient.GetAsync($"/api/admin/inbounds?nodeId={node.Id}");
var inbound = (await inboundsResponse.ReadAsAsync<List<InboundResponse>>())!.Single();
var publishResponse = await adminClient.SendPutJsonAsync($"/api/admin/inbounds/{inbound.Id}/publish", new
{
isPublished = true,
displayName = "Quota inbound",
allowedRoleIds = new[] { userRole.Id },
maxClients = (int?)null,
});
Assert.Equal(HttpStatusCode.OK, publishResponse.StatusCode);
using var userClient = factory.CreateClient();
var userName = $"quota_{Guid.NewGuid():N}"[..20];
var (_, userToken) = await RegisterAndLoginAsync(userClient, userName, "P@ssw0rd123");
userClient.UseBearerToken(userToken);
var activationRequestResponse = await userClient.PostJsonAsync("/api/activation/request", new { comment = (string?)null });
var activationRequest = await activationRequestResponse.ReadAsAsync<ActivationRequestResponse>();
var approveResponse = await adminClient.PostAsync($"/api/admin/activation-requests/{activationRequest!.Id}/approve", content: null);
Assert.Equal(HttpStatusCode.NoContent, approveResponse.StatusCode);
var tasks = Enumerable.Range(0, ConcurrentAttempts).Select(async i =>
{
using var attemptClient = factory.CreateClient();
attemptClient.UseBearerToken(userToken);
return await attemptClient.PostJsonAsync("/api/configs", new
{
inboundId = inbound.Id,
label = $"device-{i}",
deviceLimit = (int?)null,
});
});
var responses = await Task.WhenAll(tasks);
var succeeded = responses.Count(r => r.StatusCode == HttpStatusCode.OK);
var quotaExceeded = responses.Count(r => r.StatusCode == HttpStatusCode.Conflict);
Assert.Equal(Quota, succeeded);
Assert.Equal(ConcurrentAttempts - Quota, quotaExceeded);
var myConfigsResponse = await userClient.GetAsync("/api/configs");
var myConfigs = await myConfigsResponse.ReadAsAsync<List<object>>();
Assert.Equal(Quota, myConfigs!.Count);
}
}
@@ -16,4 +16,7 @@ public static class HttpClientJsonExtensions
public static Task<HttpResponseMessage> PostJsonAsync(this HttpClient client, string url, object body) public static Task<HttpResponseMessage> PostJsonAsync(this HttpClient client, string url, object body)
=> client.PostAsJsonAsync(url, body, JsonOptions); => client.PostAsJsonAsync(url, body, JsonOptions);
public static Task<HttpResponseMessage> SendPutJsonAsync(this HttpClient client, string url, object body)
=> client.PutAsJsonAsync(url, body, JsonOptions);
} }
+16
View File
@@ -21,13 +21,29 @@ services:
depends_on: depends_on:
db: db:
condition: service_healthy condition: service_healthy
# Секреты (Jwt__SigningKey, AdminSeed__Password, Telegram__BotToken, ...) — из .env,
# см. .env.example. ConnectionStrings__Default ниже переопределяет .env: адрес БД внутри
# сети compose всегда 'db', не значение из .env (которое рассчитано на локальный запуск).
env_file:
- .env
environment: environment:
ASPNETCORE_ENVIRONMENT: Production ASPNETCORE_ENVIRONMENT: Production
ASPNETCORE_HTTP_PORTS: '8080' ASPNETCORE_HTTP_PORTS: '8080'
ConnectionStrings__Default: 'Host=db;Port=5432;Database=${POSTGRES_DB:-pnvpanel};Username=${POSTGRES_USER:-pnvpanel};Password=${POSTGRES_PASSWORD:-pnvpanel}' ConnectionStrings__Default: 'Host=db;Port=5432;Database=${POSTGRES_DB:-pnvpanel};Username=${POSTGRES_USER:-pnvpanel};Password=${POSTGRES_PASSWORD:-pnvpanel}'
volumes:
# Data Protection key-ring (шифрование секретов нод at-rest) должен пережить пересоздание
# контейнера — иначе расшифровка паролей нод после рестарта станет невозможна.
- dp_keys:/app/keys
healthcheck:
test: ['CMD', 'curl', '-f', 'http://localhost:8080/health']
interval: 15s
timeout: 5s
start_period: 20s
retries: 5
ports: ports:
- '8080:8080' - '8080:8080'
restart: unless-stopped restart: unless-stopped
volumes: volumes:
pgdata: pgdata:
dp_keys:
+20 -12
View File
@@ -64,23 +64,31 @@
- Фронт: таблицы пользователей/конфигов/ролей, журнал аудита, графики трафика (Recharts), сводки. - Фронт: таблицы пользователей/конфигов/ролей, журнал аудита, графики трафика (Recharts), сводки.
- **Готово, когда**: админ видит статистику и журнал, управляет пользователями/ролями/конфигами; блокировка гасит VPN. - **Готово, когда**: админ видит статистику и журнал, управляет пользователями/ролями/конфигами; блокировка гасит VPN.
## M7 — Telegram-бот ## M7 — Telegram-бот
- Библиотека Telegram.Bot, `TelegramBotHostedService` (long polling) в процессе Api, `IOptions<TelegramOptions>`. - Библиотека Telegram.Bot, `TelegramBotHostedService` (long polling) в процессе Api, `IOptions<TelegramOptions>`.
- Домен: поля Telegram у `AppUser`, `TelegramLinkToken`, `TelegramLoginRequest`. - Домен: поля Telegram у `AppUser`, `TelegramLinkToken`, `TelegramLoginRequest`.
- Флоу привязки (`LinkTelegramCommand`) + эндпоинт `link-token`/`unlink`. - Флоу привязки (`LinkTelegramCommand`) + эндпоинт `link-token`/`unlink`.
- Passwordless-вход: `login-request` + подтверждение в боте (`ApproveTelegramLoginCommand`) → выпуск JWT; поллинг/SignalR-завершение на фронте. - Passwordless-вход: `login-request` + подтверждение в боте (`ApproveTelegramLoginCommand`) → выпуск JWT; поллинг завершения на фронте (`GET /api/auth/telegram/login-request/{id}`).
- Восстановление пароля через бота (`/resetpassword` → одноразовая ссылка на смену пароля). - Команды бота: `/start`, меню, «Мои конфиги» (`GetMyConfigsQuery`), `/login`, `/unlink`, `/requests`, `/help`.
- Команды бота: `/start`, меню, «Мои конфиги» (`GetMyConfigsQuery`), «Открыть сайт», `/login`, `/unlink`, `/help`; QR в боте.
- **Админ в боте**: уведомления о запросах активации + inline «Активировать/Отклонить», `/requests` (по Telegram id из env). - **Админ в боте**: уведомления о запросах активации + inline «Активировать/Отклонить», `/requests` (по Telegram id из env).
- **DM-уведомления юзеру**: активация, отзыв конфига админом, блокировка (если Telegram привязан). Бот — read-only по конфигам. - **DM-уведомления юзеру**: активация (`ApproveActivationCommandHandler`), блокировка (`BlockUserCommandHandler`), принудительный отзыв конфига админом (`ForceRevokeConfigCommandHandler`) — если Telegram привязан. Бот — read-only по конфигам.
- Фронт: кнопки «Войти через Telegram» и «Привязать Telegram» (deep-link/QR + ожидание подтверждения). - **Готово, когда**: юзер привязывает Telegram, входит без пароля, видит конфиги; админ активирует запросы прямо в боте. ✅ Достигнуто.
- **Готово, когда**: юзер привязывает Telegram, входит без пароля, видит конфиги; админ активирует запросы прямо в боте. - **Перенесено в backlog** (не реализовано в MVP): восстановление пароля через бота (`/resetpassword` с одноразовой ссылкой) — сейчас сброс пароля только через админа (`ResetUserPasswordCommand`); QR прямо в сообщениях бота; фронтовые кнопки «Войти через Telegram»/«Привязать Telegram» (бэкенд-контракт готов, фронт не реализовывался в эту итерацию).
## M8 — Закалка (hardening) ## M8 — Закалка (hardening)
- Полный набор тестов (Domain/Application/Integration с Testcontainers). - Тесты: `PnvPanel.Domain.Tests` (54, чистые unit-тесты инвариантов сущностей), `PnvPanel.Application.Tests`
- Rate-limiting, аудит-лог действий, единообразные ProblemDetails, ретеншн `TrafficSample`. (71, CQRS-хендлеры на EF Core InMemory + NSubstitute-моки портов), `PnvPanel.IntegrationTests`
- Прод-конфиг docker-compose (secrets, том для key-ring Data Protection, healthchecks); TLS — внешним прокси. (Testcontainers.PostgreSql + `WebApplicationFactory<Program>` — реальный HTTP-контракт, включая
- **Готово, когда**: зелёный CI, покрытие ключевых сценариев, готовность к деплою. проверку `pg_advisory_xact_lock` под параллельной нагрузкой на квоту конфигов).
- Rate-limiting, аудит-лог, единообразные `ProblemDetails`, ретеншн `TrafficSample` — сделаны в M5/M6.
- CI (`.github/workflows/ci.yml`): `dotnet build/test` (backend, включая интеграционные — на
`ubuntu-latest` Docker доступен) + `pnpm lint/typecheck/build` (frontend), без деплоя.
- Прод-`docker-compose.yml`: `env_file: .env` прокидывает все секреты в контейнер `app`, том
`dp_keys` для key-ring Data Protection (переживает пересоздание контейнера), healthcheck `app`
через `GET /health` (curl добавлен в runtime-образ). TLS — внешним прокси (без изменений).
- **Готово, когда**: зелёный CI, покрытие ключевых сценариев, готовность к деплою. ✅ Достигнуто
(интеграционные тесты не запускались локально — Docker Desktop недоступен на машине разработки;
зависят от Docker в CI для первого реального прогона).
## Backlog (после MVP) ## Backlog (после MVP)
- Полное самообслуживание в боте (создание/ротация/отзыв конфигов) — в MVP бот read-only. - Полное самообслуживание в боте (создание/ротация/отзыв конфигов) — в MVP бот read-only.
+9 -8
View File
@@ -16,9 +16,10 @@ Telegram-бот — **второй канал доставки** (presentation-
о запросе активации с комментарием заявителя и жмёт «Активировать / Отклонить» прямо в боте. о запросе активации с комментарием заявителя и жмёт «Активировать / Отклонить» прямо в боте.
5. **DM-уведомления пользователю** — если Telegram привязан, бот шлёт личные уведомления о ключевых 5. **DM-уведомления пользователю** — если Telegram привязан, бот шлёт личные уведомления о ключевых
событиях: «аккаунт активирован», «конфиг отозван админом», «вы заблокированы». событиях: «аккаунт активирован», «конфиг отозван админом», «вы заблокированы».
6. **Восстановление пароля**если пароль забыт, привязанный пользователь через бота получает 6. **Восстановление пароля**реализовано через passwordless-вход: привязанный пользователь входит
одноразовую ссылку на страницу задания нового пароля (или входит passwordless и меняет пароль в через бота (`/login`) и меняет пароль в настройках (`ChangePasswordCommand`). Без привязки
настройках). Без привязки Telegram восстановление делает только админ. Telegram сброс делает только админ (`ResetUserPasswordCommand`). Отдельная команда бота
`/resetpassword` с одноразовой ссылкой на смену пароля — **backlog**, в MVP не реализована.
> **Скоуп бота в MVP — просмотр (read-only) по конфигам.** Создание/ротация/отзыв конфигов — только > **Скоуп бота в MVP — просмотр (read-only) по конфигам.** Создание/ротация/отзыв конфигов — только
> на сайте. Полное самообслуживание в боте (создание/отзыв) — в backlog. > на сайте. Полное самообслуживание в боте (создание/отзыв) — в backlog.
@@ -104,18 +105,18 @@ Telegram ──updates──► TelegramBotHostedService (Api)
| Команда / кнопка | Действие | Требует привязки | | Команда / кнопка | Действие | Требует привязки |
| ---------------------- | -------------------------------------------------------------- | ---------------- | | ---------------------- | -------------------------------------------------------------- | ---------------- |
| `/start` | Приветствие + меню (Открыть сайт / Мои конфиги / Войти) | нет | | `/start` | Приветствие + справка по командам | нет |
| `/start link_<t>` | Привязка аккаунта по токену | нет | | `/start link_<t>` | Привязка аккаунта по токену | нет |
| `/start login_<n>` | Подтверждение passwordless-входа | да | | `/start login_<n>` | Подтверждение passwordless-входа (deep-link с сайта) | да |
| «Открыть сайт» | Ссылка на веб-панель (опц. одноразовый auto-login deep link) | нет / да |
| `/configs` | Список конфигов | да | | `/configs` | Список конфигов | да |
| `/login` | Инициировать/подтвердить вход | да |
| `/resetpassword` | Одноразовая ссылка на смену пароля (восстановление) | да |
| `/unlink` | Отвязать Telegram от аккаунта | да | | `/unlink` | Отвязать Telegram от аккаунта | да |
| `/help` | Справка | нет | | `/help` | Справка | нет |
| «Активировать/Отклонить» | (admin) решение по запросу активации | админ по env | | «Активировать/Отклонить» | (admin) решение по запросу активации | админ по env |
| `/requests` | (admin) список ожидающих запросов активации | админ по env | | `/requests` | (admin) список ожидающих запросов активации | админ по env |
> Passwordless-вход и `/resetpassword` инициируются с сайта (кнопка «Войти через Telegram»),
> не отдельной командой бота — см. пункт 6 выше про `/resetpassword` (backlog).
## Безопасность ## Безопасность
- Токены привязки и nonce входа: высокоэнтропийные, **короткоживущие** (≈25 мин), **одноразовые**. - Токены привязки и nonce входа: высокоэнтропийные, **короткоживущие** (≈25 мин), **одноразовые**.