diff --git a/.claude/launch.json b/.claude/launch.json index 7f6c3c0..5983122 100644 --- a/.claude/launch.json +++ b/.claude/launch.json @@ -5,7 +5,8 @@ "name": "frontend", "runtimeExecutable": "pnpm", "runtimeArgs": ["--dir", "frontend", "dev"], - "port": 5173 + "port": 5173, + "autoPort": true } ] } diff --git a/.env.example b/.env.example index 7d05f97..149b843 100644 --- a/.env.example +++ b/.env.example @@ -47,3 +47,11 @@ Telegram__AdminTelegramUserIds=123456789 # ── ASP.NET Core ────────────────────────────────────────────────────────── ASPNETCORE_ENVIRONMENT=Production ASPNETCORE_HTTP_PORTS=8080 + +# ── Доверенные прокси (X-Forwarded-For/Proto) ────────────────────────────── +# TLS терминируется вне compose внешним прокси/шлюзом. Чтобы клиент не мог подделать свой IP/схему +# напрямую (в обход прокси), по умолчанию доверяется только loopback (дефолт ASP.NET Core). Если +# прокси стоит не на loopback (отдельный контейнер/хост), перечисли его через запятую — конкретные +# IP через KnownProxies и/или сети в формате CIDR через KnownNetworks. +# ForwardedHeaders__KnownProxies=203.0.113.10 +# ForwardedHeaders__KnownNetworks=172.18.0.0/16 diff --git a/backend/src/PnvPanel.Api/Program.cs b/backend/src/PnvPanel.Api/Program.cs index b94769b..c0f341e 100644 --- a/backend/src/PnvPanel.Api/Program.cs +++ b/backend/src/PnvPanel.Api/Program.cs @@ -1,3 +1,4 @@ +using System.Net; using System.Text.Json.Serialization; using Microsoft.AspNetCore.HttpOverrides; using Microsoft.AspNetCore.RateLimiting; @@ -24,12 +25,23 @@ builder.Services.AddSerilog((services, configuration) => configuration .ReadFrom.Services(services) .Enrich.FromLogContext()); -// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера). +// За внешним прокси доверяем X-Forwarded-* (TLS терминируется вне контейнера), но ТОЛЬКО от явно +// перечисленных адресов/сетей прокси — иначе клиент может подделать свой IP/схему напрямую, минуя +// прокси (спуфинг rate-limiting по IP, аудит-лога, Secure-cookie). По умолчанию (без конфигурации) +// остаётся дефолт ASP.NET Core — доверие только loopback; для прод-топологии прокси задаётся через +// ForwardedHeaders__KnownProxies / ForwardedHeaders__KnownNetworks (см. .env.example). builder.Services.Configure(options => { options.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; - options.KnownIPNetworks.Clear(); - options.KnownProxies.Clear(); + + foreach (var proxy in builder.Configuration.GetSection("ForwardedHeaders:KnownProxies").Get() ?? []) + options.KnownProxies.Add(IPAddress.Parse(proxy)); + + foreach (var network in builder.Configuration.GetSection("ForwardedHeaders:KnownNetworks").Get() ?? []) + { + var parts = network.Split('/'); + options.KnownIPNetworks.Add(new System.Net.IPNetwork(IPAddress.Parse(parts[0]), int.Parse(parts[1]))); + } }); builder.Services.AddHttpContextAccessor(); @@ -83,6 +95,15 @@ builder.Services.AddHealthChecks() var app = builder.Build(); +// Без персистентного пути key-ring живёт только в памяти контейнера — после пересоздания +// расшифровать уже сохранённые пароли нод будет невозможно. Предупреждаем громко, не молчим. +if (string.IsNullOrWhiteSpace(builder.Configuration["DataProtection:KeyRingPath"])) +{ + app.Logger.LogWarning( + "DataProtection:KeyRingPath не задан — ключи шифрования секретов нод не персистентны " + + "и будут потеряны при пересоздании контейнера. В проде обязательно смонтируй том и укажи путь."); +} + // Авто-применение миграций и идемпотентный сидинг (роли + админ из env) на старте. await app.Services.ApplyMigrationsAsync(); await app.Services.SeedDataAsync(); diff --git a/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommandHandler.cs index f6db484..33160f3 100644 --- a/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Users/BlockUserCommandHandler.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Models; @@ -10,7 +11,8 @@ namespace PnvPanel.Application.Admin.Users; /// Блокировка гасит все активные конфиги в 3x-ui (см. architecture.md). public sealed class BlockUserCommandHandler( IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway, - IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser) + IRealtimeNotifier notifier, ITelegramNotifier telegramNotifier, ICurrentUser currentUser, + ILogger logger) : ICommandHandler { public async Task Handle(BlockUserCommand command, CancellationToken cancellationToken) @@ -32,9 +34,20 @@ public sealed class BlockUserCommandHandler( if (inbound is not null && node is not null) { - await gateway.UpdateClientAsync( + var updateResult = await gateway.UpdateClientAsync( node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, config.Label ?? config.ClientEmail, config.DeviceLimit, enable: false, cancellationToken); + + if (!updateResult.IsSuccess) + { + // Нода недоступна/сбой панели — не помечаем Disabled локально, иначе БД разойдётся + // с реальным состоянием клиента в 3x-ui (пользователь решит, что VPN погашен, а он жив). + // Конфиг останется Active и будет подхвачен повторным BlockUserCommand (идемпотентен). + logger.LogWarning( + "Не удалось отключить клиента конфига {ConfigId} на ноде {NodeId} при блокировке пользователя {UserId}: {Error}", + config.Id, node.Id, command.UserId, updateResult.Error); + continue; + } } config.Disable(); diff --git a/backend/src/PnvPanel.Application/Admin/Users/UnblockUserCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Users/UnblockUserCommandHandler.cs index 2d798f1..19ec59a 100644 --- a/backend/src/PnvPanel.Application/Admin/Users/UnblockUserCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Users/UnblockUserCommandHandler.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; using PnvPanel.Application.Common.Interfaces; using PnvPanel.Application.Common.Messaging; using PnvPanel.Application.Common.Models; @@ -10,7 +11,7 @@ namespace PnvPanel.Application.Admin.Users; /// Разблокировка возвращает в 3x-ui только конфиги, погашенные блокировкой (Disabled). public sealed class UnblockUserCommandHandler( IAppDbContext dbContext, IIdentityService identityService, IXuiPanelGateway gateway, - IRealtimeNotifier notifier, ICurrentUser currentUser) + IRealtimeNotifier notifier, ICurrentUser currentUser, ILogger logger) : ICommandHandler { public async Task Handle(UnblockUserCommand command, CancellationToken cancellationToken) @@ -32,9 +33,20 @@ public sealed class UnblockUserCommandHandler( if (inbound is not null && node is not null) { - await gateway.UpdateClientAsync( + var updateResult = await gateway.UpdateClientAsync( node, inbound.RemoteInboundId, config.ClientExternalId, config.Protocol, config.Label ?? config.ClientEmail, config.DeviceLimit, enable: true, cancellationToken); + + if (!updateResult.IsSuccess) + { + // Нода недоступна/сбой панели — не помечаем Enabled локально, иначе БД разойдётся + // с реальным состоянием клиента в 3x-ui. Конфиг останется Disabled и будет подхвачен + // повторным UnblockUserCommand (идемпотентен). + logger.LogWarning( + "Не удалось включить клиента конфига {ConfigId} на ноде {NodeId} при разблокировке пользователя {UserId}: {Error}", + config.Id, node.Id, command.UserId, updateResult.Error); + continue; + } } config.Enable(); diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Users/BlockUserCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Users/BlockUserCommandHandlerTests.cs index 5965ad7..ff761f7 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Users/BlockUserCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Users/BlockUserCommandHandlerTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using NSubstitute; using PnvPanel.Application.Admin.Users; using PnvPanel.Application.Common.Interfaces; @@ -17,6 +18,7 @@ public class BlockUserCommandHandlerTests private readonly IRealtimeNotifier _notifier = Substitute.For(); private readonly ITelegramNotifier _telegramNotifier = Substitute.For(); private readonly ICurrentUser _currentUser = Substitute.For(); + private readonly ILogger _logger = Substitute.For>(); [Fact] public async Task Handle_IdentityServiceFails_ReturnsFailureWithoutTouchingConfigs() @@ -26,7 +28,7 @@ public class BlockUserCommandHandlerTests var failure = UserErrors.NotFound; _identityService.BlockUserAsync(userId, Arg.Any()).Returns(Result.Failure(failure)); - var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, _currentUser); + var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, _currentUser, _logger); var result = await handler.Handle(new BlockUserCommand(userId), CancellationToken.None); @@ -55,8 +57,12 @@ public class BlockUserCommandHandlerTests _identityService.BlockUserAsync(userId, Arg.Any()).Returns(Result.Success()); _currentUser.UserId.Returns(adminId); + _gateway.UpdateClientAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Result.Success()); - var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, _currentUser); + var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, _currentUser, _logger); var result = await handler.Handle(new BlockUserCommand(userId), CancellationToken.None); @@ -82,7 +88,7 @@ public class BlockUserCommandHandlerTests _identityService.BlockUserAsync(userId, Arg.Any()).Returns(Result.Success()); _currentUser.UserId.Returns(Guid.NewGuid()); - var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, _currentUser); + var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, _currentUser, _logger); var result = await handler.Handle(new BlockUserCommand(userId), CancellationToken.None); @@ -91,4 +97,37 @@ public class BlockUserCommandHandlerTests Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); } + + [Fact] + public async Task Handle_GatewayFails_KeepsConfigActiveForRetryAndDoesNotNotify() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + var adminId = Guid.NewGuid(); + + var node = Node.Register("node-1", new Uri("https://node1.example.com"), new NodeCredentials("admin", "protected"), null); + var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443); + var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "my-config", 0); + + dbContext.Nodes.Add(node); + dbContext.Inbounds.Add(inbound); + dbContext.VpnConfigs.Add(config); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _identityService.BlockUserAsync(userId, Arg.Any()).Returns(Result.Success()); + _currentUser.UserId.Returns(adminId); + _gateway.UpdateClientAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Result.Failure(Error.Failure("Xui.UpdateClientFailed", "Нода недоступна."))); + + var handler = new BlockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _telegramNotifier, _currentUser, _logger); + + var result = await handler.Handle(new BlockUserCommand(userId), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(ConfigStatus.Active, config.Status); + await _notifier.DidNotReceive().NotifyConfigStatusChangedAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } } diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Users/UnblockUserCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Users/UnblockUserCommandHandlerTests.cs index 47b651d..49a87a6 100644 --- a/backend/tests/PnvPanel.Application.Tests/Admin/Users/UnblockUserCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Users/UnblockUserCommandHandlerTests.cs @@ -1,3 +1,4 @@ +using Microsoft.Extensions.Logging; using NSubstitute; using PnvPanel.Application.Admin.Users; using PnvPanel.Application.Common.Interfaces; @@ -16,6 +17,7 @@ public class UnblockUserCommandHandlerTests private readonly IXuiPanelGateway _gateway = Substitute.For(); private readonly IRealtimeNotifier _notifier = Substitute.For(); private readonly ICurrentUser _currentUser = Substitute.For(); + private readonly ILogger _logger = Substitute.For>(); [Fact] public async Task Handle_WhenUnblockSucceeds_ReEnablesDisabledConfigsAndWritesAudit() @@ -36,8 +38,12 @@ public class UnblockUserCommandHandlerTests _currentUser.UserId.Returns(adminId); _identityService.UnblockUserAsync(userId, Arg.Any()).Returns(Result.Success()); + _gateway.UpdateClientAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Result.Success()); - var handler = new UnblockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _currentUser); + var handler = new UnblockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _currentUser, _logger); var result = await handler.Handle(new UnblockUserCommand(userId), CancellationToken.None); @@ -60,7 +66,7 @@ public class UnblockUserCommandHandlerTests _identityService.UnblockUserAsync(userId, Arg.Any()).Returns(Result.Failure(error)); - var handler = new UnblockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _currentUser); + var handler = new UnblockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _currentUser, _logger); var result = await handler.Handle(new UnblockUserCommand(userId), CancellationToken.None); @@ -68,4 +74,38 @@ public class UnblockUserCommandHandlerTests Assert.Equal(error, result.Error); Assert.Empty(dbContext.AuditLogs); } + + [Fact] + public async Task Handle_GatewayFails_KeepsConfigDisabledForRetryAndDoesNotNotify() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = Guid.NewGuid(); + var adminId = Guid.NewGuid(); + + var node = Node.Register("node-1", new Uri("https://node1.example.com"), new NodeCredentials("admin", "protected"), null); + var inbound = Inbound.FromRemote(node.Id, "1", VpnProtocol.Vless, "remark", 443); + var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, "my-config", 0); + config.Disable(); + + dbContext.Nodes.Add(node); + dbContext.Inbounds.Add(inbound); + dbContext.VpnConfigs.Add(config); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _currentUser.UserId.Returns(adminId); + _identityService.UnblockUserAsync(userId, Arg.Any()).Returns(Result.Success()); + _gateway.UpdateClientAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any(), + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(Result.Failure(Error.Failure("Xui.UpdateClientFailed", "Нода недоступна."))); + + var handler = new UnblockUserCommandHandler(dbContext, _identityService, _gateway, _notifier, _currentUser, _logger); + + var result = await handler.Handle(new UnblockUserCommand(userId), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.Equal(ConfigStatus.Disabled, config.Status); + await _notifier.DidNotReceive().NotifyConfigStatusChangedAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()); + } } diff --git a/frontend/src/features/admin/inbounds/PublishInboundDialog.tsx b/frontend/src/features/admin/inbounds/PublishInboundDialog.tsx index e63ed15..d0de2d8 100644 --- a/frontend/src/features/admin/inbounds/PublishInboundDialog.tsx +++ b/frontend/src/features/admin/inbounds/PublishInboundDialog.tsx @@ -27,7 +27,7 @@ export function PublishInboundDialog({ const [maxClients, setMaxClients] = useState(inbound.maxClients?.toString() ?? '') const [selectedRoles, setSelectedRoles] = useState>(new Set(inbound.allowedRoleIds)) - const rolesQuery = useQuery({ queryKey: ['admin-roles-page'], queryFn: listRoles, enabled: open }) + const rolesQuery = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles, enabled: open }) const mutation = useMutation({ mutationFn: () => @@ -35,6 +35,8 @@ export function PublishInboundDialog({ onSuccess: async () => { toast.success(t('admin.nodes.publishSaved')) await queryClient.invalidateQueries({ queryKey: ['admin-inbounds', inbound.nodeId] }) + // Публикация/список ролей инбаунда влияет на то, что видит пользователь при создании конфига. + await queryClient.invalidateQueries({ queryKey: ['available-inbounds'] }) onOpenChange(false) }, onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')), diff --git a/frontend/src/features/admin/nodes/NodeCard.tsx b/frontend/src/features/admin/nodes/NodeCard.tsx index ecc5a80..d1907c5 100644 --- a/frontend/src/features/admin/nodes/NodeCard.tsx +++ b/frontend/src/features/admin/nodes/NodeCard.tsx @@ -51,6 +51,8 @@ export function NodeCard({ node }: { node: NodeDto }) { toast.success(t('admin.nodes.syncSuccess', { count: result.inboundsSynced })) await invalidateNodes() await queryClient.invalidateQueries({ queryKey: ['admin-inbounds', node.id] }) + // Синхронизация могла добавить/убрать инбаунды, доступные пользователю при создании конфига. + await queryClient.invalidateQueries({ queryKey: ['available-inbounds'] }) }, onError: (error) => toast.error(error instanceof HttpError ? error.detail : t('auth.genericError')), }) diff --git a/frontend/src/features/admin/roles/RoleFormDialog.tsx b/frontend/src/features/admin/roles/RoleFormDialog.tsx index 401b8c1..bf6d4f3 100644 --- a/frontend/src/features/admin/roles/RoleFormDialog.tsx +++ b/frontend/src/features/admin/roles/RoleFormDialog.tsx @@ -33,7 +33,7 @@ export function RoleFormDialog({ mutationFn: () => (role ? updateRole(role.id, Number(maxConfigs)) : createRole(name.trim(), Number(maxConfigs))), onSuccess: async () => { toast.success(role ? t('admin.roles.updated') : t('admin.roles.created')) - await queryClient.invalidateQueries({ queryKey: ['admin-roles-page'] }) + await queryClient.invalidateQueries({ queryKey: ['admin-roles'] }) setDialogOpen(false) setName('') setMaxConfigs('3') diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 260c21e..430c461 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -8,6 +8,12 @@ import { ThemeProvider } from './theme/ThemeProvider' import { ToastProvider } from './shared/ui/toast-store' import { RealtimeProvider } from './shared/realtime/RealtimeProvider' import { router } from './router' +import { setUnauthorizedHandler } from './shared/api/client' +import { clearSession } from './features/auth/api' + +// Если refresh-токен недействителен (истёк/отозван) — очищаем стор авторизации, чтобы +// useRequireAuth/useRequireAdmin увидели user === null и сами увели на /login. +setUnauthorizedHandler(clearSession) const queryClient = new QueryClient() diff --git a/frontend/src/routes/admin/roles.tsx b/frontend/src/routes/admin/roles.tsx index d724c8c..bf9c163 100644 --- a/frontend/src/routes/admin/roles.tsx +++ b/frontend/src/routes/admin/roles.tsx @@ -16,13 +16,13 @@ function AdminRolesPage() { const queryClient = useQueryClient() const [editing, setEditing] = useState(null) - const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['admin-roles-page'], queryFn: listRoles }) + const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles }) const deleteMutation = useMutation({ mutationFn: deleteRole, onSuccess: async () => { toast.success(t('admin.roles.deleted')) - await queryClient.invalidateQueries({ queryKey: ['admin-roles-page'] }) + await queryClient.invalidateQueries({ queryKey: ['admin-roles'] }) }, onError: () => toast.error(t('auth.genericError')), }) diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 2977e69..e186fb6 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -18,6 +18,7 @@ export default defineConfig({ alias: { '@': path.resolve(__dirname, './src') }, }, server: { + port: process.env.PORT ? Number(process.env.PORT) : 5173, proxy: { '/api': { target: apiTarget, changeOrigin: true }, '/hubs': { target: apiTarget, changeOrigin: true, ws: true },