diff --git a/backend/src/PnvPanel.Application/Admin/Inbounds/InboundDto.cs b/backend/src/PnvPanel.Application/Admin/Inbounds/InboundDto.cs index f58efd7..da4e1f4 100644 --- a/backend/src/PnvPanel.Application/Admin/Inbounds/InboundDto.cs +++ b/backend/src/PnvPanel.Application/Admin/Inbounds/InboundDto.cs @@ -10,6 +10,7 @@ public sealed record InboundDto( string Remark, int Port, bool IsPublished, + bool IsAvailable, string? DisplayName, IReadOnlyList AllowedRoleIds, DateTimeOffset? LastSyncAt @@ -24,6 +25,7 @@ public sealed record InboundDto( inbound.Remark, inbound.Port, inbound.IsPublished, + inbound.IsAvailable, inbound.DisplayName, inbound.AllowedRoleIds, inbound.LastSyncAt diff --git a/backend/src/PnvPanel.Application/Admin/Nodes/SyncNodeCommandHandler.cs b/backend/src/PnvPanel.Application/Admin/Nodes/SyncNodeCommandHandler.cs index 52b62e4..08bb2ff 100644 --- a/backend/src/PnvPanel.Application/Admin/Nodes/SyncNodeCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Admin/Nodes/SyncNodeCommandHandler.cs @@ -50,15 +50,30 @@ public sealed class SyncNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGat ); } - // Inbound, пропавший на панели, снимаем с публикации (не удаляем — реконсиляция дрейфа, - // см. architecture.md); новые конфиги на нём создать будет нельзя, старые не трогаем. + // Inbound, пропавший на панели: если по нему нет конфигов — запись просто мусор, удаляем + // (иначе она копится дублем при пересоздании инбаунда на той же панели под новым + // RemoteInboundId — 3x-ui id не переиспользует). Если конфиги есть — удалить нельзя (FK), + // помечаем недоступной: новые конфиги на нём создать нельзя, Revoke не бьёт в мёртвую панель. var remoteIds = remoteResult.Value.Select(r => r.RemoteInboundId).ToHashSet(); - foreach ( - var stale in existing.Where(i => - i.IsPublished && !remoteIds.Contains(i.RemoteInboundId) - ) - ) - stale.Unpublish(); + var stale = existing.Where(i => !remoteIds.Contains(i.RemoteInboundId)).ToList(); + if (stale.Count > 0) + { + var staleIds = stale.Select(i => i.Id).ToList(); + var staleIdsWithConfigs = await dbContext + .VpnConfigs.Where(c => staleIds.Contains(c.InboundId)) + .Select(c => c.InboundId) + .Distinct() + .ToListAsync(cancellationToken); + var withConfigs = staleIdsWithConfigs.ToHashSet(); + + foreach (var inbound in stale) + { + if (withConfigs.Contains(inbound.Id)) + inbound.MarkUnavailable(); + else + dbContext.Inbounds.Remove(inbound); + } + } node.UpdateStatus(NodeStatus.Online); node.MarkSynced(); diff --git a/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommandHandler.cs b/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommandHandler.cs index 07de128..57dd507 100644 --- a/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommandHandler.cs +++ b/backend/src/PnvPanel.Application/Configs/Revoke/RevokeVpnConfigCommandHandler.cs @@ -43,7 +43,9 @@ public sealed class RevokeVpnConfigCommandHandler( .Nodes.AsNoTracking() .FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); - if (inbound is not null && node is not null) + // inbound.IsAvailable=false значит синхронизация уже подтвердила, что его нет на панели — + // звать RemoveClientAsync незачем (и оно всё равно упадёт: инбаунда для клиента не существует). + if (inbound is not null && inbound.IsAvailable && node is not null) { var removeResult = await gateway.RemoveClientAsync( node, diff --git a/backend/src/PnvPanel.Domain/Inbounds/Inbound.cs b/backend/src/PnvPanel.Domain/Inbounds/Inbound.cs index e08bf49..59c60c2 100644 --- a/backend/src/PnvPanel.Domain/Inbounds/Inbound.cs +++ b/backend/src/PnvPanel.Domain/Inbounds/Inbound.cs @@ -16,6 +16,7 @@ public sealed class Inbound : Entity public string Remark { get; private set; } = string.Empty; public int Port { get; private set; } public bool IsPublished { get; private set; } + public bool IsAvailable { get; private set; } = true; public string? DisplayName { get; private set; } public IReadOnlyList AllowedRoleIds { get; private set; } = []; public DateTimeOffset? LastSyncAt { get; private set; } @@ -39,6 +40,7 @@ public sealed class Inbound : Entity Remark = remark, Port = port, IsPublished = false, + IsAvailable = true, LastSyncAt = DateTimeOffset.UtcNow, }; } @@ -59,4 +61,15 @@ public sealed class Inbound : Entity } public void Unpublish() => IsPublished = false; + + /// + /// Инбаунд пропал на панели (не пришёл в очередной синхронизации), но по нему есть конфиги — + /// удалить запись нельзя (FK), поэтому помечаем недоступной вместо тихого "не опубликован": + /// UI показывает явный статус, а Revoke пропускает обращение к панели как заведомо мёртвое. + /// + public void MarkUnavailable() + { + IsAvailable = false; + IsPublished = false; + } } diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718210336_AddInboundIsAvailable.Designer.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718210336_AddInboundIsAvailable.Designer.cs new file mode 100644 index 0000000..5177062 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718210336_AddInboundIsAvailable.Designer.cs @@ -0,0 +1,964 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; +using PnvPanel.Infrastructure.Persistence; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260718210336_AddInboundIsAvailable")] + partial class AddInboundIsAvailable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.9") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ClaimType") + .HasColumnType("text"); + + b.Property("ClaimValue") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("ProviderKey") + .HasColumnType("text"); + + b.Property("ProviderDisplayName") + .HasColumnType("text"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("RoleId") + .HasColumnType("uuid"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("uuid"); + + b.Property("LoginProvider") + .HasColumnType("text"); + + b.Property("Name") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Comment") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DecidedBy") + .HasColumnType("uuid"); + + b.Property("RejectionReason") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("ActivationRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Description") + .HasMaxLength(300) + .HasColumnType("character varying(300)"); + + b.Property("DownloadUrl") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IconUrl") + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("IsRecommended") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("OperatingSystem") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("ClientApps", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Action") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("ActorId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Metadata") + .HasColumnType("jsonb"); + + b.Property("Source") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("TargetId") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TargetType") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("AuditLogs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.TrafficSample", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("ConfigId") + .HasColumnType("uuid"); + + b.Property("DownBytes") + .HasColumnType("bigint"); + + b.Property("Timestamp") + .HasColumnType("timestamp with time zone"); + + b.Property("UpBytes") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("ConfigId", "Timestamp"); + + b.ToTable("TrafficSamples", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ClientEmail") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("ClientExternalId") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InboundId") + .HasColumnType("uuid"); + + b.Property("Label") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UsedDownBytes") + .HasColumnType("bigint"); + + b.Property("UsedUpBytes") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("InboundId"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("UserId", "Status"); + + b.ToTable("VpnConfigs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Inbounds.Inbound", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.PrimitiveCollection("AllowedRoleIds") + .IsRequired() + .HasColumnType("uuid[]"); + + b.Property("DisplayName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("IsAvailable") + .HasColumnType("boolean"); + + b.Property("IsPublished") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("NodeId") + .HasColumnType("uuid"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("Protocol") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Remark") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("RemoteInboundId") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.HasKey("Id"); + + b.HasIndex("NodeId", "RemoteInboundId") + .IsUnique(); + + b.ToTable("Inbounds", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionIntro", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("InstructionIntros", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionTab", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SortOrder") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("SortOrder"); + + b.ToTable("InstructionTabs", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.News.NewsPost", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(20000) + .HasColumnType("character varying(20000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CreatedAt"); + + b.ToTable("NewsPosts", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BaseAddress") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("LastSyncAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Location") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.HasKey("Id"); + + b.ToTable("Nodes", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Pricing.PricingSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("PricePerConfigPerHalfYear") + .HasColumnType("integer"); + + b.Property("PricePerConfigPerQuarter") + .HasColumnType("integer"); + + b.Property("PricePerConfigPerYear") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("PricingSettings", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.SupportTicket", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ProposedMaxConfigs") + .HasColumnType("integer"); + + b.Property("ProposedMaxIpLimit") + .HasColumnType("integer"); + + b.Property("ProposedRoleName") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("RequestedRoleId") + .HasColumnType("uuid"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("Type") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Type", "Status"); + + b.HasIndex("UserId", "Status"); + + b.ToTable("SupportTickets", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketAttachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CommentId") + .HasColumnType("uuid"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FileName") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("character varying(255)"); + + b.Property("SizeBytes") + .HasColumnType("bigint"); + + b.Property("StoredFileName") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.HasKey("Id"); + + b.HasIndex("CommentId"); + + b.HasIndex("StoredFileName") + .IsUnique(); + + b.ToTable("TicketAttachments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Support.TicketComment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("uuid"); + + b.Property("Body") + .IsRequired() + .HasMaxLength(4000) + .HasColumnType("character varying(4000)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TicketId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TicketId", "CreatedAt"); + + b.ToTable("TicketComments", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConsumedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("Token") + .IsUnique(); + + b.ToTable("TelegramLinkTokens", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Context") + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .IsRequired() + .HasMaxLength(32) + .HasColumnType("character varying(32)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.ToTable("TelegramLoginRequests", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("IsSystem") + .HasColumnType("boolean"); + + b.Property("MaxConfigs") + .HasColumnType("integer"); + + b.Property("MaxIpLimit") + .HasColumnType("integer"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("ActivatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ActivatedBy") + .HasColumnType("uuid"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IsActivated") + .HasColumnType("boolean"); + + b.Property("IsBlocked") + .HasColumnType("boolean"); + + b.Property("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("PasswordHash") + .HasColumnType("text"); + + b.Property("PhoneNumber") + .HasColumnType("text"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("SubscriptionToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("TelegramLinkedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TelegramUserId") + .HasColumnType("bigint"); + + b.Property("TelegramUsername") + .HasColumnType("text"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.HasIndex("SubscriptionToken") + .IsUnique(); + + b.HasIndex("TelegramUserId") + .IsUnique(); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("PnvPanel.Infrastructure.Identity.RefreshToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ReplacedByTokenHash") + .HasColumnType("text"); + + b.Property("RevokedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TokenHash") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("UserId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TokenHash") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("RefreshTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("PnvPanel.Domain.Nodes.Node", b => + { + b.OwnsOne("PnvPanel.Domain.Nodes.NodeCredentials", "Credentials", b1 => + { + b1.Property("NodeId") + .HasColumnType("uuid"); + + b1.Property("ProtectedPassword") + .IsRequired() + .HasColumnType("text") + .HasColumnName("CredentialsProtectedPassword"); + + b1.Property("Username") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)") + .HasColumnName("CredentialsUsername"); + + b1.HasKey("NodeId"); + + b1.ToTable("Nodes"); + + b1.WithOwner() + .HasForeignKey("NodeId"); + }); + + b.Navigation("Credentials") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718210336_AddInboundIsAvailable.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718210336_AddInboundIsAvailable.cs new file mode 100644 index 0000000..84422a3 --- /dev/null +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/20260718210336_AddInboundIsAvailable.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace PnvPanel.Infrastructure.Persistence.Migrations +{ + /// + public partial class AddInboundIsAvailable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsAvailable", + table: "Inbounds", + type: "boolean", + nullable: false, + defaultValue: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsAvailable", + table: "Inbounds"); + } + } +} diff --git a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs index c7667f4..04cc99a 100644 --- a/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs +++ b/backend/src/PnvPanel.Infrastructure/Persistence/Migrations/AppDbContextModelSnapshot.cs @@ -359,6 +359,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations .HasMaxLength(100) .HasColumnType("character varying(100)"); + b.Property("IsAvailable") + .HasColumnType("boolean"); + b.Property("IsPublished") .HasColumnType("boolean"); diff --git a/backend/tests/PnvPanel.Application.Tests/Admin/Nodes/SyncNodeCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Admin/Nodes/SyncNodeCommandHandlerTests.cs new file mode 100644 index 0000000..9507b4f --- /dev/null +++ b/backend/tests/PnvPanel.Application.Tests/Admin/Nodes/SyncNodeCommandHandlerTests.cs @@ -0,0 +1,79 @@ +using NSubstitute; +using PnvPanel.Application.Admin.Nodes; +using PnvPanel.Application.Common.Interfaces; +using PnvPanel.Application.Common.Models; +using PnvPanel.Application.Tests.TestSupport; +using PnvPanel.Domain.Configs; +using PnvPanel.Domain.Inbounds; +using PnvPanel.Domain.Nodes; +using Xunit; + +namespace PnvPanel.Application.Tests.Admin.Nodes; + +public class SyncNodeCommandHandlerTests +{ + private readonly IXuiPanelGateway _gateway = Substitute.For(); + + private static Node CreateNode() => + Node.Register( + "node-1", + new Uri("https://node1.example.com"), + new NodeCredentials("admin", "protected"), + null + ); + + [Fact] + public async Task Handle_WhenInboundGoneFromPanelAndHasNoConfigs_RemovesIt() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var node = CreateNode(); + var inbound = Inbound.FromRemote(node.Id, "old-id", VpnProtocol.Vless, "old", 443); + + dbContext.Nodes.Add(node); + dbContext.Inbounds.Add(inbound); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _gateway + .ListInboundsAsync(Arg.Any(), Arg.Any()) + .Returns( + Result.Success>( + [new RemoteInboundInfo("new-id", VpnProtocol.Vless, "new", 8443)] + ) + ); + + var handler = new SyncNodeCommandHandler(dbContext, _gateway); + var result = await handler.Handle(new SyncNodeCommand(node.Id), CancellationToken.None); + + Assert.True(result.IsSuccess); + Assert.DoesNotContain(dbContext.Inbounds.Local, i => i.RemoteInboundId == "old-id"); + Assert.Contains(dbContext.Inbounds.Local, i => i.RemoteInboundId == "new-id"); + } + + [Fact] + public async Task Handle_WhenInboundGoneFromPanelButHasConfigs_MarksUnavailableInsteadOfRemoving() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var node = CreateNode(); + var inbound = Inbound.FromRemote(node.Id, "old-id", VpnProtocol.Vless, "old", 443); + inbound.Publish("Old", [Guid.NewGuid()]); + var config = VpnConfig.Create(Guid.NewGuid(), inbound.Id, VpnProtocol.Vless, null); + config.AssignRemoteClient("external-id"); + + dbContext.Nodes.Add(node); + dbContext.Inbounds.Add(inbound); + dbContext.VpnConfigs.Add(config); + await dbContext.SaveChangesAsync(CancellationToken.None); + + _gateway + .ListInboundsAsync(Arg.Any(), Arg.Any()) + .Returns(Result.Success>([])); + + var handler = new SyncNodeCommandHandler(dbContext, _gateway); + var result = await handler.Handle(new SyncNodeCommand(node.Id), CancellationToken.None); + + Assert.True(result.IsSuccess); + var stored = Assert.Single(dbContext.Inbounds.Local, i => i.RemoteInboundId == "old-id"); + Assert.False(stored.IsAvailable); + Assert.False(stored.IsPublished); + } +} diff --git a/backend/tests/PnvPanel.Application.Tests/Configs/Revoke/RevokeVpnConfigCommandHandlerTests.cs b/backend/tests/PnvPanel.Application.Tests/Configs/Revoke/RevokeVpnConfigCommandHandlerTests.cs index 0c31ee5..2542af2 100644 --- a/backend/tests/PnvPanel.Application.Tests/Configs/Revoke/RevokeVpnConfigCommandHandlerTests.cs +++ b/backend/tests/PnvPanel.Application.Tests/Configs/Revoke/RevokeVpnConfigCommandHandlerTests.cs @@ -85,6 +85,54 @@ public class RevokeVpnConfigCommandHandlerTests ); } + [Fact] + public async Task Handle_WhenInboundIsUnavailable_RevokesLocallyWithoutCallingGateway() + { + using var dbContext = InMemoryDbContextFactory.Create(); + var userId = 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); + inbound.MarkUnavailable(); + var config = VpnConfig.Create(userId, inbound.Id, VpnProtocol.Vless, null); + config.AssignRemoteClient("external-id"); + + dbContext.Nodes.Add(node); + dbContext.Inbounds.Add(inbound); + dbContext.VpnConfigs.Add(config); + await dbContext.SaveChangesAsync(CancellationToken.None); + + var handler = new RevokeVpnConfigCommandHandler( + dbContext, + _gateway, + _notifier, + FakeCurrentUser.Authenticated(userId), + _logger + ); + + var result = await handler.Handle( + new RevokeVpnConfigCommand(config.Id), + CancellationToken.None + ); + + Assert.True(result.IsSuccess); + Assert.Equal(ConfigStatus.Revoked, config.Status); + await _gateway + .DidNotReceive() + .RemoveClientAsync( + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any(), + Arg.Any() + ); + } + [Fact] public async Task Handle_WhenAlreadyRevoked_IsIdempotentAndDoesNotCallGateway() { diff --git a/backend/tests/PnvPanel.Domain.Tests/Inbounds/InboundTests.cs b/backend/tests/PnvPanel.Domain.Tests/Inbounds/InboundTests.cs index aecbc46..91fe159 100644 --- a/backend/tests/PnvPanel.Domain.Tests/Inbounds/InboundTests.cs +++ b/backend/tests/PnvPanel.Domain.Tests/Inbounds/InboundTests.cs @@ -60,4 +60,24 @@ public class InboundTests Assert.False(inbound.IsPublished); Assert.Contains(roleId, inbound.AllowedRoleIds); } + + [Fact] + public void FromRemote_CreatesAvailableInbound() + { + var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443); + + Assert.True(inbound.IsAvailable); + } + + [Fact] + public void MarkUnavailable_SetsIsAvailableAndIsPublishedFalse() + { + var inbound = Inbound.FromRemote(Guid.NewGuid(), "12", VpnProtocol.Vless, "Germany", 443); + inbound.Publish("Germany", [Guid.NewGuid()]); + + inbound.MarkUnavailable(); + + Assert.False(inbound.IsAvailable); + Assert.False(inbound.IsPublished); + } } diff --git a/docs/architecture.md b/docs/architecture.md index 8ff220a..b601285 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -170,6 +170,9 @@ POST /api/configs к ноде. Cookie-session и авто-переавторизация на 401 обеспечиваются самой `ThreeXui.Net`. - Ошибки панели маппятся в доменные/`Result`-ошибки; недоступная нода → `NodeStatus.Offline`, а не исключение наружу. - Операции мутации по клиентам сериализуются per-inbound (библиотека уже использует мьютексы; на нашей стороне — идемпотентные команды). +- Синхронизация инбаундов ноды (`SyncNodeCommandHandler`) реконсилирует пропажу инбаунда с панели: + без привязанных конфигов запись удаляется, с конфигами — помечается `IsAvailable=false` (детали и + инварианты — [domain-model.md](domain-model.md#inbound--прокси-inbound-на-ноде)). - `BuildConnectionStringAsync` поверх ссылки из `ThreeXui.Net` принудительно подставляет `fp=firefox` (TLS-fingerprint клиента) для tls/reality-ссылок vless/trojan/vmess, независимо от того, что задано в `streamSettings` ноды; shadowsocks (без TLS) и ссылки без security не diff --git a/docs/domain-model.md b/docs/domain-model.md index 12ec71a..7f2f7e1 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -64,6 +64,7 @@ AppUser | `Remark` | `string` | Метка из 3x-ui | | `Port` | `int` | | | `IsPublished` | `bool` | Доступен ли для самообслуживания пользователями | +| `IsAvailable` | `bool` | Существует ли инбаунд на панели по последней синхронизации (см. ниже) | | `AllowedRoleIds` | `Guid[]` | Id ролей, которым разрешено создавать конфиги (native PostgreSQL `uuid[]`; не навигация на `AppRole` — тот в Infrastructure/Identity, Domain на него не ссылается) | | `DisplayName` | `string?` | Витринное имя для пользователя, напр. «Германия (Trojan)» | | `LastSyncAt` | `DateTimeOffset?` | | @@ -73,6 +74,14 @@ AppUser «Германия (Trojan)» → роли `user`, `vip`). Лимита числа клиентов на инбаунд нет — квота ограничивается только на уровне пользователя (`AppRole.MaxConfigs`). +**Синхронизация и пропажа инбаунда с панели** (`SyncNodeCommandHandler`, кнопка «Синхронизировать»): +инбаунд, не пришедший в очередном ответе 3x-ui, считается пропавшим. Если по нему нет ни одного +`VpnConfig` — запись просто удаляется (иначе при пересоздании того же инбаунда на панели под новым +`RemoteInboundId` — 3x-ui не переиспользует id — накапливался бы визуальный дубль). Если конфиги +есть — удалить нельзя (FK), инбаунд помечается `MarkUnavailable()` (`IsAvailable=false`, +`IsPublished=false`): новые конфиги на нём не создать, а `Revoke` для существующих конфигов не бьёт +в панель повторно (см. `RevokeVpnConfigCommandHandler`), а отзывает локально. + > `Node.Status` (health-check раз в 2 минуты, см. `NodeHealthCheckService`) — это диагностический > индикатор для админа, не гейт для создания конфига: он кэшированный и может ложно показывать > `Offline` из-за временного сбоя пробника. Реальную недоступность ноды ловит вызов diff --git a/frontend/src/features/admin/nodes/NodeCard.tsx b/frontend/src/features/admin/nodes/NodeCard.tsx index d1907c5..1c61755 100644 --- a/frontend/src/features/admin/nodes/NodeCard.tsx +++ b/frontend/src/features/admin/nodes/NodeCard.tsx @@ -116,12 +116,18 @@ export function NodeCard({ node }: { node: NodeDto }) { {inbound.remark} · {inbound.protocol} · :{inbound.port}
- - {inbound.isPublished ? t('admin.nodes.published') : t('admin.nodes.unpublished')} + + {inbound.isAvailable + ? inbound.isPublished + ? t('admin.nodes.published') + : t('admin.nodes.unpublished') + : t('admin.nodes.unavailable')} - + {inbound.isAvailable && ( + + )}
))} diff --git a/frontend/src/shared/api/schema.gen.ts b/frontend/src/shared/api/schema.gen.ts index ac3544e..acdefe8 100644 --- a/frontend/src/shared/api/schema.gen.ts +++ b/frontend/src/shared/api/schema.gen.ts @@ -3186,6 +3186,7 @@ export interface components { /** Format: int32 */ port: number | string; isPublished: boolean; + isAvailable: boolean; displayName: null | string; allowedRoleIds: string[]; /** Format: date-time */ diff --git a/frontend/src/shared/api/types.ts b/frontend/src/shared/api/types.ts index 18128fe..2b5398f 100644 --- a/frontend/src/shared/api/types.ts +++ b/frontend/src/shared/api/types.ts @@ -222,6 +222,7 @@ export type InboundDto = { remark: string port: number isPublished: boolean + isAvailable: boolean displayName: string | null allowedRoleIds: string[] lastSyncAt: string | null diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts index 4ad33c7..9f7b486 100644 --- a/frontend/src/shared/lib/i18n.ts +++ b/frontend/src/shared/lib/i18n.ts @@ -316,6 +316,7 @@ const resources = { publish: 'Публикация', published: 'Опубликован', unpublished: 'Не опубликован', + unavailable: 'Недоступен на панели', publishSaved: 'Настройки публикации сохранены.', displayName: 'Отображаемое имя', allowedRoles: 'Доступно ролям', @@ -757,6 +758,7 @@ const resources = { publish: 'Publishing', published: 'Published', unpublished: 'Not published', + unavailable: 'Unavailable on panel', publishSaved: 'Publishing settings saved.', displayName: 'Display name', allowedRoles: 'Allowed for roles',