Add IsAvailable property to Inbound and update related logic
CI / Backend (build + test) (push) Successful in 1m19s
CI / Frontend (lint + typecheck + build) (push) Successful in 35s

- Introduced a new boolean property, `IsAvailable`, to the `Inbound` entity to track the availability status of inbounds based on synchronization results.
- Updated the `SyncNodeCommandHandler` to mark inbounds as unavailable if they are not present in the latest synchronization but have existing configurations, preventing their deletion.
- Enhanced the `MarkUnavailable` method to set both `IsAvailable` and `IsPublished` to false, reflecting the new status accurately.
- Modified the frontend components to display the availability status of inbounds, ensuring users are informed of their current state.
- Updated tests to cover the new behavior regarding inbound availability and its impact on revocation processes.
This commit is contained in:
Leonid Pershin
2026-07-19 00:13:30 +03:00
parent c196e0c322
commit b980dc6cef
16 changed files with 1211 additions and 14 deletions
@@ -10,6 +10,7 @@ public sealed record InboundDto(
string Remark, string Remark,
int Port, int Port,
bool IsPublished, bool IsPublished,
bool IsAvailable,
string? DisplayName, string? DisplayName,
IReadOnlyList<Guid> AllowedRoleIds, IReadOnlyList<Guid> AllowedRoleIds,
DateTimeOffset? LastSyncAt DateTimeOffset? LastSyncAt
@@ -24,6 +25,7 @@ public sealed record InboundDto(
inbound.Remark, inbound.Remark,
inbound.Port, inbound.Port,
inbound.IsPublished, inbound.IsPublished,
inbound.IsAvailable,
inbound.DisplayName, inbound.DisplayName,
inbound.AllowedRoleIds, inbound.AllowedRoleIds,
inbound.LastSyncAt inbound.LastSyncAt
@@ -50,15 +50,30 @@ public sealed class SyncNodeCommandHandler(IAppDbContext dbContext, IXuiPanelGat
); );
} }
// Inbound, пропавший на панели, снимаем с публикации (не удаляем — реконсиляция дрейфа, // Inbound, пропавший на панели: если по нему нет конфигов — запись просто мусор, удаляем
// см. architecture.md); новые конфиги на нём создать будет нельзя, старые не трогаем. // (иначе она копится дублем при пересоздании инбаунда на той же панели под новым
// RemoteInboundId — 3x-ui id не переиспользует). Если конфиги есть — удалить нельзя (FK),
// помечаем недоступной: новые конфиги на нём создать нельзя, Revoke не бьёт в мёртвую панель.
var remoteIds = remoteResult.Value.Select(r => r.RemoteInboundId).ToHashSet(); var remoteIds = remoteResult.Value.Select(r => r.RemoteInboundId).ToHashSet();
foreach ( var stale = existing.Where(i => !remoteIds.Contains(i.RemoteInboundId)).ToList();
var stale in existing.Where(i => if (stale.Count > 0)
i.IsPublished && !remoteIds.Contains(i.RemoteInboundId) {
) var staleIds = stale.Select(i => i.Id).ToList();
) var staleIdsWithConfigs = await dbContext
stale.Unpublish(); .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.UpdateStatus(NodeStatus.Online);
node.MarkSynced(); node.MarkSynced();
@@ -43,7 +43,9 @@ public sealed class RevokeVpnConfigCommandHandler(
.Nodes.AsNoTracking() .Nodes.AsNoTracking()
.FirstOrDefaultAsync(n => n.Id == inbound.NodeId, cancellationToken); .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( var removeResult = await gateway.RemoveClientAsync(
node, node,
@@ -16,6 +16,7 @@ public sealed class Inbound : Entity
public string Remark { get; private set; } = string.Empty; public string Remark { get; private set; } = string.Empty;
public int Port { get; private set; } public int Port { get; private set; }
public bool IsPublished { get; private set; } public bool IsPublished { get; private set; }
public bool IsAvailable { get; private set; } = true;
public string? DisplayName { get; private set; } public string? DisplayName { get; private set; }
public IReadOnlyList<Guid> AllowedRoleIds { get; private set; } = []; public IReadOnlyList<Guid> AllowedRoleIds { get; private set; } = [];
public DateTimeOffset? LastSyncAt { get; private set; } public DateTimeOffset? LastSyncAt { get; private set; }
@@ -39,6 +40,7 @@ public sealed class Inbound : Entity
Remark = remark, Remark = remark,
Port = port, Port = port,
IsPublished = false, IsPublished = false,
IsAvailable = true,
LastSyncAt = DateTimeOffset.UtcNow, LastSyncAt = DateTimeOffset.UtcNow,
}; };
} }
@@ -59,4 +61,15 @@ public sealed class Inbound : Entity
} }
public void Unpublish() => IsPublished = false; public void Unpublish() => IsPublished = false;
/// <summary>
/// Инбаунд пропал на панели (не пришёл в очередной синхронизации), но по нему есть конфиги —
/// удалить запись нельзя (FK), поэтому помечаем недоступной вместо тихого "не опубликован":
/// UI показывает явный статус, а Revoke пропускает обращение к панели как заведомо мёртвое.
/// </summary>
public void MarkUnavailable()
{
IsAvailable = false;
IsPublished = false;
}
} }
@@ -0,0 +1,964 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("RoleId");
b.ToTable("AspNetRoleClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id"));
b.Property<string>("ClaimType")
.HasColumnType("text");
b.Property<string>("ClaimValue")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId");
b.ToTable("AspNetUserClaims", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("ProviderKey")
.HasColumnType("text");
b.Property<string>("ProviderDisplayName")
.HasColumnType("text");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("LoginProvider", "ProviderKey");
b.HasIndex("UserId");
b.ToTable("AspNetUserLogins", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<Guid>("RoleId")
.HasColumnType("uuid");
b.HasKey("UserId", "RoleId");
b.HasIndex("RoleId");
b.ToTable("AspNetUserRoles", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<System.Guid>", b =>
{
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.Property<string>("LoginProvider")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.Property<string>("Value")
.HasColumnType("text");
b.HasKey("UserId", "LoginProvider", "Name");
b.ToTable("AspNetUserTokens", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Activation.ActivationRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Comment")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("DecidedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("DecidedBy")
.HasColumnType("uuid");
b.Property<string>("RejectionReason")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("UserId", "Status");
b.ToTable("ActivationRequests", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Apps.ClientApp", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Description")
.HasMaxLength(300)
.HasColumnType("character varying(300)");
b.Property<string>("DownloadUrl")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<string>("IconUrl")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<bool>("IsRecommended")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("OperatingSystem")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.HasKey("Id");
b.ToTable("ClientApps", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Audit.AuditLog", b =>
{
b.Property<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<string>("Action")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid?>("ActorId")
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Metadata")
.HasColumnType("jsonb");
b.Property<string>("Source")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("TargetId")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("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<long>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("bigint");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<long>("Id"));
b.Property<Guid>("ConfigId")
.HasColumnType("uuid");
b.Property<long>("DownBytes")
.HasColumnType("bigint");
b.Property<DateTimeOffset>("Timestamp")
.HasColumnType("timestamp with time zone");
b.Property<long>("UpBytes")
.HasColumnType("bigint");
b.HasKey("Id");
b.HasIndex("ConfigId", "Timestamp");
b.ToTable("TrafficSamples", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Configs.VpnConfig", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ClientEmail")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("ClientExternalId")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset?>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("InboundId")
.HasColumnType("uuid");
b.Property<string>("Label")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTimeOffset?>("LastSyncAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Protocol")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("SubscriptionToken")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<long>("UsedDownBytes")
.HasColumnType("bigint");
b.Property<long>("UsedUpBytes")
.HasColumnType("bigint");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.PrimitiveCollection<Guid[]>("AllowedRoleIds")
.IsRequired()
.HasColumnType("uuid[]");
b.Property<string>("DisplayName")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<bool>("IsAvailable")
.HasColumnType("boolean");
b.Property<bool>("IsPublished")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LastSyncAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("NodeId")
.HasColumnType("uuid");
b.Property<int>("Port")
.HasColumnType("integer");
b.Property<string>("Protocol")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Remark")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(20000)
.HasColumnType("character varying(20000)");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("InstructionIntros", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Instructions.InstructionTab", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(20000)
.HasColumnType("character varying(20000)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int>("SortOrder")
.HasColumnType("integer");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTimeOffset?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(20000)
.HasColumnType("character varying(20000)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Title")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTimeOffset?>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("BaseAddress")
.IsRequired()
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LastSyncAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Location")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int?>("PricePerConfigPerHalfYear")
.HasColumnType("integer");
b.Property<int?>("PricePerConfigPerQuarter")
.HasColumnType("integer");
b.Property<int?>("PricePerConfigPerYear")
.HasColumnType("integer");
b.Property<DateTimeOffset>("UpdatedAt")
.HasColumnType("timestamp with time zone");
b.HasKey("Id");
b.ToTable("PricingSettings", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Support.SupportTicket", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("ProposedMaxConfigs")
.HasColumnType("integer");
b.Property<int?>("ProposedMaxIpLimit")
.HasColumnType("integer");
b.Property<string>("ProposedRoleName")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid?>("RequestedRoleId")
.HasColumnType("uuid");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<string>("Type")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("CommentId")
.HasColumnType("uuid");
b.Property<string>("ContentType")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("FileName")
.IsRequired()
.HasMaxLength(255)
.HasColumnType("character varying(255)");
b.Property<long>("SizeBytes")
.HasColumnType("bigint");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<Guid>("AuthorId")
.HasColumnType("uuid");
b.Property<string>("Body")
.IsRequired()
.HasMaxLength(4000)
.HasColumnType("character varying(4000)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("TicketId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TicketId", "CreatedAt");
b.ToTable("TicketComments", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLinkToken", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset?>("ConsumedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Token")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("Token")
.IsUnique();
b.ToTable("TelegramLinkTokens", (string)null);
});
modelBuilder.Entity("PnvPanel.Domain.Telegram.TelegramLoginRequest", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("Context")
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("Status")
.IsRequired()
.HasMaxLength(32)
.HasColumnType("character varying(32)");
b.Property<Guid?>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.ToTable("TelegramLoginRequests", (string)null);
});
modelBuilder.Entity("PnvPanel.Infrastructure.Identity.AppRole", b =>
{
b.Property<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<int>("MaxConfigs")
.HasColumnType("integer");
b.Property<int>("MaxIpLimit")
.HasColumnType("integer");
b.Property<string>("Name")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<int>("AccessFailedCount")
.HasColumnType("integer");
b.Property<DateTimeOffset?>("ActivatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid?>("ActivatedBy")
.HasColumnType("uuid");
b.Property<string>("ConcurrencyStamp")
.IsConcurrencyToken()
.HasColumnType("text");
b.Property<string>("Email")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<bool>("EmailConfirmed")
.HasColumnType("boolean");
b.Property<bool>("IsActivated")
.HasColumnType("boolean");
b.Property<bool>("IsBlocked")
.HasColumnType("boolean");
b.Property<bool>("LockoutEnabled")
.HasColumnType("boolean");
b.Property<DateTimeOffset?>("LockoutEnd")
.HasColumnType("timestamp with time zone");
b.Property<string>("NormalizedEmail")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("NormalizedUserName")
.HasMaxLength(256)
.HasColumnType("character varying(256)");
b.Property<string>("PasswordHash")
.HasColumnType("text");
b.Property<string>("PhoneNumber")
.HasColumnType("text");
b.Property<bool>("PhoneNumberConfirmed")
.HasColumnType("boolean");
b.Property<string>("SecurityStamp")
.HasColumnType("text");
b.Property<string>("SubscriptionToken")
.IsRequired()
.HasColumnType("text");
b.Property<DateTimeOffset?>("TelegramLinkedAt")
.HasColumnType("timestamp with time zone");
b.Property<long?>("TelegramUserId")
.HasColumnType("bigint");
b.Property<string>("TelegramUsername")
.HasColumnType("text");
b.Property<bool>("TwoFactorEnabled")
.HasColumnType("boolean");
b.Property<string>("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<Guid>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTimeOffset>("ExpiresAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("ReplacedByTokenHash")
.HasColumnType("text");
b.Property<DateTimeOffset?>("RevokedAt")
.HasColumnType("timestamp with time zone");
b.Property<string>("TokenHash")
.IsRequired()
.HasMaxLength(128)
.HasColumnType("character varying(128)");
b.Property<Guid>("UserId")
.HasColumnType("uuid");
b.HasKey("Id");
b.HasIndex("TokenHash")
.IsUnique();
b.HasIndex("UserId");
b.ToTable("RefreshTokens", (string)null);
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<System.Guid>", b =>
{
b.HasOne("PnvPanel.Infrastructure.Identity.AppRole", null)
.WithMany()
.HasForeignKey("RoleId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<System.Guid>", b =>
{
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<System.Guid>", b =>
{
b.HasOne("PnvPanel.Infrastructure.Identity.AppUser", null)
.WithMany()
.HasForeignKey("UserId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<System.Guid>", 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<System.Guid>", 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<Guid>("NodeId")
.HasColumnType("uuid");
b1.Property<string>("ProtectedPassword")
.IsRequired()
.HasColumnType("text")
.HasColumnName("CredentialsProtectedPassword");
b1.Property<string>("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
}
}
}
@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace PnvPanel.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddInboundIsAvailable : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsAvailable",
table: "Inbounds",
type: "boolean",
nullable: false,
defaultValue: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsAvailable",
table: "Inbounds");
}
}
}
@@ -359,6 +359,9 @@ namespace PnvPanel.Infrastructure.Persistence.Migrations
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("character varying(100)"); .HasColumnType("character varying(100)");
b.Property<bool>("IsAvailable")
.HasColumnType("boolean");
b.Property<bool>("IsPublished") b.Property<bool>("IsPublished")
.HasColumnType("boolean"); .HasColumnType("boolean");
@@ -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<IXuiPanelGateway>();
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<Node>(), Arg.Any<CancellationToken>())
.Returns(
Result.Success<IReadOnlyList<RemoteInboundInfo>>(
[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<Node>(), Arg.Any<CancellationToken>())
.Returns(Result.Success<IReadOnlyList<RemoteInboundInfo>>([]));
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);
}
}
@@ -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<Node>(),
Arg.Any<string>(),
Arg.Any<string>(),
Arg.Any<VpnProtocol>(),
Arg.Any<CancellationToken>()
);
}
[Fact] [Fact]
public async Task Handle_WhenAlreadyRevoked_IsIdempotentAndDoesNotCallGateway() public async Task Handle_WhenAlreadyRevoked_IsIdempotentAndDoesNotCallGateway()
{ {
@@ -60,4 +60,24 @@ public class InboundTests
Assert.False(inbound.IsPublished); Assert.False(inbound.IsPublished);
Assert.Contains(roleId, inbound.AllowedRoleIds); 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);
}
} }
+3
View File
@@ -170,6 +170,9 @@ POST /api/configs
к ноде. Cookie-session и авто-переавторизация на 401 обеспечиваются самой `ThreeXui.Net`. к ноде. Cookie-session и авто-переавторизация на 401 обеспечиваются самой `ThreeXui.Net`.
- Ошибки панели маппятся в доменные/`Result`-ошибки; недоступная нода → `NodeStatus.Offline`, а не исключение наружу. - Ошибки панели маппятся в доменные/`Result`-ошибки; недоступная нода → `NodeStatus.Offline`, а не исключение наружу.
- Операции мутации по клиентам сериализуются per-inbound (библиотека уже использует мьютексы; на нашей стороне — идемпотентные команды). - Операции мутации по клиентам сериализуются per-inbound (библиотека уже использует мьютексы; на нашей стороне — идемпотентные команды).
- Синхронизация инбаундов ноды (`SyncNodeCommandHandler`) реконсилирует пропажу инбаунда с панели:
без привязанных конфигов запись удаляется, с конфигами — помечается `IsAvailable=false` (детали и
инварианты — [domain-model.md](domain-model.md#inbound--прокси-inbound-на-ноде)).
- `BuildConnectionStringAsync` поверх ссылки из `ThreeXui.Net` принудительно подставляет - `BuildConnectionStringAsync` поверх ссылки из `ThreeXui.Net` принудительно подставляет
`fp=firefox` (TLS-fingerprint клиента) для tls/reality-ссылок vless/trojan/vmess, независимо `fp=firefox` (TLS-fingerprint клиента) для tls/reality-ссылок vless/trojan/vmess, независимо
от того, что задано в `streamSettings` ноды; shadowsocks (без TLS) и ссылки без security не от того, что задано в `streamSettings` ноды; shadowsocks (без TLS) и ссылки без security не
+9
View File
@@ -64,6 +64,7 @@ AppUser
| `Remark` | `string` | Метка из 3x-ui | | `Remark` | `string` | Метка из 3x-ui |
| `Port` | `int` | | | `Port` | `int` | |
| `IsPublished` | `bool` | Доступен ли для самообслуживания пользователями | | `IsPublished` | `bool` | Доступен ли для самообслуживания пользователями |
| `IsAvailable` | `bool` | Существует ли инбаунд на панели по последней синхронизации (см. ниже) |
| `AllowedRoleIds` | `Guid[]` | Id ролей, которым разрешено создавать конфиги (native PostgreSQL `uuid[]`; не навигация на `AppRole` — тот в Infrastructure/Identity, Domain на него не ссылается) | | `AllowedRoleIds` | `Guid[]` | Id ролей, которым разрешено создавать конфиги (native PostgreSQL `uuid[]`; не навигация на `AppRole` — тот в Infrastructure/Identity, Domain на него не ссылается) |
| `DisplayName` | `string?` | Витринное имя для пользователя, напр. «Германия (Trojan)» | | `DisplayName` | `string?` | Витринное имя для пользователя, напр. «Германия (Trojan)» |
| `LastSyncAt` | `DateTimeOffset?` | | | `LastSyncAt` | `DateTimeOffset?` | |
@@ -73,6 +74,14 @@ AppUser
«Германия (Trojan)» → роли `user`, `vip`). Лимита числа клиентов на инбаунд нет — квота «Германия (Trojan)» → роли `user`, `vip`). Лимита числа клиентов на инбаунд нет — квота
ограничивается только на уровне пользователя (`AppRole.MaxConfigs`). ограничивается только на уровне пользователя (`AppRole.MaxConfigs`).
**Синхронизация и пропажа инбаунда с панели** (`SyncNodeCommandHandler`, кнопка «Синхронизировать»):
инбаунд, не пришедший в очередном ответе 3x-ui, считается пропавшим. Если по нему нет ни одного
`VpnConfig` — запись просто удаляется (иначе при пересоздании того же инбаунда на панели под новым
`RemoteInboundId` — 3x-ui не переиспользует id — накапливался бы визуальный дубль). Если конфиги
есть — удалить нельзя (FK), инбаунд помечается `MarkUnavailable()` (`IsAvailable=false`,
`IsPublished=false`): новые конфиги на нём не создать, а `Revoke` для существующих конфигов не бьёт
в панель повторно (см. `RevokeVpnConfigCommandHandler`), а отзывает локально.
> `Node.Status` (health-check раз в 2 минуты, см. `NodeHealthCheckService`) — это диагностический > `Node.Status` (health-check раз в 2 минуты, см. `NodeHealthCheckService`) — это диагностический
> индикатор для админа, не гейт для создания конфига: он кэшированный и может ложно показывать > индикатор для админа, не гейт для создания конфига: он кэшированный и может ложно показывать
> `Offline` из-за временного сбоя пробника. Реальную недоступность ноды ловит вызов > `Offline` из-за временного сбоя пробника. Реальную недоступность ноды ловит вызов
+11 -5
View File
@@ -116,12 +116,18 @@ export function NodeCard({ node }: { node: NodeDto }) {
{inbound.remark} · {inbound.protocol} · :{inbound.port} {inbound.remark} · {inbound.protocol} · :{inbound.port}
</span> </span>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant={inbound.isPublished ? 'success' : 'outline'}> <Badge variant={inbound.isAvailable ? (inbound.isPublished ? 'success' : 'outline') : 'destructive'}>
{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')}
</Badge> </Badge>
<Button size="sm" variant="outline" onClick={() => setPublishing(inbound)}> {inbound.isAvailable && (
{t('admin.nodes.publish')} <Button size="sm" variant="outline" onClick={() => setPublishing(inbound)}>
</Button> {t('admin.nodes.publish')}
</Button>
)}
</div> </div>
</div> </div>
))} ))}
+1
View File
@@ -3186,6 +3186,7 @@ export interface components {
/** Format: int32 */ /** Format: int32 */
port: number | string; port: number | string;
isPublished: boolean; isPublished: boolean;
isAvailable: boolean;
displayName: null | string; displayName: null | string;
allowedRoleIds: string[]; allowedRoleIds: string[];
/** Format: date-time */ /** Format: date-time */
+1
View File
@@ -222,6 +222,7 @@ export type InboundDto = {
remark: string remark: string
port: number port: number
isPublished: boolean isPublished: boolean
isAvailable: boolean
displayName: string | null displayName: string | null
allowedRoleIds: string[] allowedRoleIds: string[]
lastSyncAt: string | null lastSyncAt: string | null
+2
View File
@@ -316,6 +316,7 @@ const resources = {
publish: 'Публикация', publish: 'Публикация',
published: 'Опубликован', published: 'Опубликован',
unpublished: 'Не опубликован', unpublished: 'Не опубликован',
unavailable: 'Недоступен на панели',
publishSaved: 'Настройки публикации сохранены.', publishSaved: 'Настройки публикации сохранены.',
displayName: 'Отображаемое имя', displayName: 'Отображаемое имя',
allowedRoles: 'Доступно ролям', allowedRoles: 'Доступно ролям',
@@ -757,6 +758,7 @@ const resources = {
publish: 'Publishing', publish: 'Publishing',
published: 'Published', published: 'Published',
unpublished: 'Not published', unpublished: 'Not published',
unavailable: 'Unavailable on panel',
publishSaved: 'Publishing settings saved.', publishSaved: 'Publishing settings saved.',
displayName: 'Display name', displayName: 'Display name',
allowedRoles: 'Allowed for roles', allowedRoles: 'Allowed for roles',