From df1675432694495f0875fac065bb63996ebb5928 Mon Sep 17 00:00:00 2001 From: GZTime Date: Wed, 9 Aug 2023 22:02:33 +0800 Subject: [PATCH 01/67] feat(proxy): init controller --- src/GZCTF/Controllers/ProxyController.cs | 93 ++++++++++++++++++++++++ src/GZCTF/Models/AppDbContext.cs | 2 + 2 files changed, 95 insertions(+) create mode 100644 src/GZCTF/Controllers/ProxyController.cs diff --git a/src/GZCTF/Controllers/ProxyController.cs b/src/GZCTF/Controllers/ProxyController.cs new file mode 100644 index 000000000..4b119b859 --- /dev/null +++ b/src/GZCTF/Controllers/ProxyController.cs @@ -0,0 +1,93 @@ + +using System.Net.Sockets; +using System.Net.WebSockets; +using GZCTF.Repositories.Interface; +using GZCTF.Utils; +using Microsoft.AspNetCore.Mvc; + +namespace GZCTF.Controllers; + +/// +/// 容器 TCP 流量代理、记录 +/// +[ApiController] +[Route("api/[controller]")] +public class ProxyController : ControllerBase +{ + private readonly ILogger _logger; + private readonly IContainerRepository _containerRepository; + private const int BufferSize = 1024 * 4; + + public ProxyController(ILogger logger, IContainerRepository containerRepository) + { + _logger = logger; + _containerRepository = containerRepository; + } + + /// + /// 采用 websocket 代理 TCP 流量 + /// + /// + [Route("/inst/{id}")] + [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status400BadRequest)] + public async Task ProxyForInstance(string id) + { + if (!HttpContext.WebSockets.IsWebSocketRequest) + return BadRequest(new RequestResponse("仅支持 Websocket")); + + var container = await _containerRepository.GetContainerById(id); + + if (container is null) + return NotFound(new RequestResponse("不存在的容器")); + + var socket = new Socket(AddressFamily.Unspecified, SocketType.Stream, ProtocolType.Tcp); + await socket.ConnectAsync(container.IP, container.Port); + + if(!socket.Connected) + return BadRequest(new RequestResponse("容器连接失败")); + + var ws = await HttpContext.WebSockets.AcceptWebSocketAsync(); + var stream = new NetworkStream(socket); + + var cts = new CancellationTokenSource(); + var ct = cts.Token; + + var sender = Task.Run(async () => + { + var buffer = new byte[BufferSize]; + while (true) + { + var status = await ws.ReceiveAsync(buffer, ct); + if (status.Count == 0) + { + cts.Cancel(); + break; + } + await stream.WriteAsync(buffer, 0, status.Count, ct); + } + }, ct); + + var receiver = Task.Run(async () => + { + var buffer = new byte[BufferSize]; + while (true) + { + var count = await stream.ReadAsync(buffer, 0, buffer.Length, ct); + if (count == 0) + { + cts.Cancel(); + break; + } + await ws.SendAsync(new ArraySegment(buffer, 0, count), WebSocketMessageType.Binary, true, ct); + } + }, ct); + + await Task.WhenAny(sender, receiver); + + await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", ct); + await stream.DisposeAsync(); + + return Ok(); + } +} diff --git a/src/GZCTF/Models/AppDbContext.cs b/src/GZCTF/Models/AppDbContext.cs index a3fbac7b0..368c9eb3e 100644 --- a/src/GZCTF/Models/AppDbContext.cs +++ b/src/GZCTF/Models/AppDbContext.cs @@ -226,6 +226,8 @@ protected override void OnModelCreating(ModelBuilder builder) .OnDelete(DeleteBehavior.SetNull); entity.HasIndex(e => e.InstanceId); + + entity.Navigation(e => e.Instance).AutoInclude(); }); builder.Entity(entity => From 1c42be530793611ff3966b7dfb7c684b20accd0f Mon Sep 17 00:00:00 2001 From: GZTime Date: Sat, 12 Aug 2023 18:04:48 +0800 Subject: [PATCH 02/67] wip: migrate file path --- src/GZCTF/Controllers/AssetsController.cs | 13 ++++--------- src/GZCTF/Program.cs | 10 +++++++--- src/GZCTF/Utils/LogHelper.cs | 4 ++-- 3 files changed, 13 insertions(+), 14 deletions(-) diff --git a/src/GZCTF/Controllers/AssetsController.cs b/src/GZCTF/Controllers/AssetsController.cs index dd8b98631..30c289b18 100644 --- a/src/GZCTF/Controllers/AssetsController.cs +++ b/src/GZCTF/Controllers/AssetsController.cs @@ -16,20 +16,16 @@ namespace GZCTF.Controllers; [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status403Forbidden)] public class AssetsController : ControllerBase { + private const string BasePath = "files/uploads"; + private readonly ILogger logger; private readonly IFileRepository fileRepository; - private readonly IConfiguration configuration; - private readonly string basepath; private readonly FileExtensionContentTypeProvider extProvider = new(); - public AssetsController(IFileRepository _fileeService, - IConfiguration _configuration, - ILogger _logger) + public AssetsController(IFileRepository _fileeService, ILogger _logger) { fileRepository = _fileeService; - configuration = _configuration; logger = _logger; - basepath = configuration.GetSection("UploadFolder").Value ?? "uploads"; } /// @@ -48,8 +44,7 @@ public AssetsController(IFileRepository _fileeService, [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status400BadRequest)] public IActionResult GetFile([RegularExpression("[0-9a-f]{64}")] string hash, string filename) { - var path = $"{hash[..2]}/{hash[2..4]}/{hash}"; - path = Path.GetFullPath(Path.Combine(basepath, path)); + var path = Path.GetFullPath(Path.Combine(BasePath, hash[..2], hash[2..4], hash)); if (!System.IO.File.Exists(path)) { diff --git a/src/GZCTF/Program.cs b/src/GZCTF/Program.cs index d95d722be..79c78a699 100644 --- a/src/GZCTF/Program.cs +++ b/src/GZCTF/Program.cs @@ -28,10 +28,14 @@ #region Directory -var uploadPath = Path.Combine(builder.Configuration.GetSection("UploadFolder").Value ?? "uploads"); +var dirs = new[] { "logs", "uploads", "capture" }; -if (!Directory.Exists(uploadPath)) - Directory.CreateDirectory(uploadPath); +foreach (var dir in dirs) +{ + var path = Path.Combine("files", dir); + if (!Directory.Exists(path)) + Directory.CreateDirectory(path); +} #endregion Directory diff --git a/src/GZCTF/Utils/LogHelper.cs b/src/GZCTF/Utils/LogHelper.cs index 22102b06f..4bcc5c0ca 100644 --- a/src/GZCTF/Utils/LogHelper.cs +++ b/src/GZCTF/Utils/LogHelper.cs @@ -138,14 +138,14 @@ public static Serilog.ILogger GetLogger(IConfiguration configuration, IServicePr restrictedToMinimumLevel: LogEventLevel.Debug )) .WriteTo.Async(t => t.File( - path: "log/log_.log", + path: "files/logs/log_.log", formatter: new ExpressionTemplate(LogTemplate), rollingInterval: RollingInterval.Day, fileSizeLimitBytes: 10 * 1024 * 1024, restrictedToMinimumLevel: LogEventLevel.Debug, rollOnFileSizeLimit: true, retainedFileCountLimit: 5, - hooks: new ArchiveHooks(CompressionLevel.Optimal, "log/archive/{UtcDate:yyyy-MM}") + hooks: new ArchiveHooks(CompressionLevel.Optimal, "files/logs/archive/{UtcDate:yyyy-MM}") )) .WriteTo.Async(t => t.PostgreSQL( connectionString: configuration.GetConnectionString("Database"), From d9dfced9557bf69fde2a422f1ecbe2f4688fca7e Mon Sep 17 00:00:00 2001 From: GZTime Date: Sun, 13 Aug 2023 23:10:55 +0800 Subject: [PATCH 03/67] wip: add PacketDotNet --- src/GZCTF/Controllers/ProxyController.cs | 8 +++++--- src/GZCTF/GZCTF.csproj | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/GZCTF/Controllers/ProxyController.cs b/src/GZCTF/Controllers/ProxyController.cs index 4b119b859..ee858ac8c 100644 --- a/src/GZCTF/Controllers/ProxyController.cs +++ b/src/GZCTF/Controllers/ProxyController.cs @@ -4,6 +4,8 @@ using GZCTF.Repositories.Interface; using GZCTF.Utils; using Microsoft.AspNetCore.Mvc; +using ProtocolType = System.Net.Sockets.ProtocolType; +using PacketDotNet; namespace GZCTF.Controllers; @@ -34,7 +36,7 @@ public ProxyController(ILogger logger, IContainerRepository con public async Task ProxyForInstance(string id) { if (!HttpContext.WebSockets.IsWebSocketRequest) - return BadRequest(new RequestResponse("仅支持 Websocket")); + return BadRequest(new RequestResponse("仅支持 Websocket 请求")); var container = await _containerRepository.GetContainerById(id); @@ -64,7 +66,7 @@ public async Task ProxyForInstance(string id) cts.Cancel(); break; } - await stream.WriteAsync(buffer, 0, status.Count, ct); + await stream.WriteAsync(buffer.AsMemory(0, status.Count), ct); } }, ct); @@ -73,7 +75,7 @@ public async Task ProxyForInstance(string id) var buffer = new byte[BufferSize]; while (true) { - var count = await stream.ReadAsync(buffer, 0, buffer.Length, ct); + var count = await stream.ReadAsync(buffer, ct); if (count == 0) { cts.Cancel(); diff --git a/src/GZCTF/GZCTF.csproj b/src/GZCTF/GZCTF.csproj index 18bec6b06..40c599f1b 100644 --- a/src/GZCTF/GZCTF.csproj +++ b/src/GZCTF/GZCTF.csproj @@ -62,6 +62,7 @@ + From 19241eda0e524daf3ee2bd1a45b311f880cc6a5f Mon Sep 17 00:00:00 2001 From: GZTime Date: Mon, 14 Aug 2023 02:11:29 +0800 Subject: [PATCH 04/67] wip: container service abstraction --- src/GZCTF/Controllers/EditController.cs | 4 +- src/GZCTF/GZCTF.csproj | 3 - src/GZCTF/Models/AppDbContext.cs | 3 +- src/GZCTF/Models/Data/Container.cs | 2 +- src/GZCTF/Models/Internal/ContainerConfig.cs | 4 +- src/GZCTF/Program.cs | 11 +- src/GZCTF/Repositories/InstanceRepository.cs | 4 +- .../Container/ContainerServiceExtension.cs | 43 +++ .../Container/Manager/DockerManager.cs | 125 +++++++ .../Services/Container/Manager/K8sManager.cs | 159 +++++++++ .../Container/Manager/SwarmManager.cs | 139 ++++++++ .../PortMapper/DockerDirectMapper.cs | 79 +++++ .../Container/PortMapper/K8sNodePortMapper.cs | 95 ++++++ .../Container/PortMapper/SwarmDirectMapper.cs | 61 ++++ .../Container/Provider/DockerProvider.cs | 70 ++++ .../Container/Provider/K8sProvider.cs | 161 +++++++++ src/GZCTF/Services/CronJobService.cs | 2 +- src/GZCTF/Services/DockerService.cs | 295 ---------------- ...ntainerService.cs => IContainerManager.cs} | 2 +- .../Services/Interface/IContainerProvider.cs | 17 + src/GZCTF/Services/Interface/IPortMapper.cs | 23 ++ src/GZCTF/Services/K8sService.cs | 323 ------------------ 22 files changed, 985 insertions(+), 640 deletions(-) create mode 100644 src/GZCTF/Services/Container/ContainerServiceExtension.cs create mode 100644 src/GZCTF/Services/Container/Manager/DockerManager.cs create mode 100644 src/GZCTF/Services/Container/Manager/K8sManager.cs create mode 100644 src/GZCTF/Services/Container/Manager/SwarmManager.cs create mode 100644 src/GZCTF/Services/Container/PortMapper/DockerDirectMapper.cs create mode 100644 src/GZCTF/Services/Container/PortMapper/K8sNodePortMapper.cs create mode 100644 src/GZCTF/Services/Container/PortMapper/SwarmDirectMapper.cs create mode 100644 src/GZCTF/Services/Container/Provider/DockerProvider.cs create mode 100644 src/GZCTF/Services/Container/Provider/K8sProvider.cs delete mode 100644 src/GZCTF/Services/DockerService.cs rename src/GZCTF/Services/Interface/{IContainerService.cs => IContainerManager.cs} (94%) create mode 100644 src/GZCTF/Services/Interface/IContainerProvider.cs create mode 100644 src/GZCTF/Services/Interface/IPortMapper.cs delete mode 100644 src/GZCTF/Services/K8sService.cs diff --git a/src/GZCTF/Controllers/EditController.cs b/src/GZCTF/Controllers/EditController.cs index c075e76a1..e8f1e5fd7 100644 --- a/src/GZCTF/Controllers/EditController.cs +++ b/src/GZCTF/Controllers/EditController.cs @@ -30,7 +30,7 @@ public class EditController : Controller private readonly IGameRepository _gameRepository; private readonly IChallengeRepository _challengeRepository; private readonly IFileRepository _fileService; - private readonly IContainerService _containerService; + private readonly IContainerManager _containerService; private readonly IContainerRepository _containerRepository; public EditController(UserManager userManager, @@ -40,7 +40,7 @@ public EditController(UserManager userManager, IChallengeRepository challengeRepository, IGameNoticeRepository gameNoticeRepository, IGameRepository gameRepository, - IContainerService containerService, + IContainerManager containerService, IFileRepository fileService) { _logger = logger; diff --git a/src/GZCTF/GZCTF.csproj b/src/GZCTF/GZCTF.csproj index 40c599f1b..201112aee 100644 --- a/src/GZCTF/GZCTF.csproj +++ b/src/GZCTF/GZCTF.csproj @@ -102,9 +102,6 @@ - - - diff --git a/src/GZCTF/Models/AppDbContext.cs b/src/GZCTF/Models/AppDbContext.cs index 368c9eb3e..8ecd613bf 100644 --- a/src/GZCTF/Models/AppDbContext.cs +++ b/src/GZCTF/Models/AppDbContext.cs @@ -227,7 +227,8 @@ protected override void OnModelCreating(ModelBuilder builder) entity.HasIndex(e => e.InstanceId); - entity.Navigation(e => e.Instance).AutoInclude(); + // FIXME: Which API will be affected by AutoInclude + //entity.Navigation(e => e.Instance).AutoInclude(); }); builder.Entity(entity => diff --git a/src/GZCTF/Models/Data/Container.cs b/src/GZCTF/Models/Data/Container.cs index 8aa27ae6d..4adf82c8c 100644 --- a/src/GZCTF/Models/Data/Container.cs +++ b/src/GZCTF/Models/Data/Container.cs @@ -70,7 +70,7 @@ public class Container /// 容器实例访问方式 /// [NotMapped] - public string Entry => $"{PublicIP ?? IP}:{PublicPort ?? Port}"; + public string Entry => IsProxy ? Id : $"{PublicIP ?? IP}:{PublicPort ?? Port}"; #region Db Relationship diff --git a/src/GZCTF/Models/Internal/ContainerConfig.cs b/src/GZCTF/Models/Internal/ContainerConfig.cs index 3fc335df3..389109ea4 100644 --- a/src/GZCTF/Models/Internal/ContainerConfig.cs +++ b/src/GZCTF/Models/Internal/ContainerConfig.cs @@ -1,4 +1,6 @@ -namespace GZCTF.Models.Internal; +using GZCTF.Utils; + +namespace GZCTF.Models.Internal; public class ContainerConfig { diff --git a/src/GZCTF/Program.cs b/src/GZCTF/Program.cs index 79c78a699..55960d273 100644 --- a/src/GZCTF/Program.cs +++ b/src/GZCTF/Program.cs @@ -205,16 +205,7 @@ builder.Services.Configure(forwardedOptions.ToForwardedHeadersOptions); } -if (builder.Configuration.GetSection(nameof(ContainerProvider)) - .GetValue(nameof(ContainerProvider.Type)) - is ContainerProviderType.Kubernetes) -{ - builder.Services.AddSingleton(); -} -else -{ - builder.Services.AddSingleton(); -} +builder.Services.AddContainerService(builder.Configuration); builder.Services.AddScoped(); builder.Services.AddScoped(); diff --git a/src/GZCTF/Repositories/InstanceRepository.cs b/src/GZCTF/Repositories/InstanceRepository.cs index 64ee00c39..a62711851 100644 --- a/src/GZCTF/Repositories/InstanceRepository.cs +++ b/src/GZCTF/Repositories/InstanceRepository.cs @@ -9,7 +9,7 @@ namespace GZCTF.Repositories; public class InstanceRepository : RepositoryBase, IInstanceRepository { - private readonly IContainerService _service; + private readonly IContainerManager _service; private readonly ICheatInfoRepository _cheatInfoRepository; private readonly IContainerRepository _containerRepository; private readonly IGameEventRepository _gameEventRepository; @@ -17,7 +17,7 @@ public class InstanceRepository : RepositoryBase, IInstanceRepository private readonly IOptionsSnapshot _gamePolicy; public InstanceRepository(AppDbContext context, - IContainerService service, + IContainerManager service, ICheatInfoRepository cheatInfoRepository, IContainerRepository containerRepository, IGameEventRepository gameEventRepository, diff --git a/src/GZCTF/Services/Container/ContainerServiceExtension.cs b/src/GZCTF/Services/Container/ContainerServiceExtension.cs new file mode 100644 index 000000000..c0014ac69 --- /dev/null +++ b/src/GZCTF/Services/Container/ContainerServiceExtension.cs @@ -0,0 +1,43 @@ +using Docker.DotNet; +using GZCTF.Models.Internal; +using GZCTF.Services.Interface; +using k8s; + +namespace GZCTF.Services; + +public static class ContainerServiceExtension +{ + internal static IServiceCollection AddContainerService(this IServiceCollection services, ConfigurationManager configuration) + { + var provider = configuration.GetSection(nameof(ContainerProvider)); + var type = provider.GetValue(nameof(ContainerProvider.Type)); + + // FIXME: custom IPortMapper + if (type == ContainerProviderType.Kubernetes) + { + services.AddSingleton, K8sProvider>(); + + services.AddSingleton(); + services.AddSingleton(); + } + else if (type == ContainerProviderType.Docker) + { + services.AddSingleton, DockerProvider>(); + + var docker = provider.GetValue(nameof(ContainerProvider.DockerConfig)); + + if (docker?.SwarmMode is true) + { + services.AddSingleton(); + services.AddSingleton(); + } + else + { + services.AddSingleton(); + services.AddSingleton(); + } + } + + return services; + } +} diff --git a/src/GZCTF/Services/Container/Manager/DockerManager.cs b/src/GZCTF/Services/Container/Manager/DockerManager.cs new file mode 100644 index 000000000..bfccde0ad --- /dev/null +++ b/src/GZCTF/Services/Container/Manager/DockerManager.cs @@ -0,0 +1,125 @@ +using System.Net; +using Docker.DotNet; +using Docker.DotNet.Models; +using GZCTF.Models.Internal; +using GZCTF.Services.Interface; +using GZCTF.Utils; + +namespace GZCTF.Services; + +public class DockerManager : IContainerManager +{ + private readonly ILogger _logger; + private readonly IContainerProvider _provider; + private readonly IPortMapper _mapper; + private readonly DockerMetadata _meta; + private readonly DockerClient _client; + + public DockerManager(IContainerProvider provider, IPortMapper mapper, ILogger logger) + { + _logger = logger; + _mapper = mapper; + _provider = provider; + _meta = _provider.GetMetadata(); + _client = _provider.GetProvider(); + + logger.SystemLog($"容器管理模式:Docker 单实例容器控制", TaskStatus.Success, LogLevel.Debug); + } + + + public async Task DestroyContainerAsync(Container container, CancellationToken token = default) + { + try + { + await _mapper.UnmapContainer(container, token); + await _client.Containers.RemoveContainerAsync(container.ContainerId, new() { Force = true }, token); + } + catch (DockerContainerNotFoundException) + { + _logger.SystemLog($"容器 {container.ContainerId} 已被销毁", TaskStatus.Success, LogLevel.Debug); + } + catch (DockerApiException e) + { + if (e.StatusCode == HttpStatusCode.NotFound) + { + _logger.SystemLog($"容器 {container.ContainerId} 已被销毁", TaskStatus.Success, LogLevel.Debug); + } + else + { + _logger.SystemLog($"容器 {container.ContainerId} 删除失败, 状态:{e.StatusCode}", TaskStatus.Failed, LogLevel.Warning); + _logger.SystemLog($"容器 {container.ContainerId} 删除失败, 响应:{e.ResponseBody}", TaskStatus.Failed, LogLevel.Error); + return; + } + } + catch (Exception e) + { + _logger.LogError(e, $"容器 {container.ContainerId} 删除失败"); + return; + } + + container.Status = ContainerStatus.Destroyed; + } + + private static CreateContainerParameters GetCreateContainerParameters(ContainerConfig config) + => new() + { + Image = config.Image, + Labels = new Dictionary { ["TeamId"] = config.TeamId, ["UserId"] = config.UserId }, + Name = DockerMetadata.GetName(config), + Env = config.Flag is null ? Array.Empty() : new string[] { $"GZCTF_FLAG={config.Flag}" }, + ExposedPorts = new Dictionary() + { + [config.ExposedPort.ToString()] = new EmptyStruct() + }, + HostConfig = new() + { + PublishAllPorts = true, + Memory = config.MemoryLimit * 1024 * 1024, + CPUPercent = config.CPUCount * 10, + Privileged = config.PrivilegedContainer + } + }; + + public async Task CreateContainerAsync(ContainerConfig config, CancellationToken token = default) + { + var parameters = GetCreateContainerParameters(config); + CreateContainerResponse? containerRes = null; + try + { + containerRes = await _client.Containers.CreateContainerAsync(parameters, token); + } + catch (DockerImageNotFoundException) + { + _logger.SystemLog($"拉取容器镜像 {config.Image}", TaskStatus.Pending, LogLevel.Information); + + await _client.Images.CreateImageAsync(new() + { + FromImage = config.Image + }, _meta.Auth, new Progress(msg => + { + Console.WriteLine($"{msg.Status}|{msg.ProgressMessage}|{msg.ErrorMessage}"); + }), token); + } + catch (Exception e) + { + _logger.LogError(e, $"容器 {parameters.Name} 创建失败"); + return null; + } + + try + { + containerRes ??= await _client.Containers.CreateContainerAsync(parameters, token); + } + catch (Exception e) + { + _logger.LogError(e, $"容器 {parameters.Name} 创建失败"); + return null; + } + + return await _mapper.MapContainer(new() + { + ContainerId = containerRes.ID, + Image = config.Image, + }, config, token); + } +} diff --git a/src/GZCTF/Services/Container/Manager/K8sManager.cs b/src/GZCTF/Services/Container/Manager/K8sManager.cs new file mode 100644 index 000000000..0631b4f34 --- /dev/null +++ b/src/GZCTF/Services/Container/Manager/K8sManager.cs @@ -0,0 +1,159 @@ +using System.Net; +using GZCTF.Models.Internal; +using GZCTF.Services.Interface; +using GZCTF.Utils; +using k8s; +using k8s.Autorest; +using k8s.Models; + +namespace GZCTF.Services; + +public class K8sManager : IContainerManager +{ + private readonly ILogger _logger; + private readonly IContainerProvider _provider; + private readonly IPortMapper _mapper; + private readonly K8sMetadata _meta; + private readonly Kubernetes _client; + + public K8sManager(IContainerProvider provider, IPortMapper mapper, ILogger logger) + { + _logger = logger; + _mapper = mapper; + _provider = provider; + _meta = _provider.GetMetadata(); + _client = _provider.GetProvider(); + + logger.SystemLog($"容器管理模式:K8s 集群 Pod 容器控制", TaskStatus.Success, LogLevel.Debug); + } + + public async Task CreateContainerAsync(ContainerConfig config, CancellationToken token = default) + { + var imageName = config.Image.Split("/").LastOrDefault()?.Split(":").FirstOrDefault(); + var _authSecretName = _meta.AuthSecretName; + var _options = _meta.Config; + + if (imageName is null) + { + _logger.SystemLog($"无法解析镜像名称 {config.Image}", TaskStatus.Failed, LogLevel.Warning); + return null; + } + + var name = $"{imageName.ToValidRFC1123String("chal")}-{Guid.NewGuid().ToString("N")[..16]}"; + + var pod = new V1Pod("v1", "Pod") + { + Metadata = new V1ObjectMeta() + { + Name = name, + NamespaceProperty = _options.Namespace, + Labels = new Dictionary() + { + ["ctf.gzti.me/ResourceId"] = name, + ["ctf.gzti.me/TeamId"] = config.TeamId, + ["ctf.gzti.me/UserId"] = config.UserId + } + }, + Spec = new V1PodSpec() + { + ImagePullSecrets = _authSecretName is null ? + Array.Empty() : + new List() { new() { Name = _authSecretName } }, + DnsPolicy = "None", + DnsConfig = new() + { + // FIXME: remove nullable when JsonObjectCreationHandling release + Nameservers = _options.DNS ?? new[] { "8.8.8.8", "223.5.5.5", "114.114.114.114" }, + }, + EnableServiceLinks = false, + Containers = new[] + { + new V1Container() + { + Name = name, + Image = config.Image, + ImagePullPolicy = "Always", + SecurityContext = new() { Privileged = config.PrivilegedContainer }, + Env = config.Flag is null ? new List() : new[] + { + new V1EnvVar("GZCTF_FLAG", config.Flag) + }, + Ports = new[] { new V1ContainerPort(config.ExposedPort) }, + Resources = new V1ResourceRequirements() + { + Limits = new Dictionary() + { + ["cpu"] = new ResourceQuantity($"{config.CPUCount * 100}m"), + ["memory"] = new ResourceQuantity($"{config.MemoryLimit}Mi"), + ["ephemeral-storage"] = new ResourceQuantity($"{config.StorageLimit}Mi") + }, + Requests = new Dictionary() + { + ["cpu"] = new ResourceQuantity("10m"), + ["memory"] = new ResourceQuantity("32Mi") + } + } + } + }, + RestartPolicy = "Never" + } + }; + + try + { + pod = await _client.CreateNamespacedPodAsync(pod, _options.Namespace, cancellationToken: token); + } + catch (HttpOperationException e) + { + _logger.SystemLog($"容器 {name} 创建失败, 状态:{e.Response.StatusCode}", TaskStatus.Failed, LogLevel.Warning); + _logger.SystemLog($"容器 {name} 创建失败, 响应:{e.Response.Content}", TaskStatus.Failed, LogLevel.Error); + return null; + } + catch (Exception e) + { + _logger.LogError(e, "创建容器失败"); + return null; + } + + if (pod is null) + { + _logger.SystemLog($"创建容器实例 {config.Image.Split("/").LastOrDefault()} 失败", TaskStatus.Failed, LogLevel.Warning); + return null; + } + + + return await _mapper.MapContainer(new Container() + { + ContainerId = name, + Image = config.Image, + Port = config.ExposedPort, + }, config, token); + } + + public async Task DestroyContainerAsync(Container container, CancellationToken token = default) + { + try + { + await _mapper.UnmapContainer(container, token); + await _client.CoreV1.DeleteNamespacedPodAsync(container.ContainerId, _meta.Config.Namespace, cancellationToken: token); + } + catch (HttpOperationException e) + { + if (e.Response.StatusCode == HttpStatusCode.NotFound) + { + container.Status = ContainerStatus.Destroyed; + return; + } + _logger.SystemLog($"容器 {container.ContainerId} 删除失败, 状态:{e.Response.StatusCode}", TaskStatus.Failed, LogLevel.Warning); + _logger.SystemLog($"容器 {container.ContainerId} 删除失败, 响应:{e.Response.Content}", TaskStatus.Failed, LogLevel.Error); + } + catch (Exception e) + { + _logger.LogError(e, "删除容器失败"); + return; + } + + container.Status = ContainerStatus.Destroyed; + } + +} \ No newline at end of file diff --git a/src/GZCTF/Services/Container/Manager/SwarmManager.cs b/src/GZCTF/Services/Container/Manager/SwarmManager.cs new file mode 100644 index 000000000..fcab448e9 --- /dev/null +++ b/src/GZCTF/Services/Container/Manager/SwarmManager.cs @@ -0,0 +1,139 @@ +using System.Net; +using Docker.DotNet; +using Docker.DotNet.Models; +using GZCTF.Models.Internal; +using GZCTF.Services.Interface; +using GZCTF.Utils; + +namespace GZCTF.Services; + +public class SwarmManager : IContainerManager +{ + private readonly ILogger _logger; + private readonly IContainerProvider _provider; + private readonly IPortMapper _mapper; + private readonly DockerMetadata _meta; + private readonly DockerClient _client; + + public SwarmManager(IContainerProvider provider, IPortMapper mapper, ILogger logger) + { + _logger = logger; + _mapper = mapper; + _provider = provider; + _meta = _provider.GetMetadata(); + _client = _provider.GetProvider(); + + logger.SystemLog($"容器管理模式:Docker Swarm 集群容器控制", TaskStatus.Success, LogLevel.Debug); + } + + public async Task DestroyContainerAsync(Container container, CancellationToken token = default) + { + try + { + await _mapper.UnmapContainer(container, token); + await _client.Swarm.RemoveServiceAsync(container.ContainerId, token); + } + catch (DockerContainerNotFoundException) + { + _logger.SystemLog($"容器 {container.ContainerId} 已被销毁", TaskStatus.Success, LogLevel.Debug); + } + catch (DockerApiException e) + { + if (e.StatusCode == HttpStatusCode.NotFound) + { + _logger.SystemLog($"容器 {container.ContainerId} 已被销毁", TaskStatus.Success, LogLevel.Debug); + } + else + { + _logger.SystemLog($"容器 {container.ContainerId} 删除失败, 状态:{e.StatusCode}", TaskStatus.Failed, LogLevel.Warning); + _logger.SystemLog($"容器 {container.ContainerId} 删除失败, 响应:{e.ResponseBody}", TaskStatus.Failed, LogLevel.Error); + return; + } + } + catch (Exception e) + { + _logger.LogError(e, $"容器 {container.ContainerId} 删除失败"); + return; + } + + container.Status = ContainerStatus.Destroyed; + } + + private static string GetName(ContainerConfig config) + => $"{config.Image.Split("/").LastOrDefault()?.Split(":").FirstOrDefault()}_{(config.Flag ?? Guid.NewGuid().ToString()).StrMD5()[..16]}"; + + private ServiceCreateParameters GetServiceCreateParameters(ContainerConfig config) + => new() + { + RegistryAuth = _meta.Auth, + Service = new() + { + Name = GetName(config), + Labels = new Dictionary { ["TeamId"] = config.TeamId, ["UserId"] = config.UserId }, + Mode = new() { Replicated = new() { Replicas = 1 } }, + EndpointSpec = new() + { + Ports = new PortConfig[] { new() { + PublishMode = "global", + TargetPort = (uint)config.ExposedPort, + } }, + }, + TaskTemplate = new() + { + RestartPolicy = new() { Condition = "none" }, + ContainerSpec = new() + { + Image = config.Image, + Env = config.Flag is null ? Array.Empty() : new string[] { $"GZCTF_FLAG={config.Flag}" } + }, + Resources = new() + { + Limits = new() + { + MemoryBytes = config.MemoryLimit * 1024 * 1024, + NanoCPUs = config.CPUCount * 1_0000_0000, + }, + }, + } + } + }; + + public async Task CreateContainerAsync(ContainerConfig config, CancellationToken token = default) + { + var parameters = GetServiceCreateParameters(config); + int retry = 0; + ServiceCreateResponse? serviceRes; + CreateContainer: + try + { + serviceRes = await _client.Swarm.CreateServiceAsync(parameters, token); + } + catch (DockerApiException e) + { + if (e.StatusCode == HttpStatusCode.Conflict && retry < 3) + { + _logger.SystemLog($"容器 {parameters.Service.Name} 已存在,尝试移除后重新创建", TaskStatus.Duplicate, LogLevel.Warning); + await _client.Swarm.RemoveServiceAsync(parameters.Service.Name, token); + retry++; + goto CreateContainer; + } + else + { + _logger.SystemLog($"容器 {parameters.Service.Name} 创建失败, 状态:{e.StatusCode}", TaskStatus.Failed, LogLevel.Warning); + _logger.SystemLog($"容器 {parameters.Service.Name} 创建失败, 响应:{e.ResponseBody}", TaskStatus.Failed, LogLevel.Error); + return null; + } + } + catch (Exception e) + { + _logger.LogError(e, $"容器 {parameters.Service.Name} 删除失败"); + return null; + } + + return await _mapper.MapContainer(new() + { + ContainerId = serviceRes.ID, + Image = config.Image, + }, config, token); + } +} diff --git a/src/GZCTF/Services/Container/PortMapper/DockerDirectMapper.cs b/src/GZCTF/Services/Container/PortMapper/DockerDirectMapper.cs new file mode 100644 index 000000000..cd1702c65 --- /dev/null +++ b/src/GZCTF/Services/Container/PortMapper/DockerDirectMapper.cs @@ -0,0 +1,79 @@ +using Docker.DotNet; +using GZCTF.Models.Internal; +using GZCTF.Services.Interface; +using GZCTF.Utils; + +namespace GZCTF.Services; + +public class DockerDirectMapper : IPortMapper +{ + private readonly ILogger _logger; + private readonly IContainerProvider _provider; + private readonly DockerMetadata _meta; + private readonly DockerClient _client; + + public DockerDirectMapper(IContainerProvider provider, ILogger logger) + { + _logger = logger; + _provider = provider; + _meta = _provider.GetMetadata(); + _client = _provider.GetProvider(); + + logger.SystemLog($"端口映射方式:Docker 直接端口映射", TaskStatus.Success, LogLevel.Debug); + } + + public async Task MapContainer(Container container, ContainerConfig config, CancellationToken token = default) + { + var retry = 0; + bool started; + + do + { + started = await _client.Containers.StartContainerAsync(container.ContainerId, new(), token); + retry++; + if (retry == 3) + { + _logger.SystemLog($"启动容器实例 {container.ContainerId[..12]} ({config.Image.Split("/").LastOrDefault()}) 失败", TaskStatus.Failed, LogLevel.Warning); + return null; + } + if (!started) + await Task.Delay(500, token); + } while (!started); + + var info = await _client.Containers.InspectContainerAsync(container.ContainerId, token); + + container.Status = (info.State.Dead || info.State.OOMKilled || info.State.Restarting) ? ContainerStatus.Destroyed : + info.State.Running ? ContainerStatus.Running : ContainerStatus.Pending; + + if (container.Status != ContainerStatus.Running) + { + _logger.SystemLog($"创建 {config.Image.Split("/").LastOrDefault()} 实例遇到错误:{info.State.Error}", TaskStatus.Failed, LogLevel.Warning); + return null; + } + + container.StartedAt = DateTimeOffset.Parse(info.State.StartedAt); + container.ExpectStopAt = container.StartedAt + TimeSpan.FromHours(2); + + var port = info.NetworkSettings.Ports + .FirstOrDefault(p => + p.Key.StartsWith(config.ExposedPort.ToString()) + ).Value.First().HostPort; + + if (int.TryParse(port, out var numport)) + container.Port = numport; + else + _logger.SystemLog($"无法转换端口号:{port},这是非预期的行为", TaskStatus.Failed, LogLevel.Warning); + + container.IP = info.NetworkSettings.IPAddress; + + if (!string.IsNullOrEmpty(_meta.PublicEntry)) + container.PublicIP = _meta.PublicEntry; + + return container; + } + + // DO NOTHING + public Task UnmapContainer(Container container, CancellationToken token = default) + => Task.FromResult(container); +} + diff --git a/src/GZCTF/Services/Container/PortMapper/K8sNodePortMapper.cs b/src/GZCTF/Services/Container/PortMapper/K8sNodePortMapper.cs new file mode 100644 index 000000000..30833e01b --- /dev/null +++ b/src/GZCTF/Services/Container/PortMapper/K8sNodePortMapper.cs @@ -0,0 +1,95 @@ +using GZCTF.Models.Internal; +using GZCTF.Services.Interface; +using GZCTF.Utils; +using k8s; +using k8s.Autorest; +using k8s.Models; + +namespace GZCTF.Services; + +public class K8sNodePortMapper : IPortMapper +{ + private readonly ILogger _logger; + private readonly IContainerProvider _provider; + private readonly K8sMetadata _meta; + private readonly Kubernetes _client; + + public K8sNodePortMapper(IContainerProvider provider, ILogger logger) + { + _logger = logger; + _provider = provider; + _meta = _provider.GetMetadata(); + _client = _provider.GetProvider(); + + logger.SystemLog($"端口映射方式:K8s NodePort 服务端口映射", TaskStatus.Success, LogLevel.Debug); + } + + public async Task MapContainer(Container container, ContainerConfig config, CancellationToken token = default) + { + var name = container.ContainerId; + + var service = new V1Service("v1", "Service") + { + Metadata = new V1ObjectMeta() + { + Name = name, + NamespaceProperty = _meta.Config.Namespace, + Labels = new Dictionary() { ["ctf.gzti.me/ResourceId"] = name } + }, + Spec = new V1ServiceSpec() + { + Type = "NodePort", + Ports = new[] + { + new V1ServicePort(config.ExposedPort, targetPort: config.ExposedPort) + }, + Selector = new Dictionary() + { + ["ctf.gzti.me/ResourceId"] = name + } + } + }; + + try + { + service = await _client.CoreV1.CreateNamespacedServiceAsync(service, _meta.Config.Namespace, cancellationToken: token); + } + catch (HttpOperationException e) + { + try + { + // remove the pod if service creation failed, ignore the error + await _client.CoreV1.DeleteNamespacedPodAsync(name, _meta.Config.Namespace, cancellationToken: token); + } + catch { } + _logger.SystemLog($"服务 {name} 创建失败, 状态:{e.Response.StatusCode}", TaskStatus.Failed, LogLevel.Warning); + _logger.SystemLog($"服务 {name} 创建失败, 响应:{e.Response.Content}", TaskStatus.Failed, LogLevel.Error); + return null; + } + catch (Exception e) + { + try + { + // remove the pod if service creation failed, ignore the error + await _client.CoreV1.DeleteNamespacedPodAsync(name, _meta.Config.Namespace, cancellationToken: token); + } + catch { } + _logger.LogError(e, "创建服务失败"); + return null; + } + + container.PublicPort = service.Spec.Ports[0].NodePort; + container.IP = _meta.HostIP; + container.PublicIP = _meta.PublicEntry; + container.StartedAt = DateTimeOffset.UtcNow; + + return container; + } + + public async Task UnmapContainer(Container container, CancellationToken token = default) + { + await _client.CoreV1.DeleteNamespacedServiceAsync(container.ContainerId, _meta.Config.Namespace, cancellationToken: token); + return container; + } +} + diff --git a/src/GZCTF/Services/Container/PortMapper/SwarmDirectMapper.cs b/src/GZCTF/Services/Container/PortMapper/SwarmDirectMapper.cs new file mode 100644 index 000000000..d6efcb9f4 --- /dev/null +++ b/src/GZCTF/Services/Container/PortMapper/SwarmDirectMapper.cs @@ -0,0 +1,61 @@ +using Docker.DotNet; +using Docker.DotNet.Models; +using GZCTF.Models.Internal; +using GZCTF.Services.Interface; +using GZCTF.Utils; + +namespace GZCTF.Services; + +public class SwarmDirectMapper : IPortMapper +{ + private readonly ILogger _logger; + private readonly IContainerProvider _provider; + private readonly DockerMetadata _meta; + private readonly DockerClient _client; + + public SwarmDirectMapper(IContainerProvider provider, ILogger logger) + { + _logger = logger; + _provider = provider; + _meta = _provider.GetMetadata(); + _client = _provider.GetProvider(); + + logger.SystemLog($"端口映射方式:Swarm 直接端口映射", TaskStatus.Success, LogLevel.Debug); + } + + public async Task MapContainer(Container container, ContainerConfig config, CancellationToken token = default) + { + var retry = 0; + SwarmService? res; + do + { + res = await _client.Swarm.InspectServiceAsync(container.ContainerId, token); + retry++; + if (retry == 3) + { + _logger.SystemLog($"容器 {container.ContainerId} 创建后未获取到端口暴露信息,创建失败", TaskStatus.Failed, LogLevel.Warning); + return null; + } + if (res is not { Endpoint.Ports.Count: > 0 }) + await Task.Delay(500, token); + } while (res is not { Endpoint.Ports.Count: > 0 }); + + var port = res.Endpoint.Ports.First(); + + container.StartedAt = res.CreatedAt; + container.ExpectStopAt = container.StartedAt + TimeSpan.FromHours(2); + + container.Port = (int)port.PublishedPort; + container.Status = ContainerStatus.Running; + + if (!string.IsNullOrEmpty(_meta.PublicEntry)) + container.PublicIP = _meta.PublicEntry; + + return container; + } + + // DO NOTHING + public Task UnmapContainer(Container container, CancellationToken token = default) + => Task.FromResult(container); +} + diff --git a/src/GZCTF/Services/Container/Provider/DockerProvider.cs b/src/GZCTF/Services/Container/Provider/DockerProvider.cs new file mode 100644 index 000000000..02bf218f6 --- /dev/null +++ b/src/GZCTF/Services/Container/Provider/DockerProvider.cs @@ -0,0 +1,70 @@ +using Docker.DotNet; +using Docker.DotNet.Models; +using GZCTF.Models.Internal; +using GZCTF.Services.Interface; +using GZCTF.Utils; +using Microsoft.Extensions.Options; + +namespace GZCTF.Services; + +public class DockerMetadata +{ + /// + /// 公共访问入口 + /// + public string PublicEntry { get; set; } = string.Empty; + + /// + /// Docker 配置 + /// + public DockerConfig Config { get; set; } = new(); + + /// + /// Docker 鉴权用配置 + /// + public AuthConfig? Auth { get; set; } + + /// + /// 根据配置获取容器名称 + /// + /// + /// + public static string GetName(ContainerConfig config) + => $"{config.Image.Split("/").LastOrDefault()?.Split(":").FirstOrDefault()}_{(config.Flag ?? Guid.NewGuid().ToString()).StrMD5()[..16]}"; +} + +public class DockerProvider : IContainerProvider +{ + private readonly DockerClient _dockerClient; + private readonly DockerMetadata _dockerMeta; + + public DockerMetadata GetMetadata() => _dockerMeta; + public DockerClient GetProvider() => _dockerClient; + + public DockerProvider(IOptions options, IOptions registry, ILogger logger) + { + _dockerMeta = new() + { + Config = options.Value.DockerConfig ?? new(), + PublicEntry = options.Value.PublicEntry + }; + + DockerClientConfiguration cfg = string.IsNullOrEmpty(_dockerMeta.Config.Uri) ? new() : new(new Uri(_dockerMeta.Config.Uri)); + + // TODO: Docker Auth Required + _dockerClient = cfg.CreateClient(); + + // Auth for registry + if (!string.IsNullOrWhiteSpace(registry.Value.UserName) && !string.IsNullOrWhiteSpace(registry.Value.Password)) + { + _dockerMeta.Auth = new AuthConfig() + { + Username = registry.Value.UserName, + Password = registry.Value.Password, + }; + } + + logger.SystemLog($"Docker 初始化成功 ({(string.IsNullOrEmpty(_dockerMeta.Config.Uri) ? "localhost" : _dockerMeta.Config.Uri)})", TaskStatus.Success, LogLevel.Debug); + } +} + diff --git a/src/GZCTF/Services/Container/Provider/K8sProvider.cs b/src/GZCTF/Services/Container/Provider/K8sProvider.cs new file mode 100644 index 000000000..b16e522fc --- /dev/null +++ b/src/GZCTF/Services/Container/Provider/K8sProvider.cs @@ -0,0 +1,161 @@ +using System.Text.Json; +using GZCTF.Models.Internal; +using GZCTF.Services.Interface; +using GZCTF.Utils; +using k8s; +using k8s.Models; +using Microsoft.Extensions.Options; + +namespace GZCTF.Services; + +public class K8sMetadata +{ + /// + /// 容器注册表鉴权 Secret 名称 + /// + public string? AuthSecretName { get; set; } + + /// + /// K8s 集群 Host IP + /// + public string HostIP { get; set; } = string.Empty; + + /// + /// 公共访问入口 + /// + public string PublicEntry { get; set; } = string.Empty; + + /// + /// K8s 配置 + /// + public K8sConfig Config { get; set; } = new(); +} + +public class K8sProvider : IContainerProvider +{ + private const string NetworkPolicy = "gzctf-policy"; + + private readonly Kubernetes _kubernetesClient; + private readonly K8sMetadata _k8sMetadata; + + public Kubernetes GetProvider() => _kubernetesClient; + public K8sMetadata GetMetadata() => _k8sMetadata; + + public K8sProvider(IOptions registry, IOptions provider, ILogger logger) + { + _k8sMetadata = new() + { + Config = provider.Value.K8sConfig ?? new(), + PublicEntry = provider.Value.PublicEntry + }; + + if (!File.Exists(_k8sMetadata.Config.KubeConfig)) + { + LogHelper.SystemLog(logger, $"无法加载 K8s 配置文件,请确保配置文件存在 {_k8sMetadata.Config.KubeConfig}"); + throw new FileNotFoundException(_k8sMetadata.Config.KubeConfig); + } + + var config = KubernetesClientConfiguration.BuildConfigFromConfigFile(_k8sMetadata.Config.KubeConfig); + + _k8sMetadata.HostIP = config.Host[(config.Host.LastIndexOf('/') + 1)..config.Host.LastIndexOf(':')]; + + _kubernetesClient = new Kubernetes(config); + + var registryValue = registry.Value; + var withAuth = !string.IsNullOrWhiteSpace(registryValue.ServerAddress) + && !string.IsNullOrWhiteSpace(registryValue.UserName) + && !string.IsNullOrWhiteSpace(registryValue.Password); + + if (withAuth) + { + var padding = $"{registryValue.UserName}@{registryValue.Password}@{registryValue.ServerAddress}".StrMD5(); + _k8sMetadata.AuthSecretName = $"{registryValue.UserName}-{padding}".ToValidRFC1123String("secret"); + } + + try + { + InitK8s(withAuth, registryValue); + } + catch (Exception e) + { + logger.LogError(e, $"K8s 初始化失败,请检查相关配置是否正确 ({config.Host})"); + Program.ExitWithFatalMessage($"K8s 初始化失败,请检查相关配置是否正确 ({config.Host})"); + } + + logger.SystemLog($"K8s 初始化成功 ({config.Host})", TaskStatus.Success, LogLevel.Debug); + } + + private void InitK8s(bool withAuth, RegistryConfig? registry) + { + if (_kubernetesClient.CoreV1.ListNamespace().Items.All(ns => ns.Metadata.Name != _k8sMetadata.Config.Namespace)) + _kubernetesClient.CoreV1.CreateNamespace(new() { Metadata = new() { Name = _k8sMetadata.Config.Namespace } }); + + if (_kubernetesClient.NetworkingV1.ListNamespacedNetworkPolicy(_k8sMetadata.Config.Namespace).Items.All(np => np.Metadata.Name != NetworkPolicy)) + { + + _kubernetesClient.NetworkingV1.CreateNamespacedNetworkPolicy(new() + { + Metadata = new() { Name = NetworkPolicy }, + Spec = new() + { + PodSelector = new(), + PolicyTypes = new[] { "Egress" }, + Egress = new[] + { + new V1NetworkPolicyEgressRule() + { + To = new[] + { + new V1NetworkPolicyPeer() { + IpBlock = new() { + Cidr = "0.0.0.0/0", + // FIXME: remove nullable when JsonObjectCreationHandling release + Except = _k8sMetadata.Config.AllowCIDR ?? new[] { "10.0.0.0/8" } + } + }, + } + } + } + } + }, _k8sMetadata.Config.Namespace); + } + + if (withAuth && registry is not null && registry.ServerAddress is not null) + { + var auth = Codec.Base64.Encode($"{registry.UserName}:{registry.Password}"); + var dockerjsonObj = new + { + auths = new Dictionary { + { + registry.ServerAddress, new { + auth, + username = registry.UserName, + password = registry.Password + } + } + } + }; + var dockerjsonBytes = JsonSerializer.SerializeToUtf8Bytes(dockerjsonObj); + var secret = new V1Secret() + { + Metadata = new V1ObjectMeta() + { + Name = _k8sMetadata.AuthSecretName, + NamespaceProperty = _k8sMetadata.Config.Namespace, + }, + Data = new Dictionary() { [".dockerconfigjson"] = dockerjsonBytes }, + Type = "kubernetes.io/dockerconfigjson" + }; + + try + { + _kubernetesClient.CoreV1.ReplaceNamespacedSecret(secret, _k8sMetadata.AuthSecretName, _k8sMetadata.Config.Namespace); + } + catch + { + _kubernetesClient.CoreV1.CreateNamespacedSecret(secret, _k8sMetadata.Config.Namespace); + } + } + } +} + diff --git a/src/GZCTF/Services/CronJobService.cs b/src/GZCTF/Services/CronJobService.cs index bbd014656..d63fe800d 100644 --- a/src/GZCTF/Services/CronJobService.cs +++ b/src/GZCTF/Services/CronJobService.cs @@ -29,7 +29,7 @@ public Task StartAsync(CancellationToken token) private async Task ContainerChecker(AsyncServiceScope scope) { var containerRepo = scope.ServiceProvider.GetRequiredService(); - var containerService = scope.ServiceProvider.GetRequiredService(); + var containerService = scope.ServiceProvider.GetRequiredService(); foreach (var container in await containerRepo.GetDyingContainers()) { diff --git a/src/GZCTF/Services/DockerService.cs b/src/GZCTF/Services/DockerService.cs deleted file mode 100644 index 920963991..000000000 --- a/src/GZCTF/Services/DockerService.cs +++ /dev/null @@ -1,295 +0,0 @@ -using System.Net; -using Docker.DotNet; -using Docker.DotNet.Models; -using GZCTF.Models.Internal; -using GZCTF.Services.Interface; -using GZCTF.Utils; -using Microsoft.Extensions.Options; - -namespace GZCTF.Services; - -public class DockerService : IContainerService -{ - private readonly ILogger _logger; - private readonly DockerConfig _options; - private readonly string _publicEntry; - private readonly DockerClient _dockerClient; - private readonly AuthConfig? _authConfig; - - public DockerService(IOptions options, IOptions registry, ILogger logger) - { - _options = options.Value.DockerConfig ?? new(); - _publicEntry = options.Value.PublicEntry; - _logger = logger; - DockerClientConfiguration cfg = string.IsNullOrEmpty(_options.Uri) ? new() : new(new Uri(_options.Uri)); - - // TODO: Docker Auth Required - _dockerClient = cfg.CreateClient(); - - // Auth for registry - if (!string.IsNullOrWhiteSpace(registry.Value.UserName) && !string.IsNullOrWhiteSpace(registry.Value.Password)) - { - _authConfig = new AuthConfig() - { - Username = registry.Value.UserName, - Password = registry.Value.Password, - }; - } - - logger.SystemLog($"Docker 服务已启动 ({(string.IsNullOrEmpty(_options.Uri) ? "localhost" : _options.Uri)})", TaskStatus.Success, LogLevel.Debug); - } - - public Task CreateContainerAsync(ContainerConfig config, CancellationToken token = default) - => _options.SwarmMode ? CreateContainerWithSwarm(config, token) : CreateContainerWithSingle(config, token); - - public async Task DestroyContainerAsync(Container container, CancellationToken token = default) - { - try - { - if (_options.SwarmMode) - await _dockerClient.Swarm.RemoveServiceAsync(container.ContainerId, token); - else - await _dockerClient.Containers.RemoveContainerAsync(container.ContainerId, new() { Force = true }, token); - } - catch (DockerContainerNotFoundException) - { - _logger.SystemLog($"容器 {container.ContainerId} 已被销毁", TaskStatus.Success, LogLevel.Debug); - } - catch (DockerApiException e) - { - if (e.StatusCode == HttpStatusCode.NotFound) - { - _logger.SystemLog($"容器 {container.ContainerId} 已被销毁", TaskStatus.Success, LogLevel.Debug); - } - else - { - _logger.SystemLog($"容器 {container.ContainerId} 删除失败, 状态:{e.StatusCode}", TaskStatus.Failed, LogLevel.Warning); - _logger.SystemLog($"容器 {container.ContainerId} 删除失败, 响应:{e.ResponseBody}", TaskStatus.Failed, LogLevel.Error); - return; - } - } - catch (Exception e) - { - _logger.LogError(e, $"容器 {container.ContainerId} 删除失败"); - return; - } - - container.Status = ContainerStatus.Destroyed; - } - - private static string GetName(ContainerConfig config) - => $"{config.Image.Split("/").LastOrDefault()?.Split(":").FirstOrDefault()}_{(config.Flag ?? Guid.NewGuid().ToString()).StrMD5()[..16]}"; - - private static CreateContainerParameters GetCreateContainerParameters(ContainerConfig config) - => new() - { - Image = config.Image, - Labels = new Dictionary { ["TeamId"] = config.TeamId, ["UserId"] = config.UserId }, - Name = GetName(config), - Env = config.Flag is null ? Array.Empty() : new string[] { $"GZCTF_FLAG={config.Flag}" }, - ExposedPorts = new Dictionary() - { - [config.ExposedPort.ToString()] = new EmptyStruct() - }, - HostConfig = new() - { - PublishAllPorts = true, - Memory = config.MemoryLimit * 1024 * 1024, - CPUPercent = config.CPUCount * 10, - Privileged = config.PrivilegedContainer - } - }; - - private ServiceCreateParameters GetServiceCreateParameters(ContainerConfig config) - => new() - { - RegistryAuth = _authConfig, - Service = new() - { - Name = GetName(config), - Labels = new Dictionary { ["TeamId"] = config.TeamId, ["UserId"] = config.UserId }, - Mode = new() { Replicated = new() { Replicas = 1 } }, - EndpointSpec = new() - { - Ports = new PortConfig[] { new() { - PublishMode = "global", - TargetPort = (uint)config.ExposedPort, - } }, - }, - TaskTemplate = new() - { - RestartPolicy = new() { Condition = "none" }, - ContainerSpec = new() - { - Image = config.Image, - Env = config.Flag is null ? Array.Empty() : new string[] { $"GZCTF_FLAG={config.Flag}" } - }, - Resources = new() - { - Limits = new() - { - MemoryBytes = config.MemoryLimit * 1024 * 1024, - NanoCPUs = config.CPUCount * 1_0000_0000, - }, - }, - } - } - }; - - private async Task CreateContainerWithSwarm(ContainerConfig config, CancellationToken token = default) - { - var parameters = GetServiceCreateParameters(config); - int retry = 0; - ServiceCreateResponse? serviceRes; - CreateContainer: - try - { - serviceRes = await _dockerClient.Swarm.CreateServiceAsync(parameters, token); - } - catch (DockerApiException e) - { - if (e.StatusCode == HttpStatusCode.Conflict && retry < 3) - { - _logger.SystemLog($"容器 {parameters.Service.Name} 已存在,尝试移除后重新创建", TaskStatus.Duplicate, LogLevel.Warning); - await _dockerClient.Swarm.RemoveServiceAsync(parameters.Service.Name, token); - retry++; - goto CreateContainer; - } - else - { - _logger.SystemLog($"容器 {parameters.Service.Name} 创建失败, 状态:{e.StatusCode}", TaskStatus.Failed, LogLevel.Warning); - _logger.SystemLog($"容器 {parameters.Service.Name} 创建失败, 响应:{e.ResponseBody}", TaskStatus.Failed, LogLevel.Error); - return null; - } - } - catch (Exception e) - { - _logger.LogError(e, $"容器 {parameters.Service.Name} 删除失败"); - return null; - } - - Container container = new() - { - ContainerId = serviceRes.ID, - Image = config.Image, - }; - - retry = 0; - SwarmService? res; - do - { - res = await _dockerClient.Swarm.InspectServiceAsync(serviceRes.ID, token); - retry++; - if (retry == 3) - { - _logger.SystemLog($"容器 {parameters.Service.Name} 创建后未获取到端口暴露信息,创建失败", TaskStatus.Failed, LogLevel.Warning); - return null; - } - if (res is not { Endpoint.Ports.Count: > 0 }) - await Task.Delay(500, token); - } while (res is not { Endpoint.Ports.Count: > 0 }); - - var port = res.Endpoint.Ports.First(); - - container.StartedAt = res.CreatedAt; - container.ExpectStopAt = container.StartedAt + TimeSpan.FromHours(2); - - container.Port = (int)port.PublishedPort; - container.Status = ContainerStatus.Running; - - if (!string.IsNullOrEmpty(_publicEntry)) - container.PublicIP = _publicEntry; - - return container; - } - - private async Task CreateContainerWithSingle(ContainerConfig config, CancellationToken token = default) - { - var parameters = GetCreateContainerParameters(config); - CreateContainerResponse? containerRes = null; - try - { - containerRes = await _dockerClient.Containers.CreateContainerAsync(parameters, token); - } - catch (DockerImageNotFoundException) - { - _logger.SystemLog($"拉取容器镜像 {config.Image}", TaskStatus.Pending, LogLevel.Information); - - await _dockerClient.Images.CreateImageAsync(new() - { - FromImage = config.Image - }, _authConfig, new Progress(msg => - { - Console.WriteLine($"{msg.Status}|{msg.ProgressMessage}|{msg.ErrorMessage}"); - }), token); - } - catch (Exception e) - { - _logger.LogError(e, $"容器 {parameters.Name} 创建失败"); - return null; - } - - try - { - containerRes ??= await _dockerClient.Containers.CreateContainerAsync(parameters, token); - } - catch (Exception e) - { - _logger.LogError(e, $"容器 {parameters.Name} 创建失败"); - return null; - } - - Container container = new() - { - ContainerId = containerRes.ID, - Image = config.Image, - }; - - var retry = 0; - bool started; - - do - { - started = await _dockerClient.Containers.StartContainerAsync(containerRes.ID, new(), token); - retry++; - if (retry == 3) - { - _logger.SystemLog($"启动容器实例 {container.ContainerId[..12]} ({config.Image.Split("/").LastOrDefault()}) 失败", TaskStatus.Failed, LogLevel.Warning); - return null; - } - if (!started) - await Task.Delay(500, token); - } while (!started); - - var info = await _dockerClient.Containers.InspectContainerAsync(container.ContainerId, token); - - container.Status = (info.State.Dead || info.State.OOMKilled || info.State.Restarting) ? ContainerStatus.Destroyed : - info.State.Running ? ContainerStatus.Running : ContainerStatus.Pending; - - if (container.Status != ContainerStatus.Running) - { - _logger.SystemLog($"创建 {config.Image.Split("/").LastOrDefault()} 实例遇到错误:{info.State.Error}", TaskStatus.Failed, LogLevel.Warning); - return null; - } - - container.StartedAt = DateTimeOffset.Parse(info.State.StartedAt); - container.ExpectStopAt = container.StartedAt + TimeSpan.FromHours(2); - - var port = info.NetworkSettings.Ports - .FirstOrDefault(p => - p.Key.StartsWith(config.ExposedPort.ToString()) - ).Value.First().HostPort; - - if (int.TryParse(port, out var numport)) - container.Port = numport; - else - _logger.SystemLog($"无法转换端口号:{port},这是非预期的行为", TaskStatus.Failed, LogLevel.Warning); - - container.IP = info.NetworkSettings.IPAddress; - - if (!string.IsNullOrEmpty(_publicEntry)) - container.PublicIP = _publicEntry; - - return container; - } -} diff --git a/src/GZCTF/Services/Interface/IContainerService.cs b/src/GZCTF/Services/Interface/IContainerManager.cs similarity index 94% rename from src/GZCTF/Services/Interface/IContainerService.cs rename to src/GZCTF/Services/Interface/IContainerManager.cs index 7598bcdf2..0e30e8d0b 100644 --- a/src/GZCTF/Services/Interface/IContainerService.cs +++ b/src/GZCTF/Services/Interface/IContainerManager.cs @@ -2,7 +2,7 @@ namespace GZCTF.Services.Interface; -public interface IContainerService +public interface IContainerManager { /// /// 创建容器 diff --git a/src/GZCTF/Services/Interface/IContainerProvider.cs b/src/GZCTF/Services/Interface/IContainerProvider.cs new file mode 100644 index 000000000..6660f83e8 --- /dev/null +++ b/src/GZCTF/Services/Interface/IContainerProvider.cs @@ -0,0 +1,17 @@ +namespace GZCTF.Services.Interface; + +public interface IContainerProvider +{ + /// + /// 获取容器服务提供者 + /// + /// + public T GetProvider(); + + /// + /// 获取容器服务信息 + /// + /// + public M GetMetadata(); +} + diff --git a/src/GZCTF/Services/Interface/IPortMapper.cs b/src/GZCTF/Services/Interface/IPortMapper.cs new file mode 100644 index 000000000..f68044866 --- /dev/null +++ b/src/GZCTF/Services/Interface/IPortMapper.cs @@ -0,0 +1,23 @@ +using GZCTF.Models.Internal; + +namespace GZCTF.Services.Interface; + +public interface IPortMapper +{ + /// + /// 对容器进行端口映射 + /// + /// 容器实例 + /// 容器创建配置 + /// + /// + public Task MapContainer(Container container, ContainerConfig config, CancellationToken token = default); + + /// + /// 对容器取消端口映射 + /// + /// 容器实例 + /// + /// + public Task UnmapContainer(Container container, CancellationToken token = default); +} \ No newline at end of file diff --git a/src/GZCTF/Services/K8sService.cs b/src/GZCTF/Services/K8sService.cs deleted file mode 100644 index eafe0114f..000000000 --- a/src/GZCTF/Services/K8sService.cs +++ /dev/null @@ -1,323 +0,0 @@ -using System.Net; -using System.Text; -using System.Text.Json; -using GZCTF.Models.Internal; -using GZCTF.Services.Interface; -using GZCTF.Utils; -using k8s; -using k8s.Autorest; -using k8s.Models; -using Microsoft.Extensions.Options; - -namespace GZCTF.Services; - -public class K8sService : IContainerService -{ - private const string NetworkPolicy = "gzctf-policy"; - - private readonly ILogger _logger; - private readonly Kubernetes _kubernetesClient; - private readonly string _hostIP; - private readonly string _publicEntry; - private readonly string? _authSecretName; - private readonly K8sConfig _options; - - public K8sService(IOptions registry, IOptions provider, ILogger logger) - { - _logger = logger; - _publicEntry = provider.Value.PublicEntry; - _options = provider.Value.K8sConfig ?? new(); - - if (!File.Exists(_options.KubeConfig)) - { - LogHelper.SystemLog(logger, $"无法加载 K8s 配置文件,请确保配置文件存在 {_options.KubeConfig}"); - throw new FileNotFoundException(_options.KubeConfig); - } - - var config = KubernetesClientConfiguration.BuildConfigFromConfigFile(_options.KubeConfig); - - _hostIP = config.Host[(config.Host.LastIndexOf('/') + 1)..config.Host.LastIndexOf(':')]; - - _kubernetesClient = new Kubernetes(config); - - var registryValue = registry.Value; - var withAuth = !string.IsNullOrWhiteSpace(registryValue.ServerAddress) - && !string.IsNullOrWhiteSpace(registryValue.UserName) - && !string.IsNullOrWhiteSpace(registryValue.Password); - - if (withAuth) - { - var padding = $"{registryValue.UserName}@{registryValue.Password}@{registryValue.ServerAddress}".StrMD5(); - _authSecretName = $"{registryValue.UserName}-{padding}".ToValidRFC1123String("secret"); - } - - try - { - InitK8s(withAuth, registryValue); - } - catch (Exception e) - { - logger.LogError(e, $"K8s 初始化失败,请检查相关配置是否正确 ({config.Host})"); - Program.ExitWithFatalMessage($"K8s 初始化失败,请检查相关配置是否正确 ({config.Host})"); - } - - logger.SystemLog($"K8s 服务已启动 ({config.Host})", TaskStatus.Success, LogLevel.Debug); - } - - public async Task CreateContainerAsync(ContainerConfig config, CancellationToken token = default) - { - var imageName = config.Image.Split("/").LastOrDefault()?.Split(":").FirstOrDefault(); - - if (imageName is null) - { - _logger.SystemLog($"无法解析镜像名称 {config.Image}", TaskStatus.Failed, LogLevel.Warning); - return null; - } - - var name = $"{imageName.ToValidRFC1123String("chal")}-{Guid.NewGuid().ToString("N")[..16]}"; - - var pod = new V1Pod("v1", "Pod") - { - Metadata = new V1ObjectMeta() - { - Name = name, - NamespaceProperty = _options.Namespace, - Labels = new Dictionary() - { - ["ctf.gzti.me/ResourceId"] = name, - ["ctf.gzti.me/TeamId"] = config.TeamId, - ["ctf.gzti.me/UserId"] = config.UserId - } - }, - Spec = new V1PodSpec() - { - ImagePullSecrets = _authSecretName is null ? - Array.Empty() : - new List() { new() { Name = _authSecretName } }, - DnsPolicy = "None", - DnsConfig = new() - { - // FIXME: remove nullable when JsonObjectCreationHandling release - Nameservers = _options.DNS ?? new[] { "8.8.8.8", "223.5.5.5", "114.114.114.114" }, - }, - EnableServiceLinks = false, - Containers = new[] - { - new V1Container() - { - Name = name, - Image = config.Image, - ImagePullPolicy = "Always", - SecurityContext = new() { Privileged = config.PrivilegedContainer }, - Env = config.Flag is null ? new List() : new[] - { - new V1EnvVar("GZCTF_FLAG", config.Flag) - }, - Ports = new[] { new V1ContainerPort(config.ExposedPort) }, - Resources = new V1ResourceRequirements() - { - Limits = new Dictionary() - { - ["cpu"] = new ResourceQuantity($"{config.CPUCount * 100}m"), - ["memory"] = new ResourceQuantity($"{config.MemoryLimit}Mi"), - ["ephemeral-storage"] = new ResourceQuantity($"{config.StorageLimit}Mi") - }, - Requests = new Dictionary() - { - ["cpu"] = new ResourceQuantity("10m"), - ["memory"] = new ResourceQuantity("32Mi") - } - } - } - }, - RestartPolicy = "Never" - } - }; - - try - { - pod = await _kubernetesClient.CreateNamespacedPodAsync(pod, _options.Namespace, cancellationToken: token); - } - catch (HttpOperationException e) - { - _logger.SystemLog($"容器 {name} 创建失败, 状态:{e.Response.StatusCode}", TaskStatus.Failed, LogLevel.Warning); - _logger.SystemLog($"容器 {name} 创建失败, 响应:{e.Response.Content}", TaskStatus.Failed, LogLevel.Error); - return null; - } - catch (Exception e) - { - _logger.LogError(e, "创建容器失败"); - return null; - } - - if (pod is null) - { - _logger.SystemLog($"创建容器实例 {config.Image.Split("/").LastOrDefault()} 失败", TaskStatus.Failed, LogLevel.Warning); - return null; - } - - var container = new Container() - { - ContainerId = name, - Image = config.Image, - Port = config.ExposedPort, - IsProxy = true, - }; - - var service = new V1Service("v1", "Service") - { - Metadata = new V1ObjectMeta() - { - Name = name, - NamespaceProperty = _options.Namespace, - Labels = new Dictionary() { ["ctf.gzti.me/ResourceId"] = name } - }, - Spec = new V1ServiceSpec() - { - Type = "NodePort", - Ports = new[] - { - new V1ServicePort(config.ExposedPort, targetPort: config.ExposedPort) - }, - Selector = new Dictionary() - { - ["ctf.gzti.me/ResourceId"] = name - } - } - }; - - try - { - service = await _kubernetesClient.CoreV1.CreateNamespacedServiceAsync(service, _options.Namespace, cancellationToken: token); - } - catch (HttpOperationException e) - { - try - { - // remove the pod if service creation failed, ignore the error - await _kubernetesClient.CoreV1.DeleteNamespacedPodAsync(name, _options.Namespace, cancellationToken: token); - } - catch { } - _logger.SystemLog($"服务 {name} 创建失败, 状态:{e.Response.StatusCode}", TaskStatus.Failed, LogLevel.Warning); - _logger.SystemLog($"服务 {name} 创建失败, 响应:{e.Response.Content}", TaskStatus.Failed, LogLevel.Error); - return null; - } - catch (Exception e) - { - try - { - // remove the pod if service creation failed, ignore the error - await _kubernetesClient.CoreV1.DeleteNamespacedPodAsync(name, _options.Namespace, cancellationToken: token); - } - catch { } - _logger.LogError(e, "创建服务失败"); - return null; - } - - container.PublicPort = service.Spec.Ports[0].NodePort; - container.IP = _hostIP; - container.PublicIP = _publicEntry; - container.StartedAt = DateTimeOffset.UtcNow; - - return container; - } - - public async Task DestroyContainerAsync(Container container, CancellationToken token = default) - { - try - { - await _kubernetesClient.CoreV1.DeleteNamespacedServiceAsync(container.ContainerId, _options.Namespace, cancellationToken: token); - await _kubernetesClient.CoreV1.DeleteNamespacedPodAsync(container.ContainerId, _options.Namespace, cancellationToken: token); - } - catch (HttpOperationException e) - { - if (e.Response.StatusCode == HttpStatusCode.NotFound) - { - container.Status = ContainerStatus.Destroyed; - return; - } - _logger.SystemLog($"容器 {container.ContainerId} 删除失败, 状态:{e.Response.StatusCode}", TaskStatus.Failed, LogLevel.Warning); - _logger.SystemLog($"容器 {container.ContainerId} 删除失败, 响应:{e.Response.Content}", TaskStatus.Failed, LogLevel.Error); - } - catch (Exception e) - { - _logger.LogError(e, "删除容器失败"); - return; - } - - container.Status = ContainerStatus.Destroyed; - } - - private void InitK8s(bool withAuth, RegistryConfig? registry) - { - if (_kubernetesClient.CoreV1.ListNamespace().Items.All(ns => ns.Metadata.Name != _options.Namespace)) - _kubernetesClient.CoreV1.CreateNamespace(new() { Metadata = new() { Name = _options.Namespace } }); - - if (_kubernetesClient.NetworkingV1.ListNamespacedNetworkPolicy(_options.Namespace).Items.All(np => np.Metadata.Name != NetworkPolicy)) - { - - _kubernetesClient.NetworkingV1.CreateNamespacedNetworkPolicy(new() - { - Metadata = new() { Name = NetworkPolicy }, - Spec = new() - { - PodSelector = new(), - PolicyTypes = new[] { "Egress" }, - Egress = new[] - { - new V1NetworkPolicyEgressRule() - { - To = new[] - { - new V1NetworkPolicyPeer() { - IpBlock = new() { - Cidr = "0.0.0.0/0", - // FIXME: remove nullable when JsonObjectCreationHandling release - Except = _options.AllowCIDR ?? new[] { "10.0.0.0/8" } - } - }, - } - } - } - } - }, _options.Namespace); - } - - if (withAuth && registry is not null && registry.ServerAddress is not null) - { - var auth = Codec.Base64.Encode($"{registry.UserName}:{registry.Password}"); - var dockerjsonObj = new - { - auths = new Dictionary { - { - registry.ServerAddress, new { - auth, - username = registry.UserName, - password = registry.Password - } - } - } - }; - var dockerjsonBytes = JsonSerializer.SerializeToUtf8Bytes(dockerjsonObj); - var secret = new V1Secret() - { - Metadata = new V1ObjectMeta() - { - Name = _authSecretName, - NamespaceProperty = _options.Namespace, - }, - Data = new Dictionary() { [".dockerconfigjson"] = dockerjsonBytes }, - Type = "kubernetes.io/dockerconfigjson" - }; - - try - { - _kubernetesClient.CoreV1.ReplaceNamespacedSecret(secret, _authSecretName, _options.Namespace); - } - catch - { - _kubernetesClient.CoreV1.CreateNamespacedSecret(secret, _options.Namespace); - } - } - } -} From c74d136dc4fb2c290d67bc8e33ae111b762e5143 Mon Sep 17 00:00:00 2001 From: GZTime Date: Mon, 14 Aug 2023 03:55:53 +0800 Subject: [PATCH 05/67] wip: remove IPortMapper --- src/GZCTF/Controllers/ProxyController.cs | 23 +++-- src/GZCTF/Models/Internal/Configs.cs | 10 ++ .../Container/ContainerServiceExtension.cs | 63 +++++++----- .../Container/Manager/DockerManager.cs | 73 ++++++++++++-- .../Services/Container/Manager/K8sManager.cs | 78 +++++++++++++-- .../Container/Manager/SwarmManager.cs | 63 ++++++++---- .../PortMapper/DockerDirectMapper.cs | 79 --------------- .../Container/PortMapper/K8sNodePortMapper.cs | 95 ------------------- .../Container/PortMapper/SwarmDirectMapper.cs | 61 ------------ .../Container/Provider/DockerProvider.cs | 10 +- .../Container/Provider/K8sProvider.cs | 14 +-- src/GZCTF/Services/Interface/IPortMapper.cs | 23 ----- 12 files changed, 249 insertions(+), 343 deletions(-) delete mode 100644 src/GZCTF/Services/Container/PortMapper/DockerDirectMapper.cs delete mode 100644 src/GZCTF/Services/Container/PortMapper/K8sNodePortMapper.cs delete mode 100644 src/GZCTF/Services/Container/PortMapper/SwarmDirectMapper.cs delete mode 100644 src/GZCTF/Services/Interface/IPortMapper.cs diff --git a/src/GZCTF/Controllers/ProxyController.cs b/src/GZCTF/Controllers/ProxyController.cs index ee858ac8c..3a43e5c04 100644 --- a/src/GZCTF/Controllers/ProxyController.cs +++ b/src/GZCTF/Controllers/ProxyController.cs @@ -1,11 +1,13 @@  using System.Net.Sockets; using System.Net.WebSockets; +using GZCTF.Models.Internal; using GZCTF.Repositories.Interface; using GZCTF.Utils; using Microsoft.AspNetCore.Mvc; -using ProtocolType = System.Net.Sockets.ProtocolType; +using Microsoft.Extensions.Options; using PacketDotNet; +using ProtocolType = System.Net.Sockets.ProtocolType; namespace GZCTF.Controllers; @@ -18,11 +20,15 @@ public class ProxyController : ControllerBase { private readonly ILogger _logger; private readonly IContainerRepository _containerRepository; - private const int BufferSize = 1024 * 4; - public ProxyController(ILogger logger, IContainerRepository containerRepository) + private readonly bool _enablePlatformProxy = false; + private const int BUFFER_SIZE = 1024 * 4; + private const int CONNECTION_LIMIT = 128; + + public ProxyController(ILogger logger, IOptions provider, IContainerRepository containerRepository) { _logger = logger; + _enablePlatformProxy = provider.Value.PortMappingType == ContainerPortMappingType.PlatformProxy; _containerRepository = containerRepository; } @@ -35,18 +41,21 @@ public ProxyController(ILogger logger, IContainerRepository con [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status400BadRequest)] public async Task ProxyForInstance(string id) { + if (!_enablePlatformProxy) + return BadRequest(new RequestResponse("TCP 代理已禁用")); + if (!HttpContext.WebSockets.IsWebSocketRequest) return BadRequest(new RequestResponse("仅支持 Websocket 请求")); var container = await _containerRepository.GetContainerById(id); - if (container is null) + if (container is null || !container.IsProxy) return NotFound(new RequestResponse("不存在的容器")); var socket = new Socket(AddressFamily.Unspecified, SocketType.Stream, ProtocolType.Tcp); await socket.ConnectAsync(container.IP, container.Port); - if(!socket.Connected) + if (!socket.Connected) return BadRequest(new RequestResponse("容器连接失败")); var ws = await HttpContext.WebSockets.AcceptWebSocketAsync(); @@ -57,7 +66,7 @@ public async Task ProxyForInstance(string id) var sender = Task.Run(async () => { - var buffer = new byte[BufferSize]; + var buffer = new byte[BUFFER_SIZE]; while (true) { var status = await ws.ReceiveAsync(buffer, ct); @@ -72,7 +81,7 @@ public async Task ProxyForInstance(string id) var receiver = Task.Run(async () => { - var buffer = new byte[BufferSize]; + var buffer = new byte[BUFFER_SIZE]; while (true) { var count = await stream.ReadAsync(buffer, ct); diff --git a/src/GZCTF/Models/Internal/Configs.cs b/src/GZCTF/Models/Internal/Configs.cs index 7e4b970b4..18a35fb4d 100644 --- a/src/GZCTF/Models/Internal/Configs.cs +++ b/src/GZCTF/Models/Internal/Configs.cs @@ -90,9 +90,19 @@ public enum ContainerProviderType Kubernetes } +[JsonConverter(typeof(JsonStringEnumConverter))] +public enum ContainerPortMappingType +{ + Default, + PlatformProxy, + Frp, +} + public class ContainerProvider { public ContainerProviderType Type { get; set; } = ContainerProviderType.Docker; + public ContainerPortMappingType PortMappingType { get; set; } = ContainerPortMappingType.Default; + public string PublicEntry { get; set; } = string.Empty; public K8sConfig? K8sConfig { get; set; } diff --git a/src/GZCTF/Services/Container/ContainerServiceExtension.cs b/src/GZCTF/Services/Container/ContainerServiceExtension.cs index c0014ac69..3b07b4780 100644 --- a/src/GZCTF/Services/Container/ContainerServiceExtension.cs +++ b/src/GZCTF/Services/Container/ContainerServiceExtension.cs @@ -5,39 +5,50 @@ namespace GZCTF.Services; +public class ContainerProviderMetadata +{ + /// + /// 公共访问入口 + /// + public string PublicEntry { get; set; } = string.Empty; + + /// + /// 端口映射类型 + /// + public ContainerPortMappingType PortMappingType { get; set; } = ContainerPortMappingType.Default; + + /// + /// 是否直接暴露端口 + /// + public bool ExposePort => PortMappingType == ContainerPortMappingType.Default; +} + public static class ContainerServiceExtension { internal static IServiceCollection AddContainerService(this IServiceCollection services, ConfigurationManager configuration) { - var provider = configuration.GetSection(nameof(ContainerProvider)); - var type = provider.GetValue(nameof(ContainerProvider.Type)); + var config = configuration.GetSection(nameof(ContainerProvider)).Get() ?? new(); // FIXME: custom IPortMapper - if (type == ContainerProviderType.Kubernetes) - { - services.AddSingleton, K8sProvider>(); + return services.AddProvider(config).AddManager(config); + } - services.AddSingleton(); - services.AddSingleton(); - } - else if (type == ContainerProviderType.Docker) + private static IServiceCollection AddProvider(this IServiceCollection services, ContainerProvider config) + => config.Type switch { - services.AddSingleton, DockerProvider>(); - - var docker = provider.GetValue(nameof(ContainerProvider.DockerConfig)); - - if (docker?.SwarmMode is true) - { - services.AddSingleton(); - services.AddSingleton(); - } - else - { - services.AddSingleton(); - services.AddSingleton(); - } - } - - return services; + ContainerProviderType.Docker => services.AddSingleton, DockerProvider>(), + ContainerProviderType.Kubernetes => services.AddSingleton, K8sProvider>(), + _ => throw new NotImplementedException() + }; + + private static IServiceCollection AddManager(this IServiceCollection services, ContainerProvider config) + { + if (config.Type == ContainerProviderType.Kubernetes) + return services.AddSingleton(); + + if (config.DockerConfig?.SwarmMode is true) + return services.AddSingleton(); + + return services.AddSingleton(); } } diff --git a/src/GZCTF/Services/Container/Manager/DockerManager.cs b/src/GZCTF/Services/Container/Manager/DockerManager.cs index bfccde0ad..6ce3c802d 100644 --- a/src/GZCTF/Services/Container/Manager/DockerManager.cs +++ b/src/GZCTF/Services/Container/Manager/DockerManager.cs @@ -11,14 +11,12 @@ public class DockerManager : IContainerManager { private readonly ILogger _logger; private readonly IContainerProvider _provider; - private readonly IPortMapper _mapper; private readonly DockerMetadata _meta; private readonly DockerClient _client; - public DockerManager(IContainerProvider provider, IPortMapper mapper, ILogger logger) + public DockerManager(IContainerProvider provider, ILogger logger) { _logger = logger; - _mapper = mapper; _provider = provider; _meta = _provider.GetMetadata(); _client = _provider.GetProvider(); @@ -31,7 +29,6 @@ public async Task DestroyContainerAsync(Container container, CancellationToken t { try { - await _mapper.UnmapContainer(container, token); await _client.Containers.RemoveContainerAsync(container.ContainerId, new() { Force = true }, token); } catch (DockerContainerNotFoundException) @@ -67,10 +64,6 @@ private static CreateContainerParameters GetCreateContainerParameters(ContainerC Labels = new Dictionary { ["TeamId"] = config.TeamId, ["UserId"] = config.UserId }, Name = DockerMetadata.GetName(config), Env = config.Flag is null ? Array.Empty() : new string[] { $"GZCTF_FLAG={config.Flag}" }, - ExposedPorts = new Dictionary() - { - [config.ExposedPort.ToString()] = new EmptyStruct() - }, HostConfig = new() { PublishAllPorts = true, @@ -83,6 +76,15 @@ private static CreateContainerParameters GetCreateContainerParameters(ContainerC public async Task CreateContainerAsync(ContainerConfig config, CancellationToken token = default) { var parameters = GetCreateContainerParameters(config); + + if (_meta.ExposePort) + { + parameters.ExposedPorts = new Dictionary() + { + [config.ExposedPort.ToString()] = new EmptyStruct() + }; + } + CreateContainerResponse? containerRes = null; try { @@ -116,10 +118,61 @@ await _client.Images.CreateImageAsync(new() return null; } - return await _mapper.MapContainer(new() + var container = new Container() { ContainerId = containerRes.ID, Image = config.Image, - }, config, token); + }; + + var retry = 0; + bool started; + + do + { + started = await _client.Containers.StartContainerAsync(container.ContainerId, new(), token); + retry++; + if (retry == 3) + { + _logger.SystemLog($"启动容器实例 {container.ContainerId[..12]} ({config.Image.Split("/").LastOrDefault()}) 失败", TaskStatus.Failed, LogLevel.Warning); + return null; + } + if (!started) + await Task.Delay(500, token); + } while (!started); + + var info = await _client.Containers.InspectContainerAsync(container.ContainerId, token); + + container.Status = (info.State.Dead || info.State.OOMKilled || info.State.Restarting) ? ContainerStatus.Destroyed : + info.State.Running ? ContainerStatus.Running : ContainerStatus.Pending; + + if (container.Status != ContainerStatus.Running) + { + _logger.SystemLog($"创建 {config.Image.Split("/").LastOrDefault()} 实例遇到错误:{info.State.Error}", TaskStatus.Failed, LogLevel.Warning); + return null; + } + + container.StartedAt = DateTimeOffset.Parse(info.State.StartedAt); + container.ExpectStopAt = container.StartedAt + TimeSpan.FromHours(2); + container.IP = info.NetworkSettings.IPAddress; + container.Port = config.ExposedPort; + container.IsProxy = !_meta.ExposePort; + + if (_meta.ExposePort) + { + var port = info.NetworkSettings.Ports + .FirstOrDefault(p => + p.Key.StartsWith(config.ExposedPort.ToString()) + ).Value.First().HostPort; + + if (int.TryParse(port, out var numport)) + container.PublicPort = numport; + else + _logger.SystemLog($"无法转换端口号:{port},这是非预期的行为", TaskStatus.Failed, LogLevel.Warning); + + if (!string.IsNullOrEmpty(_meta.PublicEntry)) + container.PublicIP = _meta.PublicEntry; + } + + return container; } } diff --git a/src/GZCTF/Services/Container/Manager/K8sManager.cs b/src/GZCTF/Services/Container/Manager/K8sManager.cs index 0631b4f34..098589b8c 100644 --- a/src/GZCTF/Services/Container/Manager/K8sManager.cs +++ b/src/GZCTF/Services/Container/Manager/K8sManager.cs @@ -12,14 +12,12 @@ public class K8sManager : IContainerManager { private readonly ILogger _logger; private readonly IContainerProvider _provider; - private readonly IPortMapper _mapper; private readonly K8sMetadata _meta; private readonly Kubernetes _client; - public K8sManager(IContainerProvider provider, IPortMapper mapper, ILogger logger) + public K8sManager(IContainerProvider provider, ILogger logger) { _logger = logger; - _mapper = mapper; _provider = provider; _meta = _provider.GetMetadata(); _client = _provider.GetProvider(); @@ -121,20 +119,84 @@ public K8sManager(IContainerProvider provider, IPortMap return null; } - - return await _mapper.MapContainer(new Container() + // Service is needed for port mapping + var container = new Container() { ContainerId = name, Image = config.Image, Port = config.ExposedPort, - }, config, token); + }; + + var service = new V1Service("v1", "Service") + { + Metadata = new V1ObjectMeta() + { + Name = name, + NamespaceProperty = _meta.Config.Namespace, + Labels = new Dictionary() { ["ctf.gzti.me/ResourceId"] = name } + }, + Spec = new V1ServiceSpec() + { + Type = _meta.ExposePort ? "NodePort" : "ClusterIP", + Ports = new[] + { + new V1ServicePort(config.ExposedPort, targetPort: config.ExposedPort) + }, + Selector = new Dictionary() + { + ["ctf.gzti.me/ResourceId"] = name + } + } + }; + + try + { + service = await _client.CoreV1.CreateNamespacedServiceAsync(service, _meta.Config.Namespace, cancellationToken: token); + } + catch (HttpOperationException e) + { + try + { + // remove the pod if service creation failed, ignore the error + await _client.CoreV1.DeleteNamespacedPodAsync(name, _meta.Config.Namespace, cancellationToken: token); + } + catch { } + _logger.SystemLog($"服务 {name} 创建失败, 状态:{e.Response.StatusCode}", TaskStatus.Failed, LogLevel.Warning); + _logger.SystemLog($"服务 {name} 创建失败, 响应:{e.Response.Content}", TaskStatus.Failed, LogLevel.Error); + return null; + } + catch (Exception e) + { + try + { + // remove the pod if service creation failed, ignore the error + await _client.CoreV1.DeleteNamespacedPodAsync(name, _meta.Config.Namespace, cancellationToken: token); + } + catch { } + _logger.LogError(e, "创建服务失败"); + return null; + } + + container.StartedAt = DateTimeOffset.UtcNow; + container.ExpectStopAt = container.StartedAt + TimeSpan.FromHours(2); + container.IP = service.Spec.ClusterIP; + container.Port = config.ExposedPort; + container.IsProxy = !_meta.ExposePort; + + if (_meta.ExposePort) + { + container.PublicIP = _meta.PublicEntry; + container.PublicPort = service.Spec.Ports[0].NodePort; + } + + return container; } public async Task DestroyContainerAsync(Container container, CancellationToken token = default) { try { - await _mapper.UnmapContainer(container, token); + await _client.CoreV1.DeleteNamespacedServiceAsync(container.ContainerId, _meta.Config.Namespace, cancellationToken: token); await _client.CoreV1.DeleteNamespacedPodAsync(container.ContainerId, _meta.Config.Namespace, cancellationToken: token); } catch (HttpOperationException e) @@ -156,4 +218,4 @@ public async Task DestroyContainerAsync(Container container, CancellationToken t container.Status = ContainerStatus.Destroyed; } -} \ No newline at end of file +} diff --git a/src/GZCTF/Services/Container/Manager/SwarmManager.cs b/src/GZCTF/Services/Container/Manager/SwarmManager.cs index fcab448e9..2045a1b82 100644 --- a/src/GZCTF/Services/Container/Manager/SwarmManager.cs +++ b/src/GZCTF/Services/Container/Manager/SwarmManager.cs @@ -11,14 +11,12 @@ public class SwarmManager : IContainerManager { private readonly ILogger _logger; private readonly IContainerProvider _provider; - private readonly IPortMapper _mapper; private readonly DockerMetadata _meta; private readonly DockerClient _client; - public SwarmManager(IContainerProvider provider, IPortMapper mapper, ILogger logger) + public SwarmManager(IContainerProvider provider, ILogger logger) { _logger = logger; - _mapper = mapper; _provider = provider; _meta = _provider.GetMetadata(); _client = _provider.GetProvider(); @@ -30,7 +28,6 @@ public async Task DestroyContainerAsync(Container container, CancellationToken t { try { - await _mapper.UnmapContainer(container, token); await _client.Swarm.RemoveServiceAsync(container.ContainerId, token); } catch (DockerContainerNotFoundException) @@ -59,25 +56,15 @@ public async Task DestroyContainerAsync(Container container, CancellationToken t container.Status = ContainerStatus.Destroyed; } - private static string GetName(ContainerConfig config) - => $"{config.Image.Split("/").LastOrDefault()?.Split(":").FirstOrDefault()}_{(config.Flag ?? Guid.NewGuid().ToString()).StrMD5()[..16]}"; - private ServiceCreateParameters GetServiceCreateParameters(ContainerConfig config) => new() { RegistryAuth = _meta.Auth, Service = new() { - Name = GetName(config), + Name = DockerMetadata.GetName(config), Labels = new Dictionary { ["TeamId"] = config.TeamId, ["UserId"] = config.UserId }, Mode = new() { Replicated = new() { Replicas = 1 } }, - EndpointSpec = new() - { - Ports = new PortConfig[] { new() { - PublishMode = "global", - TargetPort = (uint)config.ExposedPort, - } }, - }, TaskTemplate = new() { RestartPolicy = new() { Condition = "none" }, @@ -94,7 +81,15 @@ private ServiceCreateParameters GetServiceCreateParameters(ContainerConfig confi NanoCPUs = config.CPUCount * 1_0000_0000, }, }, - } + }, + EndpointSpec = new() + { + Ports = new PortConfig[] { new() { + PublishMode = _meta.ExposePort ? "global" : "vip", + TargetPort = (uint)config.ExposedPort, + } + }, + }, } }; @@ -130,10 +125,42 @@ private ServiceCreateParameters GetServiceCreateParameters(ContainerConfig confi return null; } - return await _mapper.MapContainer(new() + var container = new Container() { ContainerId = serviceRes.ID, Image = config.Image, - }, config, token); + }; + + retry = 0; + SwarmService? res; + do + { + res = await _client.Swarm.InspectServiceAsync(container.ContainerId, token); + retry++; + if (retry == 3) + { + _logger.SystemLog($"容器 {container.ContainerId} 创建后未获取到端口暴露信息,创建失败", TaskStatus.Failed, LogLevel.Warning); + return null; + } + if (res is not { Endpoint.Ports.Count: > 0 }) + await Task.Delay(500, token); + } while (res is not { Endpoint.Ports.Count: > 0 }); + + // TODO: Test is needed + container.Status = ContainerStatus.Running; + container.StartedAt = res.CreatedAt; + container.ExpectStopAt = container.StartedAt + TimeSpan.FromHours(2); + container.IP = res.Endpoint.VirtualIPs.First().Addr; + container.Port = (int)res.Endpoint.Ports.First().PublishedPort; + container.IsProxy = !_meta.ExposePort; + + if (_meta.ExposePort) + { + container.PublicPort = container.Port; + if (!string.IsNullOrEmpty(_meta.PublicEntry)) + container.PublicIP = _meta.PublicEntry; + } + + return container; } } diff --git a/src/GZCTF/Services/Container/PortMapper/DockerDirectMapper.cs b/src/GZCTF/Services/Container/PortMapper/DockerDirectMapper.cs deleted file mode 100644 index cd1702c65..000000000 --- a/src/GZCTF/Services/Container/PortMapper/DockerDirectMapper.cs +++ /dev/null @@ -1,79 +0,0 @@ -using Docker.DotNet; -using GZCTF.Models.Internal; -using GZCTF.Services.Interface; -using GZCTF.Utils; - -namespace GZCTF.Services; - -public class DockerDirectMapper : IPortMapper -{ - private readonly ILogger _logger; - private readonly IContainerProvider _provider; - private readonly DockerMetadata _meta; - private readonly DockerClient _client; - - public DockerDirectMapper(IContainerProvider provider, ILogger logger) - { - _logger = logger; - _provider = provider; - _meta = _provider.GetMetadata(); - _client = _provider.GetProvider(); - - logger.SystemLog($"端口映射方式:Docker 直接端口映射", TaskStatus.Success, LogLevel.Debug); - } - - public async Task MapContainer(Container container, ContainerConfig config, CancellationToken token = default) - { - var retry = 0; - bool started; - - do - { - started = await _client.Containers.StartContainerAsync(container.ContainerId, new(), token); - retry++; - if (retry == 3) - { - _logger.SystemLog($"启动容器实例 {container.ContainerId[..12]} ({config.Image.Split("/").LastOrDefault()}) 失败", TaskStatus.Failed, LogLevel.Warning); - return null; - } - if (!started) - await Task.Delay(500, token); - } while (!started); - - var info = await _client.Containers.InspectContainerAsync(container.ContainerId, token); - - container.Status = (info.State.Dead || info.State.OOMKilled || info.State.Restarting) ? ContainerStatus.Destroyed : - info.State.Running ? ContainerStatus.Running : ContainerStatus.Pending; - - if (container.Status != ContainerStatus.Running) - { - _logger.SystemLog($"创建 {config.Image.Split("/").LastOrDefault()} 实例遇到错误:{info.State.Error}", TaskStatus.Failed, LogLevel.Warning); - return null; - } - - container.StartedAt = DateTimeOffset.Parse(info.State.StartedAt); - container.ExpectStopAt = container.StartedAt + TimeSpan.FromHours(2); - - var port = info.NetworkSettings.Ports - .FirstOrDefault(p => - p.Key.StartsWith(config.ExposedPort.ToString()) - ).Value.First().HostPort; - - if (int.TryParse(port, out var numport)) - container.Port = numport; - else - _logger.SystemLog($"无法转换端口号:{port},这是非预期的行为", TaskStatus.Failed, LogLevel.Warning); - - container.IP = info.NetworkSettings.IPAddress; - - if (!string.IsNullOrEmpty(_meta.PublicEntry)) - container.PublicIP = _meta.PublicEntry; - - return container; - } - - // DO NOTHING - public Task UnmapContainer(Container container, CancellationToken token = default) - => Task.FromResult(container); -} - diff --git a/src/GZCTF/Services/Container/PortMapper/K8sNodePortMapper.cs b/src/GZCTF/Services/Container/PortMapper/K8sNodePortMapper.cs deleted file mode 100644 index 30833e01b..000000000 --- a/src/GZCTF/Services/Container/PortMapper/K8sNodePortMapper.cs +++ /dev/null @@ -1,95 +0,0 @@ -using GZCTF.Models.Internal; -using GZCTF.Services.Interface; -using GZCTF.Utils; -using k8s; -using k8s.Autorest; -using k8s.Models; - -namespace GZCTF.Services; - -public class K8sNodePortMapper : IPortMapper -{ - private readonly ILogger _logger; - private readonly IContainerProvider _provider; - private readonly K8sMetadata _meta; - private readonly Kubernetes _client; - - public K8sNodePortMapper(IContainerProvider provider, ILogger logger) - { - _logger = logger; - _provider = provider; - _meta = _provider.GetMetadata(); - _client = _provider.GetProvider(); - - logger.SystemLog($"端口映射方式:K8s NodePort 服务端口映射", TaskStatus.Success, LogLevel.Debug); - } - - public async Task MapContainer(Container container, ContainerConfig config, CancellationToken token = default) - { - var name = container.ContainerId; - - var service = new V1Service("v1", "Service") - { - Metadata = new V1ObjectMeta() - { - Name = name, - NamespaceProperty = _meta.Config.Namespace, - Labels = new Dictionary() { ["ctf.gzti.me/ResourceId"] = name } - }, - Spec = new V1ServiceSpec() - { - Type = "NodePort", - Ports = new[] - { - new V1ServicePort(config.ExposedPort, targetPort: config.ExposedPort) - }, - Selector = new Dictionary() - { - ["ctf.gzti.me/ResourceId"] = name - } - } - }; - - try - { - service = await _client.CoreV1.CreateNamespacedServiceAsync(service, _meta.Config.Namespace, cancellationToken: token); - } - catch (HttpOperationException e) - { - try - { - // remove the pod if service creation failed, ignore the error - await _client.CoreV1.DeleteNamespacedPodAsync(name, _meta.Config.Namespace, cancellationToken: token); - } - catch { } - _logger.SystemLog($"服务 {name} 创建失败, 状态:{e.Response.StatusCode}", TaskStatus.Failed, LogLevel.Warning); - _logger.SystemLog($"服务 {name} 创建失败, 响应:{e.Response.Content}", TaskStatus.Failed, LogLevel.Error); - return null; - } - catch (Exception e) - { - try - { - // remove the pod if service creation failed, ignore the error - await _client.CoreV1.DeleteNamespacedPodAsync(name, _meta.Config.Namespace, cancellationToken: token); - } - catch { } - _logger.LogError(e, "创建服务失败"); - return null; - } - - container.PublicPort = service.Spec.Ports[0].NodePort; - container.IP = _meta.HostIP; - container.PublicIP = _meta.PublicEntry; - container.StartedAt = DateTimeOffset.UtcNow; - - return container; - } - - public async Task UnmapContainer(Container container, CancellationToken token = default) - { - await _client.CoreV1.DeleteNamespacedServiceAsync(container.ContainerId, _meta.Config.Namespace, cancellationToken: token); - return container; - } -} - diff --git a/src/GZCTF/Services/Container/PortMapper/SwarmDirectMapper.cs b/src/GZCTF/Services/Container/PortMapper/SwarmDirectMapper.cs deleted file mode 100644 index d6efcb9f4..000000000 --- a/src/GZCTF/Services/Container/PortMapper/SwarmDirectMapper.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Docker.DotNet; -using Docker.DotNet.Models; -using GZCTF.Models.Internal; -using GZCTF.Services.Interface; -using GZCTF.Utils; - -namespace GZCTF.Services; - -public class SwarmDirectMapper : IPortMapper -{ - private readonly ILogger _logger; - private readonly IContainerProvider _provider; - private readonly DockerMetadata _meta; - private readonly DockerClient _client; - - public SwarmDirectMapper(IContainerProvider provider, ILogger logger) - { - _logger = logger; - _provider = provider; - _meta = _provider.GetMetadata(); - _client = _provider.GetProvider(); - - logger.SystemLog($"端口映射方式:Swarm 直接端口映射", TaskStatus.Success, LogLevel.Debug); - } - - public async Task MapContainer(Container container, ContainerConfig config, CancellationToken token = default) - { - var retry = 0; - SwarmService? res; - do - { - res = await _client.Swarm.InspectServiceAsync(container.ContainerId, token); - retry++; - if (retry == 3) - { - _logger.SystemLog($"容器 {container.ContainerId} 创建后未获取到端口暴露信息,创建失败", TaskStatus.Failed, LogLevel.Warning); - return null; - } - if (res is not { Endpoint.Ports.Count: > 0 }) - await Task.Delay(500, token); - } while (res is not { Endpoint.Ports.Count: > 0 }); - - var port = res.Endpoint.Ports.First(); - - container.StartedAt = res.CreatedAt; - container.ExpectStopAt = container.StartedAt + TimeSpan.FromHours(2); - - container.Port = (int)port.PublishedPort; - container.Status = ContainerStatus.Running; - - if (!string.IsNullOrEmpty(_meta.PublicEntry)) - container.PublicIP = _meta.PublicEntry; - - return container; - } - - // DO NOTHING - public Task UnmapContainer(Container container, CancellationToken token = default) - => Task.FromResult(container); -} - diff --git a/src/GZCTF/Services/Container/Provider/DockerProvider.cs b/src/GZCTF/Services/Container/Provider/DockerProvider.cs index 02bf218f6..012551d79 100644 --- a/src/GZCTF/Services/Container/Provider/DockerProvider.cs +++ b/src/GZCTF/Services/Container/Provider/DockerProvider.cs @@ -7,13 +7,8 @@ namespace GZCTF.Services; -public class DockerMetadata +public class DockerMetadata : ContainerProviderMetadata { - /// - /// 公共访问入口 - /// - public string PublicEntry { get; set; } = string.Empty; - /// /// Docker 配置 /// @@ -46,7 +41,8 @@ public DockerProvider(IOptions options, IOptions /// 容器注册表鉴权 Secret 名称 @@ -20,11 +20,6 @@ public class K8sMetadata /// public string HostIP { get; set; } = string.Empty; - /// - /// 公共访问入口 - /// - public string PublicEntry { get; set; } = string.Empty; - /// /// K8s 配置 /// @@ -41,12 +36,13 @@ public class K8sProvider : IContainerProvider public Kubernetes GetProvider() => _kubernetesClient; public K8sMetadata GetMetadata() => _k8sMetadata; - public K8sProvider(IOptions registry, IOptions provider, ILogger logger) + public K8sProvider(IOptions registry, IOptions options, ILogger logger) { _k8sMetadata = new() { - Config = provider.Value.K8sConfig ?? new(), - PublicEntry = provider.Value.PublicEntry + Config = options.Value.K8sConfig ?? new(), + PortMappingType = options.Value.PortMappingType, + PublicEntry = options.Value.PublicEntry, }; if (!File.Exists(_k8sMetadata.Config.KubeConfig)) diff --git a/src/GZCTF/Services/Interface/IPortMapper.cs b/src/GZCTF/Services/Interface/IPortMapper.cs deleted file mode 100644 index f68044866..000000000 --- a/src/GZCTF/Services/Interface/IPortMapper.cs +++ /dev/null @@ -1,23 +0,0 @@ -using GZCTF.Models.Internal; - -namespace GZCTF.Services.Interface; - -public interface IPortMapper -{ - /// - /// 对容器进行端口映射 - /// - /// 容器实例 - /// 容器创建配置 - /// - /// - public Task MapContainer(Container container, ContainerConfig config, CancellationToken token = default); - - /// - /// 对容器取消端口映射 - /// - /// 容器实例 - /// - /// - public Task UnmapContainer(Container container, CancellationToken token = default); -} \ No newline at end of file From e9fb36b25b9be22fbe5cad848b78b8bcbf3fc5de Mon Sep 17 00:00:00 2001 From: GZTime Date: Mon, 14 Aug 2023 11:04:53 +0800 Subject: [PATCH 06/67] wip: rename container entry --- src/GZCTF/ClientApp/src/Api.ts | 65 ++++++++++++++----- .../ClientApp/src/pages/admin/Instances.tsx | 6 +- .../Request/Admin/ContainerInstanceModel.cs | 8 +-- 3 files changed, 55 insertions(+), 24 deletions(-) diff --git a/src/GZCTF/ClientApp/src/Api.ts b/src/GZCTF/ClientApp/src/Api.ts index 35987a24a..a5a7b0454 100644 --- a/src/GZCTF/ClientApp/src/Api.ts +++ b/src/GZCTF/ClientApp/src/Api.ts @@ -549,12 +549,12 @@ export interface ContainerInstanceModel { */ expectStopAt?: string /** 访问 IP */ - publicIP?: string + ip?: string /** * 访问端口 * @format int32 */ - publicPort?: number + port?: number } /** 队伍信息 */ @@ -1669,12 +1669,9 @@ export class HttpClient { private secure?: boolean private format?: ResponseType - constructor({ - securityWorker, - secure, - format, - ...axiosConfig - }: ApiConfig = {}) { + constructor( + { securityWorker, secure, format, ...axiosConfig }: ApiConfig = {} + ) { this.instance = axios.create({ ...axiosConfig, baseURL: axiosConfig.baseURL || '' }) this.secure = secure this.format = format @@ -1727,15 +1724,9 @@ export class HttpClient { }, new FormData()) } - public request = async ({ - secure, - path, - type, - query, - format, - body, - ...params - }: FullRequestParams): Promise> => { + public request = async ( + { secure, path, type, query, format, body, ...params }: FullRequestParams + ): Promise> => { const secureParams = ((typeof secure === 'boolean' ? secure : this.secure) && this.securityWorker && @@ -4535,6 +4526,46 @@ export class Api extends HttpClient, options?: MutatorOptions) => mutate(`/api/sitekey`, data, options), } + proxy = { + /** + * No description + * + * @tags Proxy + * @name ProxyProxyForInstance + * @summary 采用 websocket 代理 TCP 流量 + * @request GET:/inst/{id} + */ + proxyProxyForInstance: (id: string, params: RequestParams = {}) => + this.request({ + path: `/inst/${id}`, + method: 'GET', + ...params, + }), + /** + * No description + * + * @tags Proxy + * @name ProxyProxyForInstance + * @summary 采用 websocket 代理 TCP 流量 + * @request GET:/inst/{id} + */ + useProxyProxyForInstance: (id: string, options?: SWRConfiguration, doFetch: boolean = true) => + useSWR(doFetch ? `/inst/${id}` : null, options), + + /** + * No description + * + * @tags Proxy + * @name ProxyProxyForInstance + * @summary 采用 websocket 代理 TCP 流量 + * @request GET:/inst/{id} + */ + mutateProxyProxyForInstance: ( + id: string, + data?: any | Promise, + options?: MutatorOptions + ) => mutate(`/inst/${id}`, data, options), + } team = { /** * @description 接受邀请的接口,需要User权限,且不在队伍中 diff --git a/src/GZCTF/ClientApp/src/pages/admin/Instances.tsx b/src/GZCTF/ClientApp/src/pages/admin/Instances.tsx index 133858df4..f0d1d7140 100644 --- a/src/GZCTF/ClientApp/src/pages/admin/Instances.tsx +++ b/src/GZCTF/ClientApp/src/pages/admin/Instances.tsx @@ -266,7 +266,7 @@ const Instances: FC = () => { cursor: 'pointer', }} onClick={() => { - clipBoard.copy(`${inst.publicIP ?? ''}:${inst.publicPort ?? ''}`) + clipBoard.copy(`${inst.ip ?? ''}:${inst.port ?? ''}`) showNotification({ color: 'teal', message: '实例入口已复制到剪贴板', @@ -274,9 +274,9 @@ const Instances: FC = () => { }) }} > - {`${inst.publicIP}:`} + {`${inst.ip}:`} - {inst.publicPort} + {inst.port} diff --git a/src/GZCTF/Models/Request/Admin/ContainerInstanceModel.cs b/src/GZCTF/Models/Request/Admin/ContainerInstanceModel.cs index 2e81642fd..0a3e781f1 100644 --- a/src/GZCTF/Models/Request/Admin/ContainerInstanceModel.cs +++ b/src/GZCTF/Models/Request/Admin/ContainerInstanceModel.cs @@ -45,12 +45,12 @@ public class ContainerInstanceModel /// /// 访问 IP /// - public string PublicIP { get; set; } = string.Empty; + public string IP { get; set; } = string.Empty; /// /// 访问端口 /// - public int PublicPort { get; set; } + public int Port { get; set; } internal static ContainerInstanceModel FromContainer(Container container) { @@ -65,8 +65,8 @@ internal static ContainerInstanceModel FromContainer(Container container) StartedAt = container.StartedAt, ExpectStopAt = container.ExpectStopAt, // fallback to host if public is null - PublicIP = container.PublicIP ?? container.IP, - PublicPort = container.PublicPort ?? container.Port + IP = container.PublicIP ?? container.IP, + Port = container.PublicPort ?? container.Port }; if (team is not null && chal is not null) From a1cb4a284c568914b625e74f1062c7d993140c72 Mon Sep 17 00:00:00 2001 From: GZTime Date: Mon, 14 Aug 2023 20:19:53 +0800 Subject: [PATCH 07/67] wip: proxy controller --- src/GZCTF/Controllers/ProxyController.cs | 109 ++++++++++++++++++++--- src/GZCTF/Services/MailSender.cs | 8 +- src/GZCTF/Utils/CacheHelper.cs | 5 ++ 3 files changed, 107 insertions(+), 15 deletions(-) diff --git a/src/GZCTF/Controllers/ProxyController.cs b/src/GZCTF/Controllers/ProxyController.cs index 3a43e5c04..ffc8657ba 100644 --- a/src/GZCTF/Controllers/ProxyController.cs +++ b/src/GZCTF/Controllers/ProxyController.cs @@ -1,10 +1,13 @@  +using System.ComponentModel.DataAnnotations; using System.Net.Sockets; using System.Net.WebSockets; +using System.Text; using GZCTF.Models.Internal; using GZCTF.Repositories.Interface; using GZCTF.Utils; using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Options; using PacketDotNet; using ProtocolType = System.Net.Sockets.ProtocolType; @@ -19,14 +22,17 @@ namespace GZCTF.Controllers; public class ProxyController : ControllerBase { private readonly ILogger _logger; + private readonly IDistributedCache _cache; private readonly IContainerRepository _containerRepository; private readonly bool _enablePlatformProxy = false; private const int BUFFER_SIZE = 1024 * 4; - private const int CONNECTION_LIMIT = 128; + private const uint CONNECTION_LIMIT = 128; - public ProxyController(ILogger logger, IOptions provider, IContainerRepository containerRepository) + public ProxyController(ILogger logger, IDistributedCache cache, + IOptions provider, IContainerRepository containerRepository) { + _cache = cache; _logger = logger; _enablePlatformProxy = provider.Value.PortMappingType == ContainerPortMappingType.PlatformProxy; _containerRepository = containerRepository; @@ -35,11 +41,13 @@ public ProxyController(ILogger logger, IOptions /// 采用 websocket 代理 TCP 流量 /// + /// 容器 id + /// /// - [Route("/inst/{id}")] + [Route("{id:length(36)}")] [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status400BadRequest)] - public async Task ProxyForInstance(string id) + public async Task ProxyForInstance([RegularExpression(@"[0-9a-f\-]{36}")] string id, CancellationToken token = default) { if (!_enablePlatformProxy) return BadRequest(new RequestResponse("TCP 代理已禁用")); @@ -47,11 +55,14 @@ public async Task ProxyForInstance(string id) if (!HttpContext.WebSockets.IsWebSocketRequest) return BadRequest(new RequestResponse("仅支持 Websocket 请求")); - var container = await _containerRepository.GetContainerById(id); + var container = await _containerRepository.GetContainerById(id, token); if (container is null || !container.IsProxy) return NotFound(new RequestResponse("不存在的容器")); + if (!await IncrementConnectionCount(id, token)) + return BadRequest(new RequestResponse("容器连接数已达上限")); + var socket = new Socket(AddressFamily.Unspecified, SocketType.Stream, ProtocolType.Tcp); await socket.ConnectAsync(container.IP, container.Port); @@ -61,7 +72,32 @@ public async Task ProxyForInstance(string id) var ws = await HttpContext.WebSockets.AcceptWebSocketAsync(); var stream = new NetworkStream(socket); - var cts = new CancellationTokenSource(); + try + { + _logger.SystemLog($"[{id}] {HttpContext.Connection.RemoteIpAddress} -> {container.IP}:{container.Port}", TaskStatus.Pending, LogLevel.Debug); + await RunProxy(stream, token); + } + finally + { + await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", token); + await stream.DisposeAsync(); + await DecrementConnectionCount(id, token); + } + + return Ok(); + } + + /// + /// 采用 websocket 代理 TCP 流量 + /// + /// + /// + /// + internal async Task RunProxy(NetworkStream stream, CancellationToken token = default) + { + var cts = CancellationTokenSource.CreateLinkedTokenSource(token); + cts.CancelAfter(TimeSpan.FromMinutes(30)); + var ct = cts.Token; var sender = Task.Run(async () => @@ -69,13 +105,13 @@ public async Task ProxyForInstance(string id) var buffer = new byte[BUFFER_SIZE]; while (true) { - var status = await ws.ReceiveAsync(buffer, ct); - if (status.Count == 0) + var status = await HttpContext.Request.Body.ReadAsync(buffer, ct); + if (status == 0) { cts.Cancel(); break; } - await stream.WriteAsync(buffer.AsMemory(0, status.Count), ct); + await stream.WriteAsync(buffer.AsMemory(0, status), ct); } }, ct); @@ -90,15 +126,62 @@ public async Task ProxyForInstance(string id) cts.Cancel(); break; } - await ws.SendAsync(new ArraySegment(buffer, 0, count), WebSocketMessageType.Binary, true, ct); + await HttpContext.Response.Body.WriteAsync(buffer.AsMemory(0, count), ct); } }, ct); await Task.WhenAny(sender, receiver); + } + + /// + /// 实现容器 TCP 连接计数的 Fetch-Add 操作 + /// + /// 容器 id + /// + /// + internal async Task IncrementConnectionCount(string id, CancellationToken token = default) + { + var key = CacheKey.ConnectionCount(id); + var bytes = await _cache.GetAsync(key); + var count = bytes is null ? 0 : BitConverter.ToUInt32(bytes); - await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", ct); - await stream.DisposeAsync(); + if (count >= CONNECTION_LIMIT) + return false; - return Ok(); + await _cache.SetAsync(key, BitConverter.GetBytes(count + 1), new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(10) + }, token); + + return true; + } + + /// + /// 实现容器 TCP 连接计数的减少操作 + /// + /// 容器 id + /// + /// + internal async Task DecrementConnectionCount(string id, CancellationToken token = default) + { + var key = CacheKey.ConnectionCount(id); + var bytes = await _cache.GetAsync(key); + + if (bytes is null) + return; + + var count = BitConverter.ToUInt32(bytes); + + if (count <= 1) + { + await _cache.RemoveAsync(key, token); + } + else + { + await _cache.SetAsync(key, BitConverter.GetBytes(count - 1), new DistributedCacheEntryOptions + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(10) + }, token); + } } } diff --git a/src/GZCTF/Services/MailSender.cs b/src/GZCTF/Services/MailSender.cs index 02c86f28f..6b79321d8 100644 --- a/src/GZCTF/Services/MailSender.cs +++ b/src/GZCTF/Services/MailSender.cs @@ -1,4 +1,5 @@ using System.Reflection; +using System.Text; using GZCTF.Models.Internal; using GZCTF.Services.Interface; using GZCTF.Utils; @@ -66,14 +67,17 @@ public async Task SendUrlAsync(string? title, string? information, string? btnms string emailContent = await new StreamReader(asm.GetManifestResourceStream(resourceName)!) .ReadToEndAsync(); - emailContent = emailContent + + emailContent = new StringBuilder(emailContent) .Replace("{title}", title) .Replace("{information}", information) .Replace("{btnmsg}", btnmsg) .Replace("{email}", email) .Replace("{userName}", userName) .Replace("{url}", url) - .Replace("{nowtime}", DateTimeOffset.UtcNow.ToString("u")); + .Replace("{nowtime}", DateTimeOffset.UtcNow.ToString("u")) + .ToString(); + if (!await SendEmailAsync(title, emailContent, email)) _logger.SystemLog("邮件发送失败!", TaskStatus.Failed); } diff --git a/src/GZCTF/Utils/CacheHelper.cs b/src/GZCTF/Utils/CacheHelper.cs index a8a238970..dad7aa464 100644 --- a/src/GZCTF/Utils/CacheHelper.cs +++ b/src/GZCTF/Utils/CacheHelper.cs @@ -86,4 +86,9 @@ public static class CacheKey /// 文章 /// public const string Posts = "_Posts"; + + /// + /// 容器连接数缓存 + /// + public static string ConnectionCount(string id) => $"_Container_Conn_{id}"; } From 71b062db89a9081af3f5e9ab9374262b33c92153 Mon Sep 17 00:00:00 2001 From: GZTime Date: Tue, 15 Aug 2023 01:06:17 +0800 Subject: [PATCH 08/67] wip: proxy controller --- src/GZCTF/Controllers/ProxyController.cs | 100 +++++++++++++++-------- src/GZCTF/Program.cs | 2 + 2 files changed, 68 insertions(+), 34 deletions(-) diff --git a/src/GZCTF/Controllers/ProxyController.cs b/src/GZCTF/Controllers/ProxyController.cs index ffc8657ba..f61076262 100644 --- a/src/GZCTF/Controllers/ProxyController.cs +++ b/src/GZCTF/Controllers/ProxyController.cs @@ -1,8 +1,7 @@ - -using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations; +using System.Net; using System.Net.Sockets; using System.Net.WebSockets; -using System.Text; using GZCTF.Models.Internal; using GZCTF.Repositories.Interface; using GZCTF.Utils; @@ -60,86 +59,120 @@ public async Task ProxyForInstance([RegularExpression(@"[0-9a-f\- if (container is null || !container.IsProxy) return NotFound(new RequestResponse("不存在的容器")); - if (!await IncrementConnectionCount(id, token)) + if (!await IncrementConnectionCount(id)) return BadRequest(new RequestResponse("容器连接数已达上限")); - var socket = new Socket(AddressFamily.Unspecified, SocketType.Stream, ProtocolType.Tcp); - await socket.ConnectAsync(container.IP, container.Port); + var ipAddress = (await Dns.GetHostAddressesAsync(container.IP, token)).FirstOrDefault(); - if (!socket.Connected) + if (ipAddress is null) + return BadRequest(new RequestResponse("容器地址解析失败")); + + NetworkStream? stream; + try + { + IPEndPoint ipEndPoint = new(ipAddress, container.Port); + var socket = new Socket(ipEndPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + await socket.ConnectAsync(ipEndPoint, token); + + if (!socket.Connected) + return BadRequest(new RequestResponse("容器连接失败")); + + stream = new NetworkStream(socket); + } + catch + { return BadRequest(new RequestResponse("容器连接失败")); + } var ws = await HttpContext.WebSockets.AcceptWebSocketAsync(); - var stream = new NetworkStream(socket); + var clientIp = HttpContext.Connection.RemoteIpAddress; + var clientPort = HttpContext.Connection.RemotePort; + + _logger.SystemLog($"[{id}] {clientIp}:{clientPort} -> {container.IP}:{container.Port}", TaskStatus.Pending, LogLevel.Debug); try { - _logger.SystemLog($"[{id}] {HttpContext.Connection.RemoteIpAddress} -> {container.IP}:{container.Port}", TaskStatus.Pending, LogLevel.Debug); - await RunProxy(stream, token); + var (tx, rx) = await RunProxy(stream, ws, token); + _logger.SystemLog($"[{id}] tx {tx}, rx {rx}", TaskStatus.Success, LogLevel.Debug); } finally { - await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, "bye", token); - await stream.DisposeAsync(); - await DecrementConnectionCount(id, token); + await DecrementConnectionCount(id); } - return Ok(); + return new EmptyResult(); } /// /// 采用 websocket 代理 TCP 流量 /// /// + /// /// /// - internal async Task RunProxy(NetworkStream stream, CancellationToken token = default) + internal static async Task<(ulong, ulong)> RunProxy(NetworkStream stream, WebSocket ws, CancellationToken token = default) { var cts = CancellationTokenSource.CreateLinkedTokenSource(token); cts.CancelAfter(TimeSpan.FromMinutes(30)); var ct = cts.Token; + ulong tx = 0, rx = 0; var sender = Task.Run(async () => { var buffer = new byte[BUFFER_SIZE]; - while (true) + try { - var status = await HttpContext.Request.Body.ReadAsync(buffer, ct); - if (status == 0) + while (true) { - cts.Cancel(); - break; + var status = await ws.ReceiveAsync(buffer, ct); + if (status.CloseStatus.HasValue) + { + await stream.DisposeAsync(); + cts.Cancel(); + break; + } + tx += (ulong)status.Count; + await stream.WriteAsync(buffer.AsMemory(0, status.Count), ct); } - await stream.WriteAsync(buffer.AsMemory(0, status), ct); } + catch (TaskCanceledException) { } + catch { cts.Cancel(); } }, ct); var receiver = Task.Run(async () => { var buffer = new byte[BUFFER_SIZE]; - while (true) + try { - var count = await stream.ReadAsync(buffer, ct); - if (count == 0) + while (true) { - cts.Cancel(); - break; + var count = await stream.ReadAsync(buffer, ct); + if (count == 0) + { + await ws.CloseAsync(WebSocketCloseStatus.Empty, null, token); + cts.Cancel(); + break; + } + rx += (ulong)count; + await ws.SendAsync(buffer.AsMemory(0, count), WebSocketMessageType.Binary, true, ct); } - await HttpContext.Response.Body.WriteAsync(buffer.AsMemory(0, count), ct); } + catch (TaskCanceledException) { } + catch { cts.Cancel(); } }, ct); await Task.WhenAny(sender, receiver); + + return (tx, rx); } /// /// 实现容器 TCP 连接计数的 Fetch-Add 操作 /// /// 容器 id - /// /// - internal async Task IncrementConnectionCount(string id, CancellationToken token = default) + internal async Task IncrementConnectionCount(string id) { var key = CacheKey.ConnectionCount(id); var bytes = await _cache.GetAsync(key); @@ -151,7 +184,7 @@ internal async Task IncrementConnectionCount(string id, CancellationToken await _cache.SetAsync(key, BitConverter.GetBytes(count + 1), new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(10) - }, token); + }); return true; } @@ -160,9 +193,8 @@ internal async Task IncrementConnectionCount(string id, CancellationToken /// 实现容器 TCP 连接计数的减少操作 /// /// 容器 id - /// /// - internal async Task DecrementConnectionCount(string id, CancellationToken token = default) + internal async Task DecrementConnectionCount(string id) { var key = CacheKey.ConnectionCount(id); var bytes = await _cache.GetAsync(key); @@ -174,14 +206,14 @@ internal async Task DecrementConnectionCount(string id, CancellationToken token if (count <= 1) { - await _cache.RemoveAsync(key, token); + await _cache.RemoveAsync(key); } else { await _cache.SetAsync(key, BitConverter.GetBytes(count - 1), new DistributedCacheEntryOptions { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(10) - }, token); + }); } } } diff --git a/src/GZCTF/Program.cs b/src/GZCTF/Program.cs index 4219a5a92..ab16e3b46 100644 --- a/src/GZCTF/Program.cs +++ b/src/GZCTF/Program.cs @@ -287,6 +287,8 @@ if (app.Environment.IsDevelopment() || app.Configuration.GetValue("RequestLogging") is true) app.UseRequestLogging(); +app.UseWebSockets(new() { KeepAliveInterval = TimeSpan.FromMinutes(30) }); + app.MapControllers(); app.MapHub("/hub/user"); app.MapHub("/hub/monitor"); From 1e2607c780357fbeab01886fcca23765187d50a0 Mon Sep 17 00:00:00 2001 From: GZTime Date: Tue, 15 Aug 2023 13:33:33 +0800 Subject: [PATCH 09/67] wip(proxy): ping conn support --- src/GZCTF/Controllers/ProxyController.cs | 76 ++++++++++++++----- src/GZCTF/Repositories/ContainerRepository.cs | 3 + .../Interface/IContainerRepository.cs | 8 ++ 3 files changed, 67 insertions(+), 20 deletions(-) diff --git a/src/GZCTF/Controllers/ProxyController.cs b/src/GZCTF/Controllers/ProxyController.cs index f61076262..8491e8d71 100644 --- a/src/GZCTF/Controllers/ProxyController.cs +++ b/src/GZCTF/Controllers/ProxyController.cs @@ -26,7 +26,7 @@ public class ProxyController : ControllerBase private readonly bool _enablePlatformProxy = false; private const int BUFFER_SIZE = 1024 * 4; - private const uint CONNECTION_LIMIT = 128; + private const uint CONNECTION_LIMIT = 64; public ProxyController(ILogger logger, IDistributedCache cache, IOptions provider, IContainerRepository containerRepository) @@ -44,6 +44,7 @@ public ProxyController(ILogger logger, IDistributedCache cache, /// /// [Route("{id:length(36)}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status400BadRequest)] public async Task ProxyForInstance([RegularExpression(@"[0-9a-f\-]{36}")] string id, CancellationToken token = default) @@ -51,17 +52,20 @@ public async Task ProxyForInstance([RegularExpression(@"[0-9a-f\- if (!_enablePlatformProxy) return BadRequest(new RequestResponse("TCP 代理已禁用")); + if (!await ValidateContainer(id, token)) + return NotFound(new RequestResponse("不存在的容器")); + if (!HttpContext.WebSockets.IsWebSocketRequest) - return BadRequest(new RequestResponse("仅支持 Websocket 请求")); + return NoContent(); + + if (!await IncrementConnectionCount(id)) + return BadRequest(new RequestResponse("容器连接数已达上限")); var container = await _containerRepository.GetContainerById(id, token); if (container is null || !container.IsProxy) return NotFound(new RequestResponse("不存在的容器")); - if (!await IncrementConnectionCount(id)) - return BadRequest(new RequestResponse("容器连接数已达上限")); - var ipAddress = (await Dns.GetHostAddressesAsync(container.IP, token)).FirstOrDefault(); if (ipAddress is null) @@ -93,7 +97,7 @@ public async Task ProxyForInstance([RegularExpression(@"[0-9a-f\- try { var (tx, rx) = await RunProxy(stream, ws, token); - _logger.SystemLog($"[{id}] tx {tx}, rx {rx}", TaskStatus.Success, LogLevel.Debug); + _logger.SystemLog($"[{id}] {clientIp}:{clientPort}, tx {tx}, rx {rx}", TaskStatus.Success, LogLevel.Debug); } finally { @@ -132,8 +136,11 @@ public async Task ProxyForInstance([RegularExpression(@"[0-9a-f\- cts.Cancel(); break; } - tx += (ulong)status.Count; - await stream.WriteAsync(buffer.AsMemory(0, status.Count), ct); + if (status.Count > 0) + { + tx += (ulong)status.Count; + await stream.WriteAsync(buffer.AsMemory(0, status.Count), ct); + } } } catch (TaskCanceledException) { } @@ -167,6 +174,37 @@ public async Task ProxyForInstance([RegularExpression(@"[0-9a-f\- return (tx, rx); } + private readonly DistributedCacheEntryOptions _validOption = new () + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) + }; + + private readonly DistributedCacheEntryOptions _storeOption = new() + { + AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(10) + }; + + /// + /// 容器存在性校验 + /// + /// 容器 id + /// + internal async Task ValidateContainer(string id, CancellationToken token = default) + { + var key = CacheKey.ConnectionCount(id); + var bytes = await _cache.GetAsync(key, token); + + if (bytes is not null) + return true; + + var valid = await _containerRepository.ValidateContainer(id, token); + + if (valid) + await _cache.SetAsync(key, BitConverter.GetBytes(0), _validOption, token); + + return valid; + } + /// /// 实现容器 TCP 连接计数的 Fetch-Add 操作 /// @@ -176,15 +214,16 @@ internal async Task IncrementConnectionCount(string id) { var key = CacheKey.ConnectionCount(id); var bytes = await _cache.GetAsync(key); - var count = bytes is null ? 0 : BitConverter.ToUInt32(bytes); - if (count >= CONNECTION_LIMIT) + if (bytes is null) return false; - await _cache.SetAsync(key, BitConverter.GetBytes(count + 1), new DistributedCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(10) - }); + var count = BitConverter.ToUInt32(bytes); + + if (count > CONNECTION_LIMIT) + return false; + + await _cache.SetAsync(key, BitConverter.GetBytes(count + 1), _storeOption); return true; } @@ -204,16 +243,13 @@ internal async Task DecrementConnectionCount(string id) var count = BitConverter.ToUInt32(bytes); - if (count <= 1) + if (count > 1) { - await _cache.RemoveAsync(key); + await _cache.SetAsync(key, BitConverter.GetBytes(count - 1), _storeOption); } else { - await _cache.SetAsync(key, BitConverter.GetBytes(count - 1), new DistributedCacheEntryOptions - { - AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(10) - }); + await _cache.SetAsync(key, BitConverter.GetBytes(0), _validOption); } } } diff --git a/src/GZCTF/Repositories/ContainerRepository.cs b/src/GZCTF/Repositories/ContainerRepository.cs index 5338881f2..720d4c0c2 100644 --- a/src/GZCTF/Repositories/ContainerRepository.cs +++ b/src/GZCTF/Repositories/ContainerRepository.cs @@ -41,4 +41,7 @@ public Task RemoveContainer(Container container, CancellationToken token = defau _context.Containers.Remove(container); return SaveAsync(token); } + + public async Task ValidateContainer(string guid, CancellationToken token = default) + => await _context.Containers.AnyAsync(c => c.Id == guid, token); } diff --git a/src/GZCTF/Repositories/Interface/IContainerRepository.cs b/src/GZCTF/Repositories/Interface/IContainerRepository.cs index 1001a421f..efc719910 100644 --- a/src/GZCTF/Repositories/Interface/IContainerRepository.cs +++ b/src/GZCTF/Repositories/Interface/IContainerRepository.cs @@ -19,6 +19,14 @@ public interface IContainerRepository : IRepository /// public Task GetContainerById(string guid, CancellationToken token = default); + /// + /// 容器数据库 ID 对应容器是否存在 + /// + /// 容器数据库 ID + /// + /// + public Task ValidateContainer(string guid, CancellationToken token = default); + /// /// 获取容器实例信息 /// From 51e5e6c4a812be6c199be2a3d229a8755c293bf6 Mon Sep 17 00:00:00 2001 From: GZTime Date: Tue, 15 Aug 2023 17:46:08 +0800 Subject: [PATCH 10/67] wip: proxy with traffic capture --- src/GZCTF/Controllers/AdminController.cs | 6 +- src/GZCTF/Controllers/ProxyController.cs | 61 +++++++--- src/GZCTF/GZCTF.csproj | 1 + src/GZCTF/Models/Data/Container.cs | 7 ++ src/GZCTF/Models/Internal/Configs.cs | 1 + src/GZCTF/Repositories/ContainerRepository.cs | 2 +- src/GZCTF/Utils/CapturableNetworkStream.cs | 110 ++++++++++++++++++ src/GZCTF/Utils/PrelaunchHelper.cs | 9 +- 8 files changed, 175 insertions(+), 22 deletions(-) create mode 100644 src/GZCTF/Utils/CapturableNetworkStream.cs diff --git a/src/GZCTF/Controllers/AdminController.cs b/src/GZCTF/Controllers/AdminController.cs index 7139b5663..a82f05d56 100644 --- a/src/GZCTF/Controllers/AdminController.cs +++ b/src/GZCTF/Controllers/AdminController.cs @@ -26,6 +26,8 @@ namespace GZCTF.Controllers; [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status403Forbidden)] public class AdminController : ControllerBase { + private const string BasePath = "files/uploads"; + private readonly ILogger _logger; private readonly UserManager _userManager; private readonly ILogRepository _logRepository; @@ -37,7 +39,6 @@ public class AdminController : ControllerBase private readonly IContainerRepository _containerRepository; private readonly IInstanceRepository _instanceRepository; private readonly IParticipationRepository _participationRepository; - private readonly string _basepath; public AdminController(UserManager userManager, ILogger logger, @@ -63,7 +64,6 @@ public AdminController(UserManager userManager, _containerRepository = containerRepository; _serviceProvider = serviceProvider; _participationRepository = participationRepository; - _basepath = configuration.GetSection("UploadFolder").Value ?? "uploads"; } /// @@ -522,7 +522,7 @@ public async Task DownloadAllWriteups(int id, CancellationToken t var wps = await _participationRepository.GetWriteups(game, token); var filename = $"Writeups-{game.Title}-{DateTimeOffset.UtcNow:yyyyMMdd-HH.mm.ssZ}"; - var stream = await Codec.ZipFilesAsync(wps.Select(p => p.File), _basepath, filename, token); + var stream = await Codec.ZipFilesAsync(wps.Select(p => p.File), BasePath, filename, token); stream.Seek(0, SeekOrigin.Begin); return File(stream, "application/zip", $"{filename}.zip"); diff --git a/src/GZCTF/Controllers/ProxyController.cs b/src/GZCTF/Controllers/ProxyController.cs index 8491e8d71..d3d6159bc 100644 --- a/src/GZCTF/Controllers/ProxyController.cs +++ b/src/GZCTF/Controllers/ProxyController.cs @@ -1,5 +1,4 @@ -using System.ComponentModel.DataAnnotations; -using System.Net; +using System.Net; using System.Net.Sockets; using System.Net.WebSockets; using GZCTF.Models.Internal; @@ -8,8 +7,6 @@ using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.Options; -using PacketDotNet; -using ProtocolType = System.Net.Sockets.ProtocolType; namespace GZCTF.Controllers; @@ -25,6 +22,7 @@ public class ProxyController : ControllerBase private readonly IContainerRepository _containerRepository; private readonly bool _enablePlatformProxy = false; + private readonly bool _enableTrafficCapture = false; private const int BUFFER_SIZE = 1024 * 4; private const uint CONNECTION_LIMIT = 64; @@ -34,6 +32,7 @@ public ProxyController(ILogger logger, IDistributedCache cache, _cache = cache; _logger = logger; _enablePlatformProxy = provider.Value.PortMappingType == ContainerPortMappingType.PlatformProxy; + _enableTrafficCapture = provider.Value.EnableTrafficCapture; _containerRepository = containerRepository; } @@ -43,11 +42,11 @@ public ProxyController(ILogger logger, IDistributedCache cache, /// 容器 id /// /// - [Route("{id:length(36)}")] + [Route("{id:guid}")] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status404NotFound)] [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status400BadRequest)] - public async Task ProxyForInstance([RegularExpression(@"[0-9a-f\-]{36}")] string id, CancellationToken token = default) + public async Task ProxyForInstance(string id, CancellationToken token = default) { if (!_enablePlatformProxy) return BadRequest(new RequestResponse("TCP 代理已禁用")); @@ -63,7 +62,7 @@ public async Task ProxyForInstance([RegularExpression(@"[0-9a-f\- var container = await _containerRepository.GetContainerById(id, token); - if (container is null || !container.IsProxy) + if (container is null || container.Instance is null || !container.IsProxy) return NotFound(new RequestResponse("不存在的容器")); var ipAddress = (await Dns.GetHostAddressesAsync(container.IP, token)).FirstOrDefault(); @@ -71,7 +70,13 @@ public async Task ProxyForInstance([RegularExpression(@"[0-9a-f\- if (ipAddress is null) return BadRequest(new RequestResponse("容器地址解析失败")); - NetworkStream? stream; + var clientIp = HttpContext.Connection.RemoteIpAddress; + var clientPort = HttpContext.Connection.RemotePort; + + if (clientIp is null) + return BadRequest(new RequestResponse("无效的访问地址")); + + CapturableNetworkStream? stream; try { IPEndPoint ipEndPoint = new(ipAddress, container.Port); @@ -79,18 +84,26 @@ public async Task ProxyForInstance([RegularExpression(@"[0-9a-f\- await socket.ConnectAsync(ipEndPoint, token); if (!socket.Connected) + { + _logger.SystemLog($"容器连接失败,请检查网络配置 -> {container.IP}:{container.Port}", TaskStatus.Failed, LogLevel.Warning); return BadRequest(new RequestResponse("容器连接失败")); + } - stream = new NetworkStream(socket); + stream = new CapturableNetworkStream(socket, new() + { + Source = new(clientIp, clientPort), + Dest = ipEndPoint, + EnableCapture = _enableTrafficCapture, + FilePath = container.TrafficPath, + }); } - catch + catch (Exception e) { + _logger.LogError(e, "容器连接失败"); return BadRequest(new RequestResponse("容器连接失败")); } var ws = await HttpContext.WebSockets.AcceptWebSocketAsync(); - var clientIp = HttpContext.Connection.RemoteIpAddress; - var clientPort = HttpContext.Connection.RemotePort; _logger.SystemLog($"[{id}] {clientIp}:{clientPort} -> {container.IP}:{container.Port}", TaskStatus.Pending, LogLevel.Debug); @@ -99,6 +112,10 @@ public async Task ProxyForInstance([RegularExpression(@"[0-9a-f\- var (tx, rx) = await RunProxy(stream, ws, token); _logger.SystemLog($"[{id}] {clientIp}:{clientPort}, tx {tx}, rx {rx}", TaskStatus.Success, LogLevel.Debug); } + catch (Exception e) + { + _logger.LogError(e, "代理错误"); + } finally { await DecrementConnectionCount(id); @@ -114,7 +131,7 @@ public async Task ProxyForInstance([RegularExpression(@"[0-9a-f\- /// /// /// - internal static async Task<(ulong, ulong)> RunProxy(NetworkStream stream, WebSocket ws, CancellationToken token = default) + internal async Task<(ulong, ulong)> RunProxy(CapturableNetworkStream stream, WebSocket ws, CancellationToken token = default) { var cts = CancellationTokenSource.CreateLinkedTokenSource(token); cts.CancelAfter(TimeSpan.FromMinutes(30)); @@ -132,7 +149,7 @@ public async Task ProxyForInstance([RegularExpression(@"[0-9a-f\- var status = await ws.ReceiveAsync(buffer, ct); if (status.CloseStatus.HasValue) { - await stream.DisposeAsync(); + stream.Close(); cts.Cancel(); break; } @@ -144,7 +161,11 @@ public async Task ProxyForInstance([RegularExpression(@"[0-9a-f\- } } catch (TaskCanceledException) { } - catch { cts.Cancel(); } + finally + { + stream.Close(); + cts.Cancel(); + } }, ct); var receiver = Task.Run(async () => @@ -158,6 +179,7 @@ public async Task ProxyForInstance([RegularExpression(@"[0-9a-f\- if (count == 0) { await ws.CloseAsync(WebSocketCloseStatus.Empty, null, token); + stream.Close(); cts.Cancel(); break; } @@ -166,7 +188,11 @@ public async Task ProxyForInstance([RegularExpression(@"[0-9a-f\- } } catch (TaskCanceledException) { } - catch { cts.Cancel(); } + finally + { + stream.Close(); + cts.Cancel(); + } }, ct); await Task.WhenAny(sender, receiver); @@ -174,7 +200,7 @@ public async Task ProxyForInstance([RegularExpression(@"[0-9a-f\- return (tx, rx); } - private readonly DistributedCacheEntryOptions _validOption = new () + private readonly DistributedCacheEntryOptions _validOption = new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10) }; @@ -188,6 +214,7 @@ public async Task ProxyForInstance([RegularExpression(@"[0-9a-f\- /// 容器存在性校验 /// /// 容器 id + /// /// internal async Task ValidateContainer(string id, CancellationToken token = default) { diff --git a/src/GZCTF/GZCTF.csproj b/src/GZCTF/GZCTF.csproj index 201112aee..30e2a0660 100644 --- a/src/GZCTF/GZCTF.csproj +++ b/src/GZCTF/GZCTF.csproj @@ -63,6 +63,7 @@ + diff --git a/src/GZCTF/Models/Data/Container.cs b/src/GZCTF/Models/Data/Container.cs index 4adf82c8c..5672f9469 100644 --- a/src/GZCTF/Models/Data/Container.cs +++ b/src/GZCTF/Models/Data/Container.cs @@ -72,6 +72,13 @@ public class Container [NotMapped] public string Entry => IsProxy ? Id : $"{PublicIP ?? IP}:{PublicPort ?? Port}"; + /// + /// 容器实例流量捕获存储路径 + /// + [NotMapped] + public string TrafficPath => Instance is null ? string.Empty : + $"files/capture/{Instance.ParticipationId}/{Instance.ChallengeId}/{DateTimeOffset.Now:s}.pcap"; + #region Db Relationship /// diff --git a/src/GZCTF/Models/Internal/Configs.cs b/src/GZCTF/Models/Internal/Configs.cs index 18a35fb4d..5b9a0beb5 100644 --- a/src/GZCTF/Models/Internal/Configs.cs +++ b/src/GZCTF/Models/Internal/Configs.cs @@ -102,6 +102,7 @@ public class ContainerProvider { public ContainerProviderType Type { get; set; } = ContainerProviderType.Docker; public ContainerPortMappingType PortMappingType { get; set; } = ContainerPortMappingType.Default; + public bool EnableTrafficCapture { get; set; } = false; public string PublicEntry { get; set; } = string.Empty; diff --git a/src/GZCTF/Repositories/ContainerRepository.cs b/src/GZCTF/Repositories/ContainerRepository.cs index 720d4c0c2..4e60c9fe3 100644 --- a/src/GZCTF/Repositories/ContainerRepository.cs +++ b/src/GZCTF/Repositories/ContainerRepository.cs @@ -13,7 +13,7 @@ public ContainerRepository(AppDbContext context) : base(context) public override Task CountAsync(CancellationToken token = default) => _context.Containers.CountAsync(token); public Task GetContainerById(string guid, CancellationToken token = default) - => _context.Containers.FirstOrDefaultAsync(i => i.Id == guid, token); + => _context.Containers.Include(c => c.Instance).FirstOrDefaultAsync(i => i.Id == guid, token); public Task> GetContainers(CancellationToken token = default) => _context.Containers.ToListAsync(token); diff --git a/src/GZCTF/Utils/CapturableNetworkStream.cs b/src/GZCTF/Utils/CapturableNetworkStream.cs new file mode 100644 index 000000000..e2952b9b8 --- /dev/null +++ b/src/GZCTF/Utils/CapturableNetworkStream.cs @@ -0,0 +1,110 @@ +using System.Net; +using System.Net.NetworkInformation; +using System.Net.Sockets; +using PacketDotNet; +using PacketDotNet.Utils; +using Serilog; +using SharpPcap; +using SharpPcap.LibPcap; + +namespace GZCTF.Utils; + +public class CapturableNetworkStreamOptions +{ + /// + /// 流量源地址 + /// + public required IPEndPoint Source { get; set; } + + /// + /// 流量目的地址 + /// + public required IPEndPoint Dest { get; set; } + + /// + /// 记录文件位置 + /// + public string FilePath { get; set; } = string.Empty; + + /// + /// 启用文件流量捕获 + /// + public bool EnableCapture { get; set; } = false; +} + +public class CapturableNetworkStream : NetworkStream +{ + private readonly CapturableNetworkStreamOptions _options; + private readonly CaptureFileWriterDevice? _device = null; + private readonly PhysicalAddress _dummyPhysicalAddress = PhysicalAddress.Parse("ba-db-ad-ba-db-ad"); + + public CapturableNetworkStream(Socket socket, CapturableNetworkStreamOptions options) : base(socket) + { + _options = options; + + options.Source.Address = options.Source.Address.MapToIPv6(); + options.Dest.Address = options.Dest.Address.MapToIPv6(); + + if (_options.EnableCapture && !string.IsNullOrEmpty(_options.FilePath)) + { + var dir = Path.GetDirectoryName(_options.FilePath); + if (!Path.Exists(dir) && dir is not null) + Directory.CreateDirectory(dir); + + _device = new(_options.FilePath, FileMode.Open); + _device.Open(LinkLayers.Ethernet); + } + } + + public override async ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + var count = await base.ReadAsync(buffer, cancellationToken); + + if (!_options.EnableCapture) + return count; + + var udp = new UdpPacket((ushort)_options.Dest.Port, (ushort)_options.Source.Port) + { + PayloadDataSegment = new ByteArraySegment(buffer.ToArray()) + }; + + var packet = new EthernetPacket(_dummyPhysicalAddress, _dummyPhysicalAddress, EthernetType.IPv6) + { + PayloadPacket = new IPv6Packet(_options.Dest.Address, _options.Source.Address) { PayloadPacket = udp } + }; + + udp.UpdateUdpChecksum(); + + _device?.Write(new RawCapture(LinkLayers.Ethernet, new(), packet.Bytes)); + + return count; + } + + public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) + { + if (_options.EnableCapture) + { + var udp = new UdpPacket((ushort)_options.Source.Port, (ushort)_options.Dest.Port) + { + PayloadDataSegment = new ByteArraySegment(buffer.ToArray()) + }; + + var packet = new EthernetPacket(_dummyPhysicalAddress, _dummyPhysicalAddress, EthernetType.IPv6) + { + PayloadPacket = new IPv6Packet(_options.Source.Address, _options.Dest.Address) { PayloadPacket = udp } + }; + + udp.UpdateUdpChecksum(); + + _device?.Write(new RawCapture(LinkLayers.Ethernet, new(), packet.Bytes)); + } + + await base.WriteAsync(buffer, cancellationToken); + } + + public override void Close() + { + base.Close(); + _device?.Close(); + } +} diff --git a/src/GZCTF/Utils/PrelaunchHelper.cs b/src/GZCTF/Utils/PrelaunchHelper.cs index 06861c644..8b1f8917f 100644 --- a/src/GZCTF/Utils/PrelaunchHelper.cs +++ b/src/GZCTF/Utils/PrelaunchHelper.cs @@ -1,6 +1,9 @@ -using Microsoft.AspNetCore.Identity; +using GZCTF.Models.Internal; +using IdentityModel.OidcClient; +using Microsoft.AspNetCore.Identity; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Distributed; +using Microsoft.Extensions.Options; namespace GZCTF.Utils; @@ -56,6 +59,10 @@ await context.Posts.AddAsync(new() } } + var containerConfig = serviceScope.ServiceProvider.GetRequiredService>(); + if (containerConfig.Value.EnableTrafficCapture && containerConfig.Value.PortMappingType != ContainerPortMappingType.PlatformProxy) + logger.SystemLog($"在不使用平台代理模式时无法进行流量捕获!", TaskStatus.Failed, LogLevel.Warning); + if (!cache.CacheCheck()) Program.ExitWithFatalMessage("缓存配置无效,请检查 RedisCache 字段配置。如不使用 Redis 请将配置项置空。"); } From 8735c395e47679aa74bb993abe8ffdda67efd521 Mon Sep 17 00:00:00 2001 From: GZTime Date: Tue, 15 Aug 2023 18:11:07 +0800 Subject: [PATCH 11/67] chore(deps): update & fix health check --- docs/package.json | 12 +- docs/pnpm-lock.yaml | 205 +++++++++++++++-------------- src/Dockerfile | 3 +- src/GZCTF/ClientApp/package.json | 8 +- src/GZCTF/ClientApp/pnpm-lock.yaml | 122 ++++++++--------- src/GZCTF/Dockerfile | 6 +- 6 files changed, 175 insertions(+), 181 deletions(-) diff --git a/docs/package.json b/docs/package.json index 8e9348f25..fadad4f75 100644 --- a/docs/package.json +++ b/docs/package.json @@ -8,17 +8,17 @@ "build": "next build" }, "dependencies": { - "@vercel/analytics": "^1.0.1", - "next": "^13.4.13", + "@vercel/analytics": "^1.0.2", + "next": "^13.4.16", "next-themes": "^0.2.1", - "nextra": "^2.10.0", - "nextra-theme-docs": "^2.10.0", + "nextra": "^2.11.0", + "nextra-theme-docs": "^2.11.0", "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { - "@types/node": "^20.4.8", - "@types/react": "^18.2.18", + "@types/node": "^20.5.0", + "@types/react": "^18.2.20", "typescript": "^5.1.6" } } diff --git a/docs/pnpm-lock.yaml b/docs/pnpm-lock.yaml index 5783dc17d..48bbb1801 100644 --- a/docs/pnpm-lock.yaml +++ b/docs/pnpm-lock.yaml @@ -6,20 +6,20 @@ settings: dependencies: '@vercel/analytics': - specifier: ^1.0.1 - version: 1.0.1 + specifier: ^1.0.2 + version: 1.0.2 next: - specifier: ^13.4.13 - version: 13.4.13(react-dom@18.2.0)(react@18.2.0) + specifier: ^13.4.16 + version: 13.4.16(react-dom@18.2.0)(react@18.2.0) next-themes: specifier: ^0.2.1 - version: 0.2.1(next@13.4.13)(react-dom@18.2.0)(react@18.2.0) + version: 0.2.1(next@13.4.16)(react-dom@18.2.0)(react@18.2.0) nextra: - specifier: ^2.10.0 - version: 2.10.0(next@13.4.13)(react-dom@18.2.0)(react@18.2.0) + specifier: ^2.11.0 + version: 2.11.0(next@13.4.16)(react-dom@18.2.0)(react@18.2.0) nextra-theme-docs: - specifier: ^2.10.0 - version: 2.10.0(next@13.4.13)(nextra@2.10.0)(react-dom@18.2.0)(react@18.2.0) + specifier: ^2.11.0 + version: 2.11.0(next@13.4.16)(nextra@2.11.0)(react-dom@18.2.0)(react@18.2.0) react: specifier: ^18.2.0 version: 18.2.0 @@ -29,26 +29,26 @@ dependencies: devDependencies: '@types/node': - specifier: ^20.4.8 - version: 20.4.8 + specifier: ^20.5.0 + version: 20.5.0 '@types/react': - specifier: ^18.2.18 - version: 18.2.18 + specifier: ^18.2.20 + version: 18.2.20 typescript: specifier: ^5.1.6 version: 5.1.6 packages: - /@babel/runtime@7.22.6: - resolution: {integrity: sha512-wDb5pWm4WDdF6LFUde3Jl8WzPA+3ZbxYqkC6xAXuD3irdEHN1k0NfTRrJD8ZD378SJ61miMLCqIOXYhd8x+AJQ==} + /@babel/runtime@7.22.10: + resolution: {integrity: sha512-21t/fkKLMZI4pqP2wlmsQAWnYW1PDyKyyUV4vCi+B25ydmdaYTKXPwCj0BzSUnZf4seIiYvSA3jcZ3gdsMFkLQ==} engines: {node: '>=6.9.0'} dependencies: - regenerator-runtime: 0.13.11 + regenerator-runtime: 0.14.0 dev: false - /@braintree/sanitize-url@6.0.3: - resolution: {integrity: sha512-g2hMyGSFYOvt0eeY2c2wrG1B6dVWF1be4vGxG9mI1BEHJuQm4Hie2HrooxYHBDRDi8hANIzQ8cuvBgxSVlQOTQ==} + /@braintree/sanitize-url@6.0.4: + resolution: {integrity: sha512-s3jaWicZd0pkP0jf5ysyHUI/RE7MHos6qlToFcGWXVp+ykHOy77OUMrfbgJ9it2C5bow7OIQwYYaHjk9XlBQ2A==} dev: false /@headlessui/react@1.7.16(react-dom@18.2.0)(react@18.2.0): @@ -67,7 +67,7 @@ packages: resolution: {integrity: sha512-jLuwRlz8DQfQNiUCJR50Y09CGPq3fLtmtUQfVrj79E0JWu3dvsVcxVIcfhR5h0iXu+/z++zDrYeiJqifRynJkA==} dependencies: '@types/estree-jsx': 1.0.0 - '@types/mdx': 2.0.5 + '@types/mdx': 2.0.6 estree-util-build-jsx: 2.2.2 estree-util-is-identifier-name: 2.1.0 estree-util-to-js: 1.2.0 @@ -92,8 +92,8 @@ packages: peerDependencies: react: '>=16' dependencies: - '@types/mdx': 2.0.5 - '@types/react': 18.2.18 + '@types/mdx': 2.0.6 + '@types/react': 18.2.20 react: 18.2.0 dev: false @@ -213,12 +213,12 @@ packages: '@napi-rs/simple-git-win32-x64-msvc': 0.1.8 dev: false - /@next/env@13.4.13: - resolution: {integrity: sha512-fwz2QgVg08v7ZL7KmbQBLF2PubR/6zQdKBgmHEl3BCyWTEDsAQEijjw2gbFhI1tcKfLdOOJUXntz5vZ4S0Polg==} + /@next/env@13.4.16: + resolution: {integrity: sha512-pCU0sJBqdfKP9mwDadxvZd+eLz3fZrTlmmDHY12Hdpl3DD0vy8ou5HWKVfG0zZS6tqhL4wnQqRbspdY5nqa7MA==} dev: false - /@next/swc-darwin-arm64@13.4.13: - resolution: {integrity: sha512-ZptVhHjzUuivnXMNCJ6lER33HN7lC+rZ01z+PM10Ows21NHFYMvGhi5iXkGtBDk6VmtzsbqnAjnx4Oz5um0FjA==} + /@next/swc-darwin-arm64@13.4.16: + resolution: {integrity: sha512-Rl6i1uUq0ciRa3VfEpw6GnWAJTSKo9oM2OrkGXPsm7rMxdd2FR5NkKc0C9xzFCI4+QtmBviWBdF2m3ur3Nqstw==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] @@ -226,8 +226,8 @@ packages: dev: false optional: true - /@next/swc-darwin-x64@13.4.13: - resolution: {integrity: sha512-t9nTiWCLApw8W4G1kqJyYP7y6/7lyal3PftmRturIxAIBlZss9wrtVN8nci50StDHmIlIDxfguYIEGVr9DbFTg==} + /@next/swc-darwin-x64@13.4.16: + resolution: {integrity: sha512-o1vIKYbZORyDmTrPV1hApt9NLyWrS5vr2p5hhLGpOnkBY1cz6DAXjv8Lgan8t6X87+83F0EUDlu7klN8ieZ06A==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] @@ -235,8 +235,8 @@ packages: dev: false optional: true - /@next/swc-linux-arm64-gnu@13.4.13: - resolution: {integrity: sha512-xEHUqC8eqR5DHe8SOmMnDU1K3ggrJ28uIKltrQAwqFSSSmzjnN/XMocZkcVhuncuxYrpbri0iMQstRyRVdQVWg==} + /@next/swc-linux-arm64-gnu@13.4.16: + resolution: {integrity: sha512-JRyAl8lCfyTng4zoOmE6hNI2f1MFUr7JyTYCHl1RxX42H4a5LMwJhDVQ7a9tmDZ/yj+0hpBn+Aan+d6lA3v0UQ==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -244,8 +244,8 @@ packages: dev: false optional: true - /@next/swc-linux-arm64-musl@13.4.13: - resolution: {integrity: sha512-sNf3MnLAm8rquSSAoeD9nVcdaDeRYOeey4stOWOyWIgbBDtP+C93amSgH/LPTDoUV7gNiU6f+ghepTjTjRgIUQ==} + /@next/swc-linux-arm64-musl@13.4.16: + resolution: {integrity: sha512-9gqVqNzUMWbUDgDiND18xoUqhwSm2gmksqXgCU0qaOKt6oAjWz8cWYjgpPVD0WICKFylEY/gvPEP1fMZDVFZ/g==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] @@ -253,8 +253,8 @@ packages: dev: false optional: true - /@next/swc-linux-x64-gnu@13.4.13: - resolution: {integrity: sha512-WhcRaJJSHyx9OWmKjjz+OWHumiPZWRqmM/09Bt7Up4UqUJFFhGExeztR4trtv3rflvULatu9IH/nTV8fUUgaMA==} + /@next/swc-linux-x64-gnu@13.4.16: + resolution: {integrity: sha512-KcQGwchAKmZVPa8i5PLTxvTs1/rcFnSltfpTm803Tr/BtBV3AxCkHLfhtoyVtVzx/kl/oue8oS+DSmbepQKwhw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -262,8 +262,8 @@ packages: dev: false optional: true - /@next/swc-linux-x64-musl@13.4.13: - resolution: {integrity: sha512-+Y4LLhOWWZQIDKVwr2R17lq2KSN0F1c30QVgGIWfnjjHpH8nrIWHEndhqYU+iFuW8It78CiJjQKTw4f51HD7jA==} + /@next/swc-linux-x64-musl@13.4.16: + resolution: {integrity: sha512-2RbMZNxYnJmW8EPHVBsGZPq5zqWAyBOc/YFxq/jIQ/Yn3RMFZ1dZVCjtIcsiaKmgh7mjA/W0ApbumutHNxRqqQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] @@ -271,8 +271,8 @@ packages: dev: false optional: true - /@next/swc-win32-arm64-msvc@13.4.13: - resolution: {integrity: sha512-rWurdOR20uxjfqd1X9vDAgv0Jb26KjyL8akF9CBeFqX8rVaBAnW/Wf6A2gYEwyYY4Bai3T7p1kro6DFrsvBAAw==} + /@next/swc-win32-arm64-msvc@13.4.16: + resolution: {integrity: sha512-thDcGonELN7edUKzjzlHrdoKkm7y8IAdItQpRvvMxNUXa4d9r0ElofhTZj5emR7AiXft17hpen+QAkcWpqG7Jg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] @@ -280,8 +280,8 @@ packages: dev: false optional: true - /@next/swc-win32-ia32-msvc@13.4.13: - resolution: {integrity: sha512-E8bSPwRuY5ibJ3CzLQmJEt8qaWrPYuUTwnrwygPUEWoLzD5YRx9SD37oXRdU81TgGwDzCxpl7z5Nqlfk50xAog==} + /@next/swc-win32-ia32-msvc@13.4.16: + resolution: {integrity: sha512-f7SE1Mo4JAchUWl0LQsbtySR9xCa+x55C0taetjUApKtcLR3AgAjASrrP+oE1inmLmw573qRnE1eZN8YJfEBQw==} engines: {node: '>= 10'} cpu: [ia32] os: [win32] @@ -289,8 +289,8 @@ packages: dev: false optional: true - /@next/swc-win32-x64-msvc@13.4.13: - resolution: {integrity: sha512-4KlyC6jWRubPnppgfYsNTPeWfGCxtWLh5vaOAW/kdzAk9widqho8Qb5S4K2vHmal1tsURi7Onk2MMCV1phvyqA==} + /@next/swc-win32-x64-msvc@13.4.16: + resolution: {integrity: sha512-WamDZm1M/OEM4QLce3lOmD1XdLEl37zYZwlmOLhmF7qYJ2G6oYm9+ejZVv+LakQIsIuXhSpVlOvrxIAHqwRkPQ==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -313,7 +313,7 @@ packages: peerDependencies: react: ^18.2.0 dependencies: - mermaid: 10.3.0 + mermaid: 10.3.1 react: 18.2.0 unist-util-visit: 5.0.0 transitivePeerDependencies: @@ -387,23 +387,23 @@ packages: '@types/unist': 2.0.7 dev: false - /@types/mdx@2.0.5: - resolution: {integrity: sha512-76CqzuD6Q7LC+AtbPqrvD9AqsN0k8bsYo2bM2J8pmNldP1aIPAbzUQ7QbobyXL4eLr1wK5x8FZFe8eF/ubRuBg==} + /@types/mdx@2.0.6: + resolution: {integrity: sha512-sVcwEG10aFU2KcM7cIA0M410UPv/DesOPyG8zMVk0QUDexHA3lYmGucpEpZ2dtWWhi2ip3CG+5g/iH0PwoW4Fw==} dev: false /@types/ms@0.7.31: resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==} dev: false - /@types/node@20.4.8: - resolution: {integrity: sha512-0mHckf6D2DiIAzh8fM8f3HQCvMKDpK94YQ0DSVkfWTG9BZleYIWudw9cJxX8oCk9bM+vAkDyujDV6dmKHbvQpg==} + /@types/node@20.5.0: + resolution: {integrity: sha512-Mgq7eCtoTjT89FqNoTzzXg2XvCi5VMhRV6+I2aYanc6kQCBImeNaAYRs/DyoVqk1YEUJK5gN9VO7HRIdz4Wo3Q==} dev: true /@types/prop-types@15.7.5: resolution: {integrity: sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w==} - /@types/react@18.2.18: - resolution: {integrity: sha512-da4NTSeBv/P34xoZPhtcLkmZuJ+oYaCxHmyHzwaDQo9RQPBeXV+06gEk2FpqEcsX9XrnNLvRpVh6bdavDSjtiQ==} + /@types/react@18.2.20: + resolution: {integrity: sha512-WKNtmsLWJM/3D5mG4U84cysVY31ivmyw85dE84fOCk5Hx78wezB/XEjVPWl2JTZ5FkEeaTJf+VgUAUn3PE7Isw==} dependencies: '@types/prop-types': 15.7.5 '@types/scheduler': 0.16.3 @@ -420,8 +420,8 @@ packages: resolution: {integrity: sha512-MFETx3tbTjE7Uk6vvnWINA/1iJ7LuMdO4fcq8UfF0pRbj01aGLduVvQcRyswuACJdpnHgg8E3rQLhaRdNEJS0w==} dev: false - /@vercel/analytics@1.0.1: - resolution: {integrity: sha512-Ux0c9qUfkcPqng3vrR0GTrlQdqNJ2JREn/2ydrVuKwM3RtMfF2mWX31Ijqo1opSjNAq6rK76PwtANw6kl6TAow==} + /@vercel/analytics@1.0.2: + resolution: {integrity: sha512-BZFxVrv24VbNNl5xMxqUojQIegEeXMI6rX3rg1uVLYUEXsuKNBSAEQf4BWEcjQDp/8aYJOj6m8V4PUA3x/cxgg==} dev: false /acorn-jsx@5.3.2(acorn@8.10.0): @@ -483,8 +483,8 @@ packages: streamsearch: 1.1.0 dev: false - /caniuse-lite@1.0.30001519: - resolution: {integrity: sha512-0QHgqR+Jv4bxHMp8kZ1Kn8CH55OikjKJ6JmKkZYP1F3D7w+lnFXF70nG5eNfsZS89jadi5Ywy5UCSKLAglIRkg==} + /caniuse-lite@1.0.30001520: + resolution: {integrity: sha512-tahF5O9EiiTzwTUqAeFjIZbn4Dnqxzz7ktrgGlMYNLH43Ul26IgTMH/zvL3DG0lZxBYnlT04axvInszUsZULdA==} dev: false /ccount@2.0.1: @@ -528,8 +528,8 @@ packages: execa: 0.8.0 dev: false - /clsx@1.2.1: - resolution: {integrity: sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==} + /clsx@2.0.0: + resolution: {integrity: sha512-rQ1+kcj+ttHG0MKVGBUXwayCCF1oh39BF5COIpRzuCEv8Mwjv0XucrI2ExNTOn9IlLifGClWQcU9BrZORvtw6Q==} engines: {node: '>=6'} dev: false @@ -584,26 +584,26 @@ packages: /csstype@3.1.2: resolution: {integrity: sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ==} - /cytoscape-cose-bilkent@4.1.0(cytoscape@3.25.0): + /cytoscape-cose-bilkent@4.1.0(cytoscape@3.26.0): resolution: {integrity: sha512-wgQlVIUJF13Quxiv5e1gstZ08rnZj2XaLHGoFMYXz7SkNfCDOOteKBE6SYRfA9WxxI/iBc3ajfDoc6hb/MRAHQ==} peerDependencies: cytoscape: ^3.2.0 dependencies: cose-base: 1.0.3 - cytoscape: 3.25.0 + cytoscape: 3.26.0 dev: false - /cytoscape-fcose@2.2.0(cytoscape@3.25.0): + /cytoscape-fcose@2.2.0(cytoscape@3.26.0): resolution: {integrity: sha512-ki1/VuRIHFCzxWNrsshHYPs6L7TvLu3DL+TyIGEsRcvVERmxokbf5Gdk7mFxZnTdiGtnA4cfSmjZJMviqSuZrQ==} peerDependencies: cytoscape: ^3.2.0 dependencies: cose-base: 2.2.0 - cytoscape: 3.25.0 + cytoscape: 3.26.0 dev: false - /cytoscape@3.25.0: - resolution: {integrity: sha512-7MW3Iz57mCUo6JQCho6CmPBCbTlJr7LzyEtIkutG255HLVd4XuBg2I9BkTZLI/e4HoaOB/BiAzXuQybQ95+r9Q==} + /cytoscape@3.26.0: + resolution: {integrity: sha512-IV+crL+KBcrCnVVUCZW+zRRRFUZQcrtdOPXki+o4CFUWLdAEYvuZLcBSJC9EBK++suamERKzeY7roq2hdovV3w==} engines: {node: '>=0.10'} dependencies: heap: 0.2.7 @@ -1370,7 +1370,7 @@ packages: /match-sorter@6.3.1: resolution: {integrity: sha512-mxybbo3pPNuA+ZuCUhm5bwNkXrJTbsk5VWbR5wiwz/GC6LIiegBGn2w3O08UG/jdbYLinw51fSQ5xNU1U3MgBw==} dependencies: - '@babel/runtime': 7.22.6 + '@babel/runtime': 7.22.10 remove-accents: 0.4.2 dev: false @@ -1568,15 +1568,15 @@ packages: '@types/mdast': 3.0.12 dev: false - /mermaid@10.3.0: - resolution: {integrity: sha512-H5quxuQjwXC8M1WuuzhAp2TdqGg74t5skfDBrNKJ7dt3z8Wprl5S6h9VJsRhoBUTSs1TMtHEdplLhCqXleZZLw==} + /mermaid@10.3.1: + resolution: {integrity: sha512-hkenh7WkuRWPcob3oJtrN3W+yzrrIYuWF1OIfk/d0xGE8UWlvDhfexaHmDwwe8DKQgqMLI8DWEPwGprxkumjuw==} dependencies: - '@braintree/sanitize-url': 6.0.3 + '@braintree/sanitize-url': 6.0.4 '@types/d3-scale': 4.0.3 '@types/d3-scale-chromatic': 3.0.0 - cytoscape: 3.25.0 - cytoscape-cose-bilkent: 4.1.0(cytoscape@3.25.0) - cytoscape-fcose: 2.2.0(cytoscape@3.25.0) + cytoscape: 3.26.0 + cytoscape-cose-bilkent: 4.1.0(cytoscape@3.26.0) + cytoscape-fcose: 2.2.0(cytoscape@3.26.0) d3: 7.8.5 d3-sankey: 0.12.3 dagre-d3-es: 7.0.10 @@ -1973,32 +1973,32 @@ packages: - supports-color dev: false - /next-seo@6.1.0(next@13.4.13)(react-dom@18.2.0)(react@18.2.0): + /next-seo@6.1.0(next@13.4.16)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-iMBpFoJsR5zWhguHJvsoBDxDSmdYTHtnVPB1ij+CD0NReQCP78ZxxbdL9qkKIf4oEuZEqZkrjAQLB0bkII7RYA==} peerDependencies: next: ^8.1.1-canary.54 || >=9.0.0 react: '>=16.0.0' react-dom: '>=16.0.0' dependencies: - next: 13.4.13(react-dom@18.2.0)(react@18.2.0) + next: 13.4.16(react-dom@18.2.0)(react@18.2.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) dev: false - /next-themes@0.2.1(next@13.4.13)(react-dom@18.2.0)(react@18.2.0): + /next-themes@0.2.1(next@13.4.16)(react-dom@18.2.0)(react@18.2.0): resolution: {integrity: sha512-B+AKNfYNIzh0vqQQKqQItTS8evEouKD7H5Hj3kmuPERwddR2TxvDSFZuTj6T7Jfn1oyeUyJMydPl1Bkxkh0W7A==} peerDependencies: next: '*' react: '*' react-dom: '*' dependencies: - next: 13.4.13(react-dom@18.2.0)(react@18.2.0) + next: 13.4.16(react-dom@18.2.0)(react@18.2.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) dev: false - /next@13.4.13(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-A3YVbVDNeXLhWsZ8Nf6IkxmNlmTNz0yVg186NJ97tGZqPDdPzTrHotJ+A1cuJm2XfuWPrKOUZILl5iBQkIf8Jw==} + /next@13.4.16(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-1xaA/5DrfpPu0eV31Iro7JfPeqO8uxQWb1zYNTe+KDKdzqkAGapLcDYHMLNKXKB7lHjZ7LfKUOf9dyuzcibrhA==} engines: {node: '>=16.8.0'} hasBin: true peerDependencies: @@ -2012,10 +2012,10 @@ packages: sass: optional: true dependencies: - '@next/env': 13.4.13 + '@next/env': 13.4.16 '@swc/helpers': 0.5.1 busboy: 1.6.0 - caniuse-lite: 1.0.30001519 + caniuse-lite: 1.0.30001520 postcss: 8.4.14 react: 18.2.0 react-dom: 18.2.0(react@18.2.0) @@ -2023,48 +2023,49 @@ packages: watchpack: 2.4.0 zod: 3.21.4 optionalDependencies: - '@next/swc-darwin-arm64': 13.4.13 - '@next/swc-darwin-x64': 13.4.13 - '@next/swc-linux-arm64-gnu': 13.4.13 - '@next/swc-linux-arm64-musl': 13.4.13 - '@next/swc-linux-x64-gnu': 13.4.13 - '@next/swc-linux-x64-musl': 13.4.13 - '@next/swc-win32-arm64-msvc': 13.4.13 - '@next/swc-win32-ia32-msvc': 13.4.13 - '@next/swc-win32-x64-msvc': 13.4.13 + '@next/swc-darwin-arm64': 13.4.16 + '@next/swc-darwin-x64': 13.4.16 + '@next/swc-linux-arm64-gnu': 13.4.16 + '@next/swc-linux-arm64-musl': 13.4.16 + '@next/swc-linux-x64-gnu': 13.4.16 + '@next/swc-linux-x64-musl': 13.4.16 + '@next/swc-win32-arm64-msvc': 13.4.16 + '@next/swc-win32-ia32-msvc': 13.4.16 + '@next/swc-win32-x64-msvc': 13.4.16 transitivePeerDependencies: - '@babel/core' - babel-plugin-macros dev: false - /nextra-theme-docs@2.10.0(next@13.4.13)(nextra@2.10.0)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-uXoqRoewbu0JoqQ1m67aIztWe9/nEhcSeHMimhLxZghKZxkYN0kTR5y5jmrwOHRPuJUTLL2YFwy1rvWJIZS2lw==} + /nextra-theme-docs@2.11.0(next@13.4.16)(nextra@2.11.0)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-kNBVNB/NPW/3MI8Em7KFWjfX5Mtf5xY0UPhDveF5+aEvVUlrViS8Q0hfAdcbxq+0sUEc0hdr4KehU4H36cCkqg==} peerDependencies: next: '>=9.5.3' - nextra: 2.10.0 + nextra: 2.11.0 react: '>=16.13.1' react-dom: '>=16.13.1' dependencies: '@headlessui/react': 1.7.16(react-dom@18.2.0)(react@18.2.0) '@popperjs/core': 2.11.8 - clsx: 1.2.1 + clsx: 2.0.0 + escape-string-regexp: 5.0.0 flexsearch: 0.7.31 focus-visible: 5.2.0 git-url-parse: 13.1.0 intersection-observer: 0.12.2 match-sorter: 6.3.1 - next: 13.4.13(react-dom@18.2.0)(react@18.2.0) - next-seo: 6.1.0(next@13.4.13)(react-dom@18.2.0)(react@18.2.0) - next-themes: 0.2.1(next@13.4.13)(react-dom@18.2.0)(react@18.2.0) - nextra: 2.10.0(next@13.4.13)(react-dom@18.2.0)(react@18.2.0) + next: 13.4.16(react-dom@18.2.0)(react@18.2.0) + next-seo: 6.1.0(next@13.4.16)(react-dom@18.2.0)(react@18.2.0) + next-themes: 0.2.1(next@13.4.16)(react-dom@18.2.0)(react@18.2.0) + nextra: 2.11.0(next@13.4.16)(react-dom@18.2.0)(react@18.2.0) react: 18.2.0 react-dom: 18.2.0(react@18.2.0) scroll-into-view-if-needed: 3.0.10 - zod: 3.21.4 + zod: 3.22.0 dev: false - /nextra@2.10.0(next@13.4.13)(react-dom@18.2.0)(react@18.2.0): - resolution: {integrity: sha512-euv93UnWpdth8slMRJLqMrWvCCzR/VTVH6DPrn1JW7hZS03c2lzG2q+fsiYULGiy/kFyysmlxd4Nx5KGB1Txwg==} + /nextra@2.11.0(next@13.4.16)(react-dom@18.2.0)(react@18.2.0): + resolution: {integrity: sha512-I9F+NYl5fMBG6HUdPAvD6SbH3lpAvBeOmkS2Hkk+Cn3r8Ouc/QgLmJfpBXmG3gVFFxqYs2eQ2w/ppIivLLdylg==} engines: {node: '>=16'} peerDependencies: next: '>=9.5.3' @@ -2077,13 +2078,13 @@ packages: '@napi-rs/simple-git': 0.1.8 '@theguild/remark-mermaid': 0.0.4(react@18.2.0) '@theguild/remark-npm2yarn': 0.1.1 - clsx: 1.2.1 + clsx: 2.0.0 github-slugger: 2.0.0 graceful-fs: 4.2.11 gray-matter: 4.0.3 katex: 0.16.8 lodash.get: 4.4.2 - next: 13.4.13(react-dom@18.2.0)(react@18.2.0) + next: 13.4.16(react-dom@18.2.0)(react@18.2.0) next-mdx-remote: 4.4.1(react-dom@18.2.0)(react@18.2.0) p-limit: 3.1.0 react: 18.2.0 @@ -2098,7 +2099,7 @@ packages: title: 3.5.3 unist-util-remove: 4.0.0 unist-util-visit: 5.0.0 - zod: 3.21.4 + zod: 3.22.0 transitivePeerDependencies: - supports-color dev: false @@ -2225,8 +2226,8 @@ packages: resolution: {integrity: sha512-onYyVhBNr4CmAxFsKS7bz+uTLRakypIe4R+5A824vBSkQy/hB3fZepoVEf8OVAxzLvK+H/jm9TzpI3ETSm64Kg==} dev: false - /regenerator-runtime@0.13.11: - resolution: {integrity: sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==} + /regenerator-runtime@0.14.0: + resolution: {integrity: sha512-srw17NI0TUWHuGa5CFGGmhfNIeja30WMBfbslPNhf6JrqQlLN5gcrvig1oqPxiVaXb0oW0XRKtH6Nngs5lKCIA==} dev: false /rehype-katex@6.0.3: @@ -2710,6 +2711,10 @@ packages: resolution: {integrity: sha512-m46AKbrzKVzOzs/DZgVnG5H55N1sv1M8qZU3A8RIKbs3mrACDNeIOeilDymVb2HdmP8uwshOCF4uJ8uM9rCqJw==} dev: false + /zod@3.22.0: + resolution: {integrity: sha512-y5KZY/ssf5n7hCGDGGtcJO/EBJEm5Pa+QQvFBeyMOtnFYOSflalxIFFvdaYevPhePcmcKC4aTbFkCcXN7D0O8Q==} + dev: false + /zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} dev: false diff --git a/src/Dockerfile b/src/Dockerfile index e72d3a491..398bfe619 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -26,8 +26,9 @@ RUN dotnet publish "GZCTF.csproj" -c Release -o /app/publish -r linux-x64 --no-s FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS final WORKDIR /app EXPOSE 80 -COPY --from=build /usr/bin/wget /usr/bin/wget + COPY --from=publish /app/publish . +RUN apt update && apt install -y wget && rm -rf /var/lib/apt/lists/* HEALTHCHECK --interval=5m --timeout=3s --start-period=10s --retries=1 \ CMD wget --no-verbose --tries=1 --spider http://localhost:80/healthz || exit 1 diff --git a/src/GZCTF/ClientApp/package.json b/src/GZCTF/ClientApp/package.json index d6bb2e3c9..b9cce9ace 100644 --- a/src/GZCTF/ClientApp/package.json +++ b/src/GZCTF/ClientApp/package.json @@ -32,7 +32,7 @@ "embla-carousel-react": "^7.1.0", "katex": "^0.16.8", "lz-string": "^1.5.0", - "marked": "^7.0.2", + "marked": "^7.0.3", "pdfjs-dist": "3.6.172", "prismjs": "^1.29.0", "react": "^18.2.0", @@ -48,12 +48,12 @@ "@nabla/vite-plugin-eslint": "^1.5.0", "@trivago/prettier-plugin-sort-imports": "^4.2.0", "@types/katex": "^0.16.2", - "@types/node": "20.4.10", + "@types/node": "20.5.0", "@types/prismjs": "^1.26.0", "@types/react": "^18.2.20", "@types/react-dom": "^18.2.7", - "@typescript-eslint/eslint-plugin": "^6.3.0", - "@typescript-eslint/parser": "^6.3.0", + "@typescript-eslint/eslint-plugin": "^6.4.0", + "@typescript-eslint/parser": "^6.4.0", "@vitejs/plugin-react": "^4.0.4", "axios": "^1.4.0", "babel-plugin-prismjs": "^2.1.0", diff --git a/src/GZCTF/ClientApp/pnpm-lock.yaml b/src/GZCTF/ClientApp/pnpm-lock.yaml index cb28acef2..971eb93f3 100644 --- a/src/GZCTF/ClientApp/pnpm-lock.yaml +++ b/src/GZCTF/ClientApp/pnpm-lock.yaml @@ -69,8 +69,8 @@ dependencies: specifier: ^1.5.0 version: 1.5.0 marked: - specifier: ^7.0.2 - version: 7.0.2 + specifier: ^7.0.3 + version: 7.0.3 pdfjs-dist: specifier: 3.6.172 version: 3.6.172 @@ -113,8 +113,8 @@ devDependencies: specifier: ^0.16.2 version: 0.16.2 '@types/node': - specifier: 20.4.10 - version: 20.4.10 + specifier: 20.5.0 + version: 20.5.0 '@types/prismjs': specifier: ^1.26.0 version: 1.26.0 @@ -125,11 +125,11 @@ devDependencies: specifier: ^18.2.7 version: 18.2.7 '@typescript-eslint/eslint-plugin': - specifier: ^6.3.0 - version: 6.3.0(@typescript-eslint/parser@6.3.0)(eslint@8.47.0)(typescript@5.1.6) + specifier: ^6.4.0 + version: 6.4.0(@typescript-eslint/parser@6.4.0)(eslint@8.47.0)(typescript@5.1.6) '@typescript-eslint/parser': - specifier: ^6.3.0 - version: 6.3.0(eslint@8.47.0)(typescript@5.1.6) + specifier: ^6.4.0 + version: 6.4.0(eslint@8.47.0)(typescript@5.1.6) '@vitejs/plugin-react': specifier: ^4.0.4 version: 4.0.4(vite@4.4.9) @@ -168,7 +168,7 @@ devDependencies: version: 5.1.6 vite: specifier: ^4.4.9 - version: 4.4.9(@types/node@20.4.10) + version: 4.4.9(@types/node@20.5.0) vite-plugin-pages: specifier: ^0.31.0 version: 0.31.0(vite@4.4.9) @@ -1102,7 +1102,7 @@ packages: '@types/eslint': 8.40.0 chalk: 4.1.2 eslint: 8.47.0 - vite: 4.4.9(@types/node@20.4.10) + vite: 4.4.9(@types/node@20.5.0) dev: true /@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1: @@ -1307,8 +1307,8 @@ packages: resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==} dev: true - /@types/node@20.4.10: - resolution: {integrity: sha512-vwzFiiy8Rn6E0MtA13/Cxxgpan/N6UeNYR9oUu6kuJWxu6zCk98trcDp8CBhbtaeuq9SykCmXkFr2lWLoPcvLg==} + /@types/node@20.5.0: + resolution: {integrity: sha512-Mgq7eCtoTjT89FqNoTzzXg2XvCi5VMhRV6+I2aYanc6kQCBImeNaAYRs/DyoVqk1YEUJK5gN9VO7HRIdz4Wo3Q==} /@types/parse-json@4.0.0: resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} @@ -1345,8 +1345,8 @@ packages: resolution: {integrity: sha512-7yQiX6MWSFSvc/1wW5smJMZTZ4fHOd+hqLr3qr/HONDxHEa2bnYAsOcGBOEqFIjd4yetwMOdEDdeW+udRAQnHA==} dev: true - /@typescript-eslint/eslint-plugin@6.3.0(@typescript-eslint/parser@6.3.0)(eslint@8.47.0)(typescript@5.1.6): - resolution: {integrity: sha512-IZYjYZ0ifGSLZbwMqIip/nOamFiWJ9AH+T/GYNZBWkVcyNQOFGtSMoWV7RvY4poYCMZ/4lHzNl796WOSNxmk8A==} + /@typescript-eslint/eslint-plugin@6.4.0(@typescript-eslint/parser@6.4.0)(eslint@8.47.0)(typescript@5.1.6): + resolution: {integrity: sha512-62o2Hmc7Gs3p8SLfbXcipjWAa6qk2wZGChXG2JbBtYpwSRmti/9KHLqfbLs9uDigOexG+3PaQ9G2g3201FWLKg==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: '@typescript-eslint/parser': ^6.0.0 || ^6.0.0-alpha @@ -1357,17 +1357,16 @@ packages: optional: true dependencies: '@eslint-community/regexpp': 4.6.2 - '@typescript-eslint/parser': 6.3.0(eslint@8.47.0)(typescript@5.1.6) - '@typescript-eslint/scope-manager': 6.3.0 - '@typescript-eslint/type-utils': 6.3.0(eslint@8.47.0)(typescript@5.1.6) - '@typescript-eslint/utils': 6.3.0(eslint@8.47.0)(typescript@5.1.6) - '@typescript-eslint/visitor-keys': 6.3.0 + '@typescript-eslint/parser': 6.4.0(eslint@8.47.0)(typescript@5.1.6) + '@typescript-eslint/scope-manager': 6.4.0 + '@typescript-eslint/type-utils': 6.4.0(eslint@8.47.0)(typescript@5.1.6) + '@typescript-eslint/utils': 6.4.0(eslint@8.47.0)(typescript@5.1.6) + '@typescript-eslint/visitor-keys': 6.4.0 debug: 4.3.4 eslint: 8.47.0 graphemer: 1.4.0 ignore: 5.2.4 natural-compare: 1.4.0 - natural-compare-lite: 1.4.0 semver: 7.5.4 ts-api-utils: 1.0.1(typescript@5.1.6) typescript: 5.1.6 @@ -1375,8 +1374,8 @@ packages: - supports-color dev: true - /@typescript-eslint/parser@6.3.0(eslint@8.47.0)(typescript@5.1.6): - resolution: {integrity: sha512-ibP+y2Gr6p0qsUkhs7InMdXrwldjxZw66wpcQq9/PzAroM45wdwyu81T+7RibNCh8oc0AgrsyCwJByncY0Ongg==} + /@typescript-eslint/parser@6.4.0(eslint@8.47.0)(typescript@5.1.6): + resolution: {integrity: sha512-I1Ah1irl033uxjxO9Xql7+biL3YD7w9IU8zF+xlzD/YxY6a4b7DYA08PXUUCbm2sEljwJF6ERFy2kTGAGcNilg==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 @@ -1385,10 +1384,10 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/scope-manager': 6.3.0 - '@typescript-eslint/types': 6.3.0 - '@typescript-eslint/typescript-estree': 6.3.0(typescript@5.1.6) - '@typescript-eslint/visitor-keys': 6.3.0 + '@typescript-eslint/scope-manager': 6.4.0 + '@typescript-eslint/types': 6.4.0 + '@typescript-eslint/typescript-estree': 6.4.0(typescript@5.1.6) + '@typescript-eslint/visitor-keys': 6.4.0 debug: 4.3.4 eslint: 8.47.0 typescript: 5.1.6 @@ -1396,16 +1395,16 @@ packages: - supports-color dev: true - /@typescript-eslint/scope-manager@6.3.0: - resolution: {integrity: sha512-WlNFgBEuGu74ahrXzgefiz/QlVb+qg8KDTpknKwR7hMH+lQygWyx0CQFoUmMn1zDkQjTBBIn75IxtWss77iBIQ==} + /@typescript-eslint/scope-manager@6.4.0: + resolution: {integrity: sha512-TUS7vaKkPWDVvl7GDNHFQMsMruD+zhkd3SdVW0d7b+7Zo+bd/hXJQ8nsiUZMi1jloWo6c9qt3B7Sqo+flC1nig==} engines: {node: ^16.0.0 || >=18.0.0} dependencies: - '@typescript-eslint/types': 6.3.0 - '@typescript-eslint/visitor-keys': 6.3.0 + '@typescript-eslint/types': 6.4.0 + '@typescript-eslint/visitor-keys': 6.4.0 dev: true - /@typescript-eslint/type-utils@6.3.0(eslint@8.47.0)(typescript@5.1.6): - resolution: {integrity: sha512-7Oj+1ox1T2Yc8PKpBvOKWhoI/4rWFd1j7FA/rPE0lbBPXTKjdbtC+7Ev0SeBjEKkIhKWVeZSP+mR7y1Db1CdfQ==} + /@typescript-eslint/type-utils@6.4.0(eslint@8.47.0)(typescript@5.1.6): + resolution: {integrity: sha512-TvqrUFFyGY0cX3WgDHcdl2/mMCWCDv/0thTtx/ODMY1QhEiyFtv/OlLaNIiYLwRpAxAtOLOY9SUf1H3Q3dlwAg==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 @@ -1414,8 +1413,8 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/typescript-estree': 6.3.0(typescript@5.1.6) - '@typescript-eslint/utils': 6.3.0(eslint@8.47.0)(typescript@5.1.6) + '@typescript-eslint/typescript-estree': 6.4.0(typescript@5.1.6) + '@typescript-eslint/utils': 6.4.0(eslint@8.47.0)(typescript@5.1.6) debug: 4.3.4 eslint: 8.47.0 ts-api-utils: 1.0.1(typescript@5.1.6) @@ -1424,13 +1423,13 @@ packages: - supports-color dev: true - /@typescript-eslint/types@6.3.0: - resolution: {integrity: sha512-K6TZOvfVyc7MO9j60MkRNWyFSf86IbOatTKGrpTQnzarDZPYPVy0oe3myTMq7VjhfsUAbNUW8I5s+2lZvtx1gg==} + /@typescript-eslint/types@6.4.0: + resolution: {integrity: sha512-+FV9kVFrS7w78YtzkIsNSoYsnOtrYVnKWSTVXoL1761CsCRv5wpDOINgsXpxD67YCLZtVQekDDyaxfjVWUJmmg==} engines: {node: ^16.0.0 || >=18.0.0} dev: true - /@typescript-eslint/typescript-estree@6.3.0(typescript@5.1.6): - resolution: {integrity: sha512-Xh4NVDaC4eYKY4O3QGPuQNp5NxBAlEvNQYOqJquR2MePNxO11E5K3t5x4M4Mx53IZvtpW+mBxIT0s274fLUocg==} + /@typescript-eslint/typescript-estree@6.4.0(typescript@5.1.6): + resolution: {integrity: sha512-iDPJArf/K2sxvjOR6skeUCNgHR/tCQXBsa+ee1/clRKr3olZjZ/dSkXPZjG6YkPtnW6p5D1egeEPMCW6Gn4yLA==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: typescript: '*' @@ -1438,8 +1437,8 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/types': 6.3.0 - '@typescript-eslint/visitor-keys': 6.3.0 + '@typescript-eslint/types': 6.4.0 + '@typescript-eslint/visitor-keys': 6.4.0 debug: 4.3.4 globby: 11.1.0 is-glob: 4.0.3 @@ -1450,8 +1449,8 @@ packages: - supports-color dev: true - /@typescript-eslint/utils@6.3.0(eslint@8.47.0)(typescript@5.1.6): - resolution: {integrity: sha512-hLLg3BZE07XHnpzglNBG8P/IXq/ZVXraEbgY7FM0Cnc1ehM8RMdn9mat3LubJ3KBeYXXPxV1nugWbQPjGeJk6Q==} + /@typescript-eslint/utils@6.4.0(eslint@8.47.0)(typescript@5.1.6): + resolution: {integrity: sha512-BvvwryBQpECPGo8PwF/y/q+yacg8Hn/2XS+DqL/oRsOPK+RPt29h5Ui5dqOKHDlbXrAeHUTnyG3wZA0KTDxRZw==} engines: {node: ^16.0.0 || >=18.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 @@ -1459,9 +1458,9 @@ packages: '@eslint-community/eslint-utils': 4.4.0(eslint@8.47.0) '@types/json-schema': 7.0.12 '@types/semver': 7.5.0 - '@typescript-eslint/scope-manager': 6.3.0 - '@typescript-eslint/types': 6.3.0 - '@typescript-eslint/typescript-estree': 6.3.0(typescript@5.1.6) + '@typescript-eslint/scope-manager': 6.4.0 + '@typescript-eslint/types': 6.4.0 + '@typescript-eslint/typescript-estree': 6.4.0(typescript@5.1.6) eslint: 8.47.0 semver: 7.5.4 transitivePeerDependencies: @@ -1469,12 +1468,12 @@ packages: - typescript dev: true - /@typescript-eslint/visitor-keys@6.3.0: - resolution: {integrity: sha512-kEhRRj7HnvaSjux1J9+7dBen15CdWmDnwrpyiHsFX6Qx2iW5LOBUgNefOFeh2PjWPlNwN8TOn6+4eBU3J/gupw==} + /@typescript-eslint/visitor-keys@6.4.0: + resolution: {integrity: sha512-yJSfyT+uJm+JRDWYRYdCm2i+pmvXJSMtPR9Cq5/XQs4QIgNoLcoRtDdzsLbLsFM/c6um6ohQkg/MLxWvoIndJA==} engines: {node: ^16.0.0 || >=18.0.0} dependencies: - '@typescript-eslint/types': 6.3.0 - eslint-visitor-keys: 3.4.2 + '@typescript-eslint/types': 6.4.0 + eslint-visitor-keys: 3.4.3 dev: true /@vitejs/plugin-react@4.0.4(vite@4.4.9): @@ -1487,7 +1486,7 @@ packages: '@babel/plugin-transform-react-jsx-self': 7.22.5(@babel/core@7.22.10) '@babel/plugin-transform-react-jsx-source': 7.22.5(@babel/core@7.22.10) react-refresh: 0.14.0 - vite: 4.4.9(@types/node@20.4.10) + vite: 4.4.9(@types/node@20.5.0) transitivePeerDependencies: - supports-color dev: true @@ -2096,11 +2095,6 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true - /eslint-visitor-keys@3.4.2: - resolution: {integrity: sha512-8drBzUEyZ2llkpCA67iYrgEssKDUu68V8ChqqOfFupIaG/LCVPUT+CoGJpT77zJprs4T/W7p07LP7zAIMuweVw==} - engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - dev: true - /eslint-visitor-keys@3.4.3: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -2875,8 +2869,8 @@ packages: resolution: {integrity: sha512-JhvWq/iz1BvlmnPvLJjXv+xnMPJZuychrDC68V+yCGQJn5chcA8rLGKo5EP1XwIKVrigSXKLmbeXAGkf36wdCQ==} dev: false - /marked@7.0.2: - resolution: {integrity: sha512-ADEBjnCHOrsLoV7JPvUNWtELZ0b4SeIALhrfxuA9uhS3hw2PCezROoFduIqMOjeagBPto732+YC5tZHBMQRdqg==} + /marked@7.0.3: + resolution: {integrity: sha512-ev2uM40p0zQ/GbvqotfKcSWEa59fJwluGZj5dcaUOwDRrB1F3dncdXy8NWUApk4fi8atU3kTBOwjyjZ0ud0dxw==} engines: {node: '>= 16'} hasBin: true dev: false @@ -2972,10 +2966,6 @@ packages: engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} hasBin: true - /natural-compare-lite@1.4.0: - resolution: {integrity: sha512-Tj+HTDSJJKaZnfiuw+iaF9skdPpTo2GtEly5JHnWV/hfv2Qj/9RKsGISQtLh2ox3l5EAGw487hnBee0sIJ6v2g==} - dev: true - /natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} dev: true @@ -4008,7 +3998,7 @@ packages: json5: 2.2.3 local-pkg: 0.4.3 picocolors: 1.0.0 - vite: 4.4.9(@types/node@20.4.10) + vite: 4.4.9(@types/node@20.5.0) yaml: 2.3.1 transitivePeerDependencies: - supports-color @@ -4034,7 +4024,7 @@ packages: clean-css: 5.3.2 flat-cache: 3.0.4 picocolors: 1.0.0 - vite: 4.4.9(@types/node@20.4.10) + vite: 4.4.9(@types/node@20.5.0) transitivePeerDependencies: - debug dev: true @@ -4050,13 +4040,13 @@ packages: debug: 4.3.4 globrex: 0.1.2 tsconfck: 2.1.1(typescript@5.1.6) - vite: 4.4.9(@types/node@20.4.10) + vite: 4.4.9(@types/node@20.5.0) transitivePeerDependencies: - supports-color - typescript dev: false - /vite@4.4.9(@types/node@20.4.10): + /vite@4.4.9(@types/node@20.5.0): resolution: {integrity: sha512-2mbUn2LlUmNASWwSCNSJ/EG2HuSRTnVNaydp6vMCm5VIqJsjMfbIWtbH2kDuwUVW5mMUKKZvGPX/rqeqVvv1XA==} engines: {node: ^14.18.0 || >=16.0.0} hasBin: true @@ -4084,7 +4074,7 @@ packages: terser: optional: true dependencies: - '@types/node': 20.4.10 + '@types/node': 20.5.0 esbuild: 0.18.19 postcss: 8.4.27 rollup: 3.28.0 diff --git a/src/GZCTF/Dockerfile b/src/GZCTF/Dockerfile index 06bb0ff2f..4756ff68d 100644 --- a/src/GZCTF/Dockerfile +++ b/src/GZCTF/Dockerfile @@ -1,17 +1,15 @@ FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS build ARG TARGETPLATFORM COPY publish /build -RUN apt update && \ - apt install -y wget --no-install-recommends && \ - cp -r /build/${TARGETPLATFORM} /publish +RUN cp -r /build/${TARGETPLATFORM} /publish FROM mcr.microsoft.com/dotnet/aspnet:7.0 AS final WORKDIR /app COPY --from=build /publish . -COPY --from=build /usr/bin/wget /usr/bin/wget EXPOSE 80 +RUN apt update && apt install -y wget --no-install-recommends && rm -rf /var/lib/apt/lists/* HEALTHCHECK --interval=5m --timeout=3s --start-period=10s --retries=1 \ CMD wget --no-verbose --tries=1 --spider http://localhost:80/healthz || exit 1 From 92bd343c94ff13055955b8296c3eec7d04ad5ea3 Mon Sep 17 00:00:00 2001 From: GZTime Date: Tue, 15 Aug 2023 18:24:22 +0800 Subject: [PATCH 12/67] docs: update --- docs/pages/_meta.zh.json | 3 ++- docs/pages/changelog.zh.mdx | 15 +++++++++++++++ docs/pages/config/appsettings.zh.mdx | 12 ++++++------ docs/pages/quick-start.zh.mdx | 10 ++++++++-- 4 files changed, 31 insertions(+), 9 deletions(-) create mode 100644 docs/pages/changelog.zh.mdx diff --git a/docs/pages/_meta.zh.json b/docs/pages/_meta.zh.json index c47906632..14b522b35 100644 --- a/docs/pages/_meta.zh.json +++ b/docs/pages/_meta.zh.json @@ -5,5 +5,6 @@ "deployment": "部署", "guide": "使用指南", "issue": "常见问题", - "thanks": "致谢" + "thanks": "致谢", + "changelog": "更新日志" } diff --git a/docs/pages/changelog.zh.mdx b/docs/pages/changelog.zh.mdx new file mode 100644 index 000000000..ad5370083 --- /dev/null +++ b/docs/pages/changelog.zh.mdx @@ -0,0 +1,15 @@ +import { Callout } from "nextra-theme-docs"; + +# 更新日志 + +## v0.17 + +**Break Changes** + +- **将原有 `uploads` 目录移动至 `files/uploads`,移除了此目录的配置项,更改了日志存储位置** + + 更新建议:将原有 `uploads` 目录移动至 `files/uploads`,并重新挂载相关目录,删除 `uploads` 目录的配置项和原有 `log` 目录 + +## v0.16-v0.1 + +见 Release 记录:https://github.com/GZTimeWalker/GZCTF/releases diff --git a/docs/pages/config/appsettings.zh.mdx b/docs/pages/config/appsettings.zh.mdx index 2dcbc492e..08593ce15 100644 --- a/docs/pages/config/appsettings.zh.mdx +++ b/docs/pages/config/appsettings.zh.mdx @@ -41,6 +41,8 @@ import { Callout } from "nextra-theme-docs"; "XorKey": "", "ContainerProvider": { "Type": "Docker", // or "Kubernetes" + "PortMappingType": "Default", + "EnableTrafficCapture": false, "PublicEntry": "ctf.example.com", // or "xxx.xxx.xxx.xxx" "DockerConfig": { // optional @@ -80,12 +82,8 @@ import { Callout } from "nextra-theme-docs"; "ForwardLimit": 1, "ForwardedForHeaderName": "X-Forwarded-For", // use the following options to allow proxy - "TrustedNetworks": [ - "10.0.0.0/8" - ], - "TrustedProxies": [ - "10.0.0.1" - ] + "TrustedNetworks": ["10.0.0.0/8"], + "TrustedProxies": ["10.0.0.1"] } } ``` @@ -129,6 +127,8 @@ GZCTF 仅支持 PostgreSQL 作为数据库,不支持 MySQL 等其他数据库 - **Type:** 容器后端类型,可选 `Docker` 或 `Kubernetes`。 - **PublicEntry:** 容器后端的公网地址,用于生成比赛的访问地址,展示给参赛队伍。 +- **PortMappingType:** 端口映射类型,可选 `Default` 或 `PlatformProxy`。 +- **EnableTrafficCapture:** 是否开启流量捕获,仅在 `PortMappingType` 设置为 `PlatformProxy` 时可用。若开启,将会记录流量于 `/app/files/capture` 目录下。 #### Docker diff --git a/docs/pages/quick-start.zh.mdx b/docs/pages/quick-start.zh.mdx index dc599c57c..d3e638480 100644 --- a/docs/pages/quick-start.zh.mdx +++ b/docs/pages/quick-start.zh.mdx @@ -47,6 +47,8 @@ GZCTF 的安全性和前端功能(如操作剪贴板)依赖于 HTTPS,此 "XorKey": "", "ContainerProvider": { "Type": "Docker", // or "Kubernetes" + "PortMappingType": "Default", // or "PlatformProxy" + "EnableTrafficCapture": false, "PublicEntry": "", // or "xxx.xxx.xxx.xxx" "DockerConfig": { // optional @@ -66,6 +68,11 @@ GZCTF 的安全性和前端功能(如操作剪贴板)依赖于 HTTPS,此 "Sitekey": "", "Secretkey": "", "RecaptchaThreshold": "0.5" + }, + "ForwardedOptions": { + "ForwardedHeaders": 5, + "ForwardLimit": 1, + "TrustedNetworks": ["192.168.12.0/8"] } } ``` @@ -83,9 +90,8 @@ GZCTF 的安全性和前端功能(如操作剪贴板)依赖于 HTTPS,此 ports: - "80:80" volumes: - - "./data/files:/app/uploads" + - "./data/files:/app/files" - "./appsettings.json:/app/appsettings.json:ro" - - "./logs:/app/log" # - "./k8sconfig.yaml:/app/k8sconfig.yaml:ro" # this is required for k8s deployment - "/var/run/docker.sock:/var/run/docker.sock" # this is required for docker deployment depends_on: From 1fd40837fdfa876217d805364deab68e7ef1bed2 Mon Sep 17 00:00:00 2001 From: GZTime Date: Tue, 15 Aug 2023 20:13:35 +0800 Subject: [PATCH 13/67] feat(docker): add network name --- docs/pages/config/appsettings.zh.mdx | 30 +++++++++++++++++++ docs/pages/quick-start.zh.mdx | 6 ++-- src/GZCTF/Models/Internal/Configs.cs | 1 + .../Container/Manager/DockerManager.cs | 9 +++--- 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/docs/pages/config/appsettings.zh.mdx b/docs/pages/config/appsettings.zh.mdx index 08593ce15..e31813bcc 100644 --- a/docs/pages/config/appsettings.zh.mdx +++ b/docs/pages/config/appsettings.zh.mdx @@ -47,6 +47,7 @@ import { Callout } from "nextra-theme-docs"; "DockerConfig": { // optional "SwarmMode": false, + "ChallengeNetwork": "", "Uri": "unix:///var/run/docker.sock" }, "K8sConfig": { @@ -145,6 +146,35 @@ GZCTF 仅支持 PostgreSQL 作为数据库,不支持 MySQL 等其他数据库 - 如需使用本地 docker,请将 Uri 置空,并将 `/var/run/docker.sock` 挂载入容器对应位置 - 如需使用外部 docker,请将 Uri 指向对应 docker API Server,**外部 API 鉴权尚未实现,不推荐此部署方式** +- **ChallengeNetwork:** 指定题目容器所在的网络,若不指定,将会使用默认网络。 + + + + 采用 `PlatformProxy` 端口映射类型时,为了使得 GZCTF 顺利访问题目容器,需要额外手动创建一个网络: + + ```bash + docker network create challenges -d bridge --subnet 192.168.133.0/24 + ``` + + 设置配置项 **ChallengeNetwork** 为对应的网络名称,并需要在 docker-compose.yml 中桥接外部网络,例如: + + ```yaml + version: "3.7" + services: + gzctf: + ... + networks: + - default + - challenges + + networks: + challenges: + external: true + ``` + + + + #### Kubernetes - **Namespace:** Kubernetes 命名空间,用于创建题目实例的命名空间,默认为 `gzctf-challenges` diff --git a/docs/pages/quick-start.zh.mdx b/docs/pages/quick-start.zh.mdx index d3e638480..ca370331b 100644 --- a/docs/pages/quick-start.zh.mdx +++ b/docs/pages/quick-start.zh.mdx @@ -122,7 +122,7 @@ GZCTF 的安全性和前端功能(如操作剪贴板)依赖于 HTTPS,此 -4. 运行 `docker-compose up -d` 来启动 GZCTF,之后你可以通过浏览器访问 GZCTF 了。 +4. 运行 `docker compose up -d` 来启动 GZCTF,之后你可以通过浏览器访问 GZCTF 了。 ## 初始管理员 @@ -137,7 +137,7 @@ UPDATE "AspNetUsers" SET "Role"=3 WHERE "UserName"='your_admin_user_name'; 你可能会用到如下的命令: ```bash -docker-compose exec db psql -U postgres +docker compose exec db psql -U postgres ``` ```bash @@ -185,7 +185,7 @@ ctf=# #do your sql query } ``` -之后重新使用 `docker-compose up -d` 启动 GZCTF 即可。 +之后重新使用 `docker compose up -d` 启动 GZCTF 即可。 ## 容器镜像 diff --git a/src/GZCTF/Models/Internal/Configs.cs b/src/GZCTF/Models/Internal/Configs.cs index 5b9a0beb5..327d3f1fd 100644 --- a/src/GZCTF/Models/Internal/Configs.cs +++ b/src/GZCTF/Models/Internal/Configs.cs @@ -114,6 +114,7 @@ public class DockerConfig { public string Uri { get; set; } = string.Empty; public bool SwarmMode { get; set; } = false; + public string? ChallengeNetwork { get; set; } } public class K8sConfig diff --git a/src/GZCTF/Services/Container/Manager/DockerManager.cs b/src/GZCTF/Services/Container/Manager/DockerManager.cs index 6ce3c802d..6e8bacf63 100644 --- a/src/GZCTF/Services/Container/Manager/DockerManager.cs +++ b/src/GZCTF/Services/Container/Manager/DockerManager.cs @@ -57,7 +57,7 @@ public async Task DestroyContainerAsync(Container container, CancellationToken t container.Status = ContainerStatus.Destroyed; } - private static CreateContainerParameters GetCreateContainerParameters(ContainerConfig config) + private CreateContainerParameters GetCreateContainerParameters(ContainerConfig config) => new() { Image = config.Image, @@ -66,11 +66,11 @@ private static CreateContainerParameters GetCreateContainerParameters(ContainerC Env = config.Flag is null ? Array.Empty() : new string[] { $"GZCTF_FLAG={config.Flag}" }, HostConfig = new() { - PublishAllPorts = true, Memory = config.MemoryLimit * 1024 * 1024, CPUPercent = config.CPUCount * 10, - Privileged = config.PrivilegedContainer - } + Privileged = config.PrivilegedContainer, + NetworkMode = string.IsNullOrWhiteSpace(_meta.Config.ChallengeNetwork) ? null : _meta.Config.ChallengeNetwork, + }, }; public async Task CreateContainerAsync(ContainerConfig config, CancellationToken token = default) @@ -83,6 +83,7 @@ private static CreateContainerParameters GetCreateContainerParameters(ContainerC { [config.ExposedPort.ToString()] = new EmptyStruct() }; + parameters.HostConfig.PublishAllPorts = true; } CreateContainerResponse? containerRes = null; From b90da7e49f064f62a41e077e8c01fc0cad588b32 Mon Sep 17 00:00:00 2001 From: GZTime Date: Tue, 15 Aug 2023 21:53:51 +0800 Subject: [PATCH 14/67] wip: use ChallengeNetwork directly --- src/GZCTF/Services/Container/Manager/DockerManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GZCTF/Services/Container/Manager/DockerManager.cs b/src/GZCTF/Services/Container/Manager/DockerManager.cs index 6e8bacf63..0797f37ac 100644 --- a/src/GZCTF/Services/Container/Manager/DockerManager.cs +++ b/src/GZCTF/Services/Container/Manager/DockerManager.cs @@ -69,7 +69,7 @@ private CreateContainerParameters GetCreateContainerParameters(ContainerConfig c Memory = config.MemoryLimit * 1024 * 1024, CPUPercent = config.CPUCount * 10, Privileged = config.PrivilegedContainer, - NetworkMode = string.IsNullOrWhiteSpace(_meta.Config.ChallengeNetwork) ? null : _meta.Config.ChallengeNetwork, + NetworkMode = _meta.Config.ChallengeNetwork, }, }; From 9d13e1a1b872859257575e4c278cf3b6ab15558d Mon Sep 17 00:00:00 2001 From: GZTime Date: Tue, 15 Aug 2023 22:21:12 +0800 Subject: [PATCH 15/67] fix(docker): can not get container ip --- src/GZCTF/Services/Container/Manager/DockerManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GZCTF/Services/Container/Manager/DockerManager.cs b/src/GZCTF/Services/Container/Manager/DockerManager.cs index 0797f37ac..835d6edca 100644 --- a/src/GZCTF/Services/Container/Manager/DockerManager.cs +++ b/src/GZCTF/Services/Container/Manager/DockerManager.cs @@ -154,7 +154,7 @@ await _client.Images.CreateImageAsync(new() container.StartedAt = DateTimeOffset.Parse(info.State.StartedAt); container.ExpectStopAt = container.StartedAt + TimeSpan.FromHours(2); - container.IP = info.NetworkSettings.IPAddress; + container.IP = info.NetworkSettings.Networks.FirstOrDefault().Value.IPAddress; container.Port = config.ExposedPort; container.IsProxy = !_meta.ExposePort; From 5d901484903375f26fd854e9656569bb858943d8 Mon Sep 17 00:00:00 2001 From: GZTime Date: Tue, 15 Aug 2023 22:38:56 +0800 Subject: [PATCH 16/67] fix(docker): add libpcap --- src/Dockerfile | 4 ++-- src/GZCTF/Dockerfile | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/Dockerfile b/src/Dockerfile index 398bfe619..b06b3bed3 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -10,7 +10,7 @@ ENV VITE_APP_GIT_NAME=$GIT_NAME RUN apt update && \ apt install -y wget && \ apt install -y gnupg2 && \ - wget -qO- https://deb.nodesource.com/setup_18.x | bash - && \ + wget -qO- https://deb.nodesource.com/setup_20.x | bash - && \ apt install -y build-essential nodejs RUN npm i -g pnpm @@ -28,7 +28,7 @@ WORKDIR /app EXPOSE 80 COPY --from=publish /app/publish . -RUN apt update && apt install -y wget && rm -rf /var/lib/apt/lists/* +RUN apt update && apt install -y wget libpcap --no-install-recommends && rm -rf /var/lib/apt/lists/* HEALTHCHECK --interval=5m --timeout=3s --start-period=10s --retries=1 \ CMD wget --no-verbose --tries=1 --spider http://localhost:80/healthz || exit 1 diff --git a/src/GZCTF/Dockerfile b/src/GZCTF/Dockerfile index 4756ff68d..ad2939725 100644 --- a/src/GZCTF/Dockerfile +++ b/src/GZCTF/Dockerfile @@ -9,7 +9,7 @@ COPY --from=build /publish . EXPOSE 80 -RUN apt update && apt install -y wget --no-install-recommends && rm -rf /var/lib/apt/lists/* +RUN apt update && apt install -y wget libpcap --no-install-recommends && rm -rf /var/lib/apt/lists/* HEALTHCHECK --interval=5m --timeout=3s --start-period=10s --retries=1 \ CMD wget --no-verbose --tries=1 --spider http://localhost:80/healthz || exit 1 From 21dc62b8f13e3278c3b4c93c4a8231a3961bf79c Mon Sep 17 00:00:00 2001 From: GZTime Date: Tue, 15 Aug 2023 22:53:27 +0800 Subject: [PATCH 17/67] fix(docker): libpcap -> libpcap0.8 --- src/Dockerfile | 6 ++---- src/GZCTF/Dockerfile | 2 +- 2 files changed, 3 insertions(+), 5 deletions(-) diff --git a/src/Dockerfile b/src/Dockerfile index b06b3bed3..2f2fbec46 100644 --- a/src/Dockerfile +++ b/src/Dockerfile @@ -7,9 +7,7 @@ ENV VITE_APP_BUILD_TIMESTAMP=$TIMESTAMP ENV VITE_APP_GIT_SHA=$GIT_SHA ENV VITE_APP_GIT_NAME=$GIT_NAME -RUN apt update && \ - apt install -y wget && \ - apt install -y gnupg2 && \ +RUN apt update && apt install -y wget gnupg2 libpcap0.8 && \ wget -qO- https://deb.nodesource.com/setup_20.x | bash - && \ apt install -y build-essential nodejs @@ -28,7 +26,7 @@ WORKDIR /app EXPOSE 80 COPY --from=publish /app/publish . -RUN apt update && apt install -y wget libpcap --no-install-recommends && rm -rf /var/lib/apt/lists/* +RUN apt update && apt install -y wget libpcap0.8 --no-install-recommends && rm -rf /var/lib/apt/lists/* HEALTHCHECK --interval=5m --timeout=3s --start-period=10s --retries=1 \ CMD wget --no-verbose --tries=1 --spider http://localhost:80/healthz || exit 1 diff --git a/src/GZCTF/Dockerfile b/src/GZCTF/Dockerfile index ad2939725..5ab1b3981 100644 --- a/src/GZCTF/Dockerfile +++ b/src/GZCTF/Dockerfile @@ -9,7 +9,7 @@ COPY --from=build /publish . EXPOSE 80 -RUN apt update && apt install -y wget libpcap --no-install-recommends && rm -rf /var/lib/apt/lists/* +RUN apt update && apt install -y wget libpcap0.8 --no-install-recommends && rm -rf /var/lib/apt/lists/* HEALTHCHECK --interval=5m --timeout=3s --start-period=10s --retries=1 \ CMD wget --no-verbose --tries=1 --spider http://localhost:80/healthz || exit 1 From 4c7ce1a23616e993dec4d134a978ca4fac32d7ee Mon Sep 17 00:00:00 2001 From: GZTime Date: Tue, 15 Aug 2023 23:21:09 +0800 Subject: [PATCH 18/67] fix(capture): use slice for buffer --- src/GZCTF/Program.cs | 2 +- src/GZCTF/Utils/CapturableNetworkStream.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/GZCTF/Program.cs b/src/GZCTF/Program.cs index ab16e3b46..7b9afea7f 100644 --- a/src/GZCTF/Program.cs +++ b/src/GZCTF/Program.cs @@ -277,6 +277,7 @@ } app.UseRouting(); +app.MapHealthChecks("/healthz"); app.UseAuthentication(); app.UseAuthorization(); @@ -293,7 +294,6 @@ app.MapHub("/hub/user"); app.MapHub("/hub/monitor"); app.MapHub("/hub/admin"); -app.MapHealthChecks("/healthz"); app.MapFallbackToFile("index.html"); await using var scope = app.Services.CreateAsyncScope(); diff --git a/src/GZCTF/Utils/CapturableNetworkStream.cs b/src/GZCTF/Utils/CapturableNetworkStream.cs index e2952b9b8..848cbf82d 100644 --- a/src/GZCTF/Utils/CapturableNetworkStream.cs +++ b/src/GZCTF/Utils/CapturableNetworkStream.cs @@ -65,7 +65,7 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation var udp = new UdpPacket((ushort)_options.Dest.Port, (ushort)_options.Source.Port) { - PayloadDataSegment = new ByteArraySegment(buffer.ToArray()) + PayloadDataSegment = new ByteArraySegment(buffer[..count].ToArray()) }; var packet = new EthernetPacket(_dummyPhysicalAddress, _dummyPhysicalAddress, EthernetType.IPv6) From 2210a75ac41867e7f45464977305601def182fa0 Mon Sep 17 00:00:00 2001 From: GZTime Date: Tue, 15 Aug 2023 23:27:36 +0800 Subject: [PATCH 19/67] docs: add platform proxy --- docs/pages/config/appsettings.zh.mdx | 31 ++--------------- docs/pages/guide/_meta.zh.json | 3 +- docs/pages/guide/platform-proxy.zh.mdx | 48 ++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 30 deletions(-) create mode 100644 docs/pages/guide/platform-proxy.zh.mdx diff --git a/docs/pages/config/appsettings.zh.mdx b/docs/pages/config/appsettings.zh.mdx index e31813bcc..7ee37c6d5 100644 --- a/docs/pages/config/appsettings.zh.mdx +++ b/docs/pages/config/appsettings.zh.mdx @@ -148,39 +148,12 @@ GZCTF 仅支持 PostgreSQL 作为数据库,不支持 MySQL 等其他数据库 - **ChallengeNetwork:** 指定题目容器所在的网络,若不指定,将会使用默认网络。 - - - 采用 `PlatformProxy` 端口映射类型时,为了使得 GZCTF 顺利访问题目容器,需要额外手动创建一个网络: - - ```bash - docker network create challenges -d bridge --subnet 192.168.133.0/24 - ``` - - 设置配置项 **ChallengeNetwork** 为对应的网络名称,并需要在 docker-compose.yml 中桥接外部网络,例如: - - ```yaml - version: "3.7" - services: - gzctf: - ... - networks: - - default - - challenges - - networks: - challenges: - external: true - ``` - - - - #### Kubernetes - **Namespace:** Kubernetes 命名空间,用于创建题目实例的命名空间,默认为 `gzctf-challenges` - **ConfigPath:** Kubernetes 配置文件路径,用于连接集群,默认为 `k8sconfig.yaml` -- **AllowCIDR:** [实验功能] 允许访问 Pod 的 CIDR 白名单 -- **DNS:** [实验功能] 避免使用集群 DNS 的自定义 DNS 服务器列表 +- **AllowCIDR:** 允许访问 Pod 的 CIDR 白名单 +- **DNS:** 避免使用集群 DNS 的自定义 DNS 服务器列表 默认行为请将集群连接配置放入 `k8sconfig.yaml` 文件中,并将其挂载到 `/app` 目录下。实验功能若非了解行为请勿更改。 diff --git a/docs/pages/guide/_meta.zh.json b/docs/pages/guide/_meta.zh.json index 6fecabdc7..df1a86fe9 100644 --- a/docs/pages/guide/_meta.zh.json +++ b/docs/pages/guide/_meta.zh.json @@ -1,4 +1,5 @@ { "challenge": "赛题配置", - "dynamic-flag": "动态 flag" + "dynamic-flag": "动态 flag", + "platform-proxy": "平台流量代理" } diff --git a/docs/pages/guide/platform-proxy.zh.mdx b/docs/pages/guide/platform-proxy.zh.mdx new file mode 100644 index 000000000..1249aeb13 --- /dev/null +++ b/docs/pages/guide/platform-proxy.zh.mdx @@ -0,0 +1,48 @@ +import { Callout } from "nextra-theme-docs"; + +# 平台代理 + +GZCTF 自带对于流量的 WebSocket-TCP 转发功能和对应的流量记录能力,可以通过相关配置项进行开启。 + +## 配置 + +在 `appsettings.json` 中,找到 `ContainerProvider` 节点,进行如下配置: + +```json +{ + "ContainerProvider": { + "PortMappingType": "PlatformProxy", + "EnableTrafficCapture": false + } +} +``` + +## 使用 + +在平台代理开启后,可以使用平台的 `/api/proxy/{guid}` 接口进行流量转发。 + +参考使用客户端:[WebSocketReflectorX](https://github.com/XDSEC/WebSocketReflectorX) + +## 注意事项 + +采用 `Docker` 单机作为后端且使用 `PlatformProxy` 端口映射类型时,为了使得 GZCTF 顺利访问题目容器,需要额外手动创建一个网络: + +```bash +docker network create challenges -d bridge --subnet 192.168.133.0/24 +``` + +设置配置项 **ChallengeNetwork** 为对应的网络名称,并需要在 docker-compose.yml 中桥接外部网络,例如: + +```yaml +version: "3.7" +services: +gzctf: + ... + networks: + - default + - challenges + +networks: +challenges: + external: true +``` From 531887eed74596529432cecb396d04e7690b6db7 Mon Sep 17 00:00:00 2001 From: GZTime Date: Wed, 16 Aug 2023 00:20:13 +0800 Subject: [PATCH 20/67] feat(frontend): update for instance entry --- .../src/components/ChallengeDetailModal.tsx | 52 ++---------- .../src/components/InstanceEntry.tsx | 85 +++++++++++++++++++ .../admin/ChallengePreviewModal.tsx | 45 +++------- .../ClientApp/src/pages/admin/Instances.tsx | 46 ++++++++-- src/GZCTF/ClientApp/src/utils/Shared.tsx | 5 ++ 5 files changed, 147 insertions(+), 86 deletions(-) create mode 100644 src/GZCTF/ClientApp/src/components/InstanceEntry.tsx diff --git a/src/GZCTF/ClientApp/src/components/ChallengeDetailModal.tsx b/src/GZCTF/ClientApp/src/components/ChallengeDetailModal.tsx index c8d30f89d..22c21f691 100644 --- a/src/GZCTF/ClientApp/src/components/ChallengeDetailModal.tsx +++ b/src/GZCTF/ClientApp/src/components/ChallengeDetailModal.tsx @@ -7,7 +7,6 @@ import { Box, Button, Card, - Code, Divider, Group, LoadingOverlay, @@ -18,19 +17,18 @@ import { Text, TextInput, Title, - Tooltip, } from '@mantine/core' -import { useClipboard, useDisclosure, useInputState } from '@mantine/hooks' +import { useDisclosure, useInputState } from '@mantine/hooks' import { notifications, showNotification, updateNotification } from '@mantine/notifications' import { mdiCheck, mdiClose, mdiDownload, mdiLightbulbOnOutline, mdiLoading } from '@mdi/js' import { Icon } from '@mdi/react' import MarkdownRender, { InlineMarkdownRender } from '@Components/MarkdownRender' import { showErrorNotification } from '@Utils/ApiErrorHandler' import { ChallengeTagItemProps } from '@Utils/Shared' -import { useTooltipStyles } from '@Utils/ThemeOverride' import { OnceSWRConfig } from '@Utils/useConfig' import { useTypographyStyles } from '@Utils/useTypographyStyles' import api, { AnswerResult, ChallengeType } from '@Api' +import InstanceEntry from './InstanceEntry' interface ChallengeDetailModalProps extends ModalProps { gameId: number @@ -130,15 +128,10 @@ const ChallengeDetailModal: FC = (props) => { setPlaceholder(FlagPlaceholders[Math.floor(Math.random() * FlagPlaceholders.length)]) }, [challengeId]) - const instanceCloseTime = dayjs(challenge?.context?.closeTime ?? 0) - const instanceLeft = instanceCloseTime.diff(dayjs(), 'minute') - const isDynamic = challenge?.type === ChallengeType.StaticContainer || challenge?.type === ChallengeType.DynamicContainer const { classes, theme } = useTypographyStyles() - const { classes: tooltipClasses } = useTooltipStyles() - const clipBoard = useClipboard() const [disabled, setDisabled] = useState(false) const [onSubmitting, setOnSubmitting] = useState(false) @@ -404,41 +397,12 @@ const ChallengeDetailModal: FC = (props) => { )} {isDynamic && challenge?.context?.instanceEntry && ( - - - - 实例访问入口: - - { - clipBoard.copy(challenge.context?.instanceEntry ?? '') - showNotification({ - color: 'teal', - message: '实例入口已复制到剪贴板', - icon: , - }) - }} - > - {challenge?.context?.instanceEntry} - - - - - - - - - - + )} diff --git a/src/GZCTF/ClientApp/src/components/InstanceEntry.tsx b/src/GZCTF/ClientApp/src/components/InstanceEntry.tsx new file mode 100644 index 000000000..84f94e96c --- /dev/null +++ b/src/GZCTF/ClientApp/src/components/InstanceEntry.tsx @@ -0,0 +1,85 @@ +import dayjs from 'dayjs' +import { FC } from 'react' +import { Stack, Text, Button, Group, Code, Tooltip, Anchor } from '@mantine/core' +import { useClipboard } from '@mantine/hooks' +import { showNotification } from '@mantine/notifications' +import { mdiCheck } from '@mdi/js' +import { Icon } from '@mdi/react' +import { getProxyUrl } from '@Utils/Shared' +import { useTooltipStyles } from '@Utils/ThemeOverride' +import { ClientFlagContext } from '@Api' +import { Countdown } from './ChallengeDetailModal' + +interface InstanceEntryProps { + context: ClientFlagContext + disabled: boolean + onProlong: () => void + onDestroy: () => void +} + +export const InstanceEntry: FC = (props) => { + const { context, onProlong, disabled, onDestroy } = props + const clipBoard = useClipboard() + const instanceCloseTime = dayjs(context.closeTime ?? 0) + const instanceLeft = instanceCloseTime.diff(dayjs(), 'minute') + const { classes: tooltipClasses, theme } = useTooltipStyles() + + const instanceEntry = context.instanceEntry ?? '' + const isPlatfromProxy = instanceEntry.length === 36 && !instanceEntry.includes(':') + const copyEntry = isPlatfromProxy ? getProxyUrl(instanceEntry) : instanceEntry + + return ( + + + + 实例入口: + + { + clipBoard.copy(copyEntry) + showNotification({ + color: 'teal', + title: isPlatfromProxy ? '实例入口已复制到剪贴板' : undefined, + message: isPlatfromProxy ? '请使用客户端进行访问' : '实例入口已复制到剪贴板', + icon: , + }) + }} + > + {instanceEntry} + + + + + + {isPlatfromProxy && ( + + + 获取客户端: + + WebSocketReflectorX + + + + )} + + + + + + ) +} + +export default InstanceEntry diff --git a/src/GZCTF/ClientApp/src/components/admin/ChallengePreviewModal.tsx b/src/GZCTF/ClientApp/src/components/admin/ChallengePreviewModal.tsx index 3b2e346e9..6f04266fc 100644 --- a/src/GZCTF/ClientApp/src/components/admin/ChallengePreviewModal.tsx +++ b/src/GZCTF/ClientApp/src/components/admin/ChallengePreviewModal.tsx @@ -5,7 +5,6 @@ import { ActionIcon, Box, Button, - Code, Divider, Group, LoadingOverlay, @@ -16,16 +15,15 @@ import { Text, TextInput, Title, - Tooltip, } from '@mantine/core' import { useDisclosure, useInputState } from '@mantine/hooks' import { showNotification } from '@mantine/notifications' import { mdiCheck, mdiDownload, mdiLightbulbOnOutline } from '@mdi/js' import { Icon } from '@mdi/react' -import { Countdown, FlagPlaceholders } from '@Components/ChallengeDetailModal' +import { FlagPlaceholders } from '@Components/ChallengeDetailModal' +import InstanceEntry from '@Components/InstanceEntry' import MarkdownRender, { InlineMarkdownRender } from '@Components/MarkdownRender' import { ChallengeTagItemProps } from '@Utils/Shared' -import { useTooltipStyles } from '@Utils/ThemeOverride' import { useTypographyStyles } from '@Utils/useTypographyStyles' import { ChallengeType, ChallengeUpdateModel, FileType } from '@Api' @@ -42,9 +40,8 @@ const ChallengePreviewModal: FC = (props) => { const [placeholder, setPlaceholder] = useState('') const [flag, setFlag] = useInputState('') - const [withContainer, setWithContainer] = useState(false) const [startTime, setStartTime] = useState(dayjs()) - const { classes: tooltipClasses } = useTooltipStyles() + const [withContainer, setWithContainer] = useState(false) const onSubmit = (event: React.FormEvent) => { event.preventDefault() @@ -183,33 +180,15 @@ const ChallengePreviewModal: FC = (props) => { )} {isDynamic && withContainer && ( - - - - 实例访问入口: - - - localhost:2333 - - - - - - - - - - + {}} + onDestroy={() => setWithContainer(false)} + /> )} diff --git a/src/GZCTF/ClientApp/src/pages/admin/Instances.tsx b/src/GZCTF/ClientApp/src/pages/admin/Instances.tsx index f0d1d7140..e19607016 100644 --- a/src/GZCTF/ClientApp/src/pages/admin/Instances.tsx +++ b/src/GZCTF/ClientApp/src/pages/admin/Instances.tsx @@ -27,7 +27,7 @@ import { Icon } from '@mdi/react' import { ActionIconWithConfirm } from '@Components/ActionIconWithConfirm' import AdminPage from '@Components/admin/AdminPage' import { showErrorNotification } from '@Utils/ApiErrorHandler' -import { ChallengeTagLabelMap } from '@Utils/Shared' +import { ChallengeTagLabelMap, getProxyUrl } from '@Utils/Shared' import { useTableStyles, useTooltipStyles } from '@Utils/ThemeOverride' import api, { ChallengeModel, ChallengeTag, TeamModel } from '@Api' @@ -205,8 +205,8 @@ const Instances: FC = () => { 队伍 题目 - 容器 Id 生命周期 + 容器 Id 访问入口 @@ -233,11 +233,6 @@ const Instances: FC = () => { - - - {inst.containerId?.substring(0, 20)} - - @@ -249,6 +244,39 @@ const Instances: FC = () => { + + + + { + clipBoard.copy( + inst.containerGuid && getProxyUrl(inst.containerGuid) + ) + showNotification({ + color: 'teal', + title: '代理 URL 已复制到剪贴板', + message: '请使用客户端进行访问', + icon: , + }) + }} + > + {inst.containerGuid} + + + + { clipBoard.copy(`${inst.ip ?? ''}:${inst.port ?? ''}`) showNotification({ color: 'teal', - message: '实例入口已复制到剪贴板', + message: '访问入口已复制到剪贴板', icon: , }) }} @@ -286,7 +314,7 @@ const Instances: FC = () => { onDelete(inst.containerGuid)} /> diff --git a/src/GZCTF/ClientApp/src/utils/Shared.tsx b/src/GZCTF/ClientApp/src/utils/Shared.tsx index f4f854c98..00e9086db 100644 --- a/src/GZCTF/ClientApp/src/utils/Shared.tsx +++ b/src/GZCTF/ClientApp/src/utils/Shared.tsx @@ -309,3 +309,8 @@ export const TaskStatusColorMap = new Map([ [TaskStatus.Duplicate, 'lime'], [null, 'gray'], ]) + +export const getProxyUrl = (guid: string) => { + const protocol = window.location.protocol.replace('http', 'ws') + return `${protocol}//${window.location.host}/api/proxy/${guid}` +} From 6ad80b961996b42af1f2dd5bf1c0ed75c9c275a3 Mon Sep 17 00:00:00 2001 From: GZTime Date: Wed, 16 Aug 2023 00:30:39 +0800 Subject: [PATCH 21/67] docs: update --- docs/pages/guide/platform-proxy.zh.mdx | 2 +- src/GZCTF/ClientApp/src/components/InstanceEntry.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/pages/guide/platform-proxy.zh.mdx b/docs/pages/guide/platform-proxy.zh.mdx index 1249aeb13..5dc34ef4e 100644 --- a/docs/pages/guide/platform-proxy.zh.mdx +++ b/docs/pages/guide/platform-proxy.zh.mdx @@ -21,7 +21,7 @@ GZCTF 自带对于流量的 WebSocket-TCP 转发功能和对应的流量记录 在平台代理开启后,可以使用平台的 `/api/proxy/{guid}` 接口进行流量转发。 -参考使用客户端:[WebSocketReflectorX](https://github.com/XDSEC/WebSocketReflectorX) +可用客户端:[WebSocketReflectorX](https://github.com/XDSEC/WebSocketReflectorX) 进行本地端口代理,从而进行无感交互。 ## 注意事项 diff --git a/src/GZCTF/ClientApp/src/components/InstanceEntry.tsx b/src/GZCTF/ClientApp/src/components/InstanceEntry.tsx index 84f94e96c..fc7ede8a8 100644 --- a/src/GZCTF/ClientApp/src/components/InstanceEntry.tsx +++ b/src/GZCTF/ClientApp/src/components/InstanceEntry.tsx @@ -61,7 +61,7 @@ export const InstanceEntry: FC = (props) => { 获取客户端: From 095b3416beddd106eaa5a072b9cf46114e25bf24 Mon Sep 17 00:00:00 2001 From: GZTime Date: Wed, 16 Aug 2023 00:35:33 +0800 Subject: [PATCH 22/67] docs: update --- docs/pages/deployment/docker-k8s.zh.mdx | 3 +-- docs/pages/deployment/k8s-only.zh.mdx | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/docs/pages/deployment/docker-k8s.zh.mdx b/docs/pages/deployment/docker-k8s.zh.mdx index 1f3b11276..36d197160 100644 --- a/docs/pages/deployment/docker-k8s.zh.mdx +++ b/docs/pages/deployment/docker-k8s.zh.mdx @@ -102,9 +102,8 @@ gzctf: networks: default: volumes: - - "./data/files:/app/uploads" + - "./data/files:/app/files" - "./appsettings.json:/app/appsettings.json:ro" - - "./logs:/app/log" - "./k8sconfig.yaml:/app/k8sconfig.yaml:ro" # this is required for k8s deployment # - "/var/run/docker.sock:/var/run/docker.sock" # this is required for docker deployment depends_on: diff --git a/docs/pages/deployment/k8s-only.zh.mdx b/docs/pages/deployment/k8s-only.zh.mdx index 164314ab4..fb7c2b54a 100644 --- a/docs/pages/deployment/k8s-only.zh.mdx +++ b/docs/pages/deployment/k8s-only.zh.mdx @@ -148,7 +148,7 @@ import { Callout } from "nextra-theme-docs"; name: http volumeMounts: - name: gzctf-files - mountPath: /app/uploads + mountPath: /app/files - name: gzctf-config mountPath: /app/appsettings.json subPath: appsettings.json From 3be858c70176184e0c90f31f2791aba5da3120e3 Mon Sep 17 00:00:00 2001 From: GZTime Date: Wed, 16 Aug 2023 10:02:49 +0800 Subject: [PATCH 23/67] wip: update proxy controller --- src/GZCTF/Controllers/ProxyController.cs | 6 ++---- src/GZCTF/GZCTF.csproj | 4 ++-- src/GZCTF/Models/Data/Container.cs | 5 ++--- 3 files changed, 6 insertions(+), 9 deletions(-) diff --git a/src/GZCTF/Controllers/ProxyController.cs b/src/GZCTF/Controllers/ProxyController.cs index d3d6159bc..a10f732ea 100644 --- a/src/GZCTF/Controllers/ProxyController.cs +++ b/src/GZCTF/Controllers/ProxyController.cs @@ -94,7 +94,7 @@ public async Task ProxyForInstance(string id, CancellationToken t Source = new(clientIp, clientPort), Dest = ipEndPoint, EnableCapture = _enableTrafficCapture, - FilePath = container.TrafficPath, + FilePath = container.TrafficPath(HttpContext.Connection.Id), }); } catch (Exception e) @@ -105,12 +105,10 @@ public async Task ProxyForInstance(string id, CancellationToken t var ws = await HttpContext.WebSockets.AcceptWebSocketAsync(); - _logger.SystemLog($"[{id}] {clientIp}:{clientPort} -> {container.IP}:{container.Port}", TaskStatus.Pending, LogLevel.Debug); - try { var (tx, rx) = await RunProxy(stream, ws, token); - _logger.SystemLog($"[{id}] {clientIp}:{clientPort}, tx {tx}, rx {rx}", TaskStatus.Success, LogLevel.Debug); + _logger.SystemLog($"[{id}] {clientIp} -> {container.IP}:{container.Port}, tx {tx}, rx {rx}", TaskStatus.Success, LogLevel.Debug); } catch (Exception e) { diff --git a/src/GZCTF/GZCTF.csproj b/src/GZCTF/GZCTF.csproj index 30e2a0660..473b0af9a 100644 --- a/src/GZCTF/GZCTF.csproj +++ b/src/GZCTF/GZCTF.csproj @@ -39,8 +39,8 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/src/GZCTF/Models/Data/Container.cs b/src/GZCTF/Models/Data/Container.cs index 5672f9469..1aeac6fea 100644 --- a/src/GZCTF/Models/Data/Container.cs +++ b/src/GZCTF/Models/Data/Container.cs @@ -75,9 +75,8 @@ public class Container /// /// 容器实例流量捕获存储路径 /// - [NotMapped] - public string TrafficPath => Instance is null ? string.Empty : - $"files/capture/{Instance.ParticipationId}/{Instance.ChallengeId}/{DateTimeOffset.Now:s}.pcap"; + public string TrafficPath(string conn) => Instance is null ? string.Empty : + $"files/capture/{Instance.ChallengeId}/{Instance.ParticipationId}/{DateTimeOffset.Now:s}-{conn}.pcap"; #region Db Relationship From dc657a72af8d537eaae16ffcd50d2eaf604e33f0 Mon Sep 17 00:00:00 2001 From: GZTime Date: Wed, 16 Aug 2023 10:15:40 +0800 Subject: [PATCH 24/67] wip: add CaptureTraffic & fix typos --- src/GZCTF/ClientApp/src/components/InstanceEntry.tsx | 10 +++++----- src/GZCTF/Controllers/ProxyController.cs | 2 +- src/GZCTF/Models/Data/Challenge.cs | 6 +++--- src/GZCTF/Models/Internal/ContainerConfig.cs | 4 ++-- .../Models/Request/Edit/ChallengeEditDetailModel.cs | 6 +++--- src/GZCTF/Models/Request/Edit/ChallengeUpdateModel.cs | 4 ++-- src/GZCTF/Repositories/InstanceRepository.cs | 2 +- src/GZCTF/Services/Container/Manager/DockerManager.cs | 1 - src/GZCTF/Services/Container/Manager/K8sManager.cs | 1 - src/GZCTF/Utils/CapturableNetworkStream.cs | 2 +- 10 files changed, 18 insertions(+), 20 deletions(-) diff --git a/src/GZCTF/ClientApp/src/components/InstanceEntry.tsx b/src/GZCTF/ClientApp/src/components/InstanceEntry.tsx index fc7ede8a8..56ee6ecc1 100644 --- a/src/GZCTF/ClientApp/src/components/InstanceEntry.tsx +++ b/src/GZCTF/ClientApp/src/components/InstanceEntry.tsx @@ -25,8 +25,8 @@ export const InstanceEntry: FC = (props) => { const { classes: tooltipClasses, theme } = useTooltipStyles() const instanceEntry = context.instanceEntry ?? '' - const isPlatfromProxy = instanceEntry.length === 36 && !instanceEntry.includes(':') - const copyEntry = isPlatfromProxy ? getProxyUrl(instanceEntry) : instanceEntry + const isPlatformProxy = instanceEntry.length === 36 && !instanceEntry.includes(':') + const copyEntry = isPlatformProxy ? getProxyUrl(instanceEntry) : instanceEntry return ( @@ -44,8 +44,8 @@ export const InstanceEntry: FC = (props) => { clipBoard.copy(copyEntry) showNotification({ color: 'teal', - title: isPlatfromProxy ? '实例入口已复制到剪贴板' : undefined, - message: isPlatfromProxy ? '请使用客户端进行访问' : '实例入口已复制到剪贴板', + title: isPlatformProxy ? '实例入口已复制到剪贴板' : undefined, + message: isPlatformProxy ? '请使用客户端进行访问' : '实例入口已复制到剪贴板', icon: , }) }} @@ -56,7 +56,7 @@ export const InstanceEntry: FC = (props) => { - {isPlatfromProxy && ( + {isPlatformProxy && ( 获取客户端: diff --git a/src/GZCTF/Controllers/ProxyController.cs b/src/GZCTF/Controllers/ProxyController.cs index a10f732ea..b5145067c 100644 --- a/src/GZCTF/Controllers/ProxyController.cs +++ b/src/GZCTF/Controllers/ProxyController.cs @@ -129,7 +129,7 @@ public async Task ProxyForInstance(string id, CancellationToken t /// /// /// - internal async Task<(ulong, ulong)> RunProxy(CapturableNetworkStream stream, WebSocket ws, CancellationToken token = default) + internal static async Task<(ulong, ulong)> RunProxy(CapturableNetworkStream stream, WebSocket ws, CancellationToken token = default) { var cts = CancellationTokenSource.CreateLinkedTokenSource(token); cts.CancelAfter(TimeSpan.FromMinutes(30)); diff --git a/src/GZCTF/Models/Data/Challenge.cs b/src/GZCTF/Models/Data/Challenge.cs index 0464fa541..07bce6e17 100644 --- a/src/GZCTF/Models/Data/Challenge.cs +++ b/src/GZCTF/Models/Data/Challenge.cs @@ -81,9 +81,9 @@ public class Challenge public int? ContainerExposePort { get; set; } = 80; /// - /// 是否为特权容器 + /// 是否需要记录访问流量 /// - public bool? PrivilegedContainer { get; set; } = false; + public bool CaptureTraffic { get; set; } = false; /// /// 解决题目人数 @@ -249,7 +249,7 @@ internal Challenge Update(ChallengeUpdateModel model) MemoryLimit = model.MemoryLimit ?? MemoryLimit; StorageLimit = model.StorageLimit ?? StorageLimit; ContainerImage = model.ContainerImage?.Trim() ?? ContainerImage; - PrivilegedContainer = model.PrivilegedContainer ?? PrivilegedContainer; + CaptureTraffic = model.CaptureTraffic ?? CaptureTraffic; ContainerExposePort = model.ContainerExposePort ?? ContainerExposePort; OriginalScore = model.OriginalScore ?? OriginalScore; MinScoreRate = model.MinScoreRate ?? MinScoreRate; diff --git a/src/GZCTF/Models/Internal/ContainerConfig.cs b/src/GZCTF/Models/Internal/ContainerConfig.cs index 389109ea4..df44543f1 100644 --- a/src/GZCTF/Models/Internal/ContainerConfig.cs +++ b/src/GZCTF/Models/Internal/ContainerConfig.cs @@ -30,9 +30,9 @@ public class ContainerConfig public string? Flag { get; set; } = string.Empty; /// - /// 是否为特权容器 + /// 是否需要记录访问流量 /// - public bool PrivilegedContainer { get; set; } = false; + public bool CaptureTraffic { get; set; } = false; /// /// 内存限制(MB) diff --git a/src/GZCTF/Models/Request/Edit/ChallengeEditDetailModel.cs b/src/GZCTF/Models/Request/Edit/ChallengeEditDetailModel.cs index 7c1321476..9102abfdb 100644 --- a/src/GZCTF/Models/Request/Edit/ChallengeEditDetailModel.cs +++ b/src/GZCTF/Models/Request/Edit/ChallengeEditDetailModel.cs @@ -87,9 +87,9 @@ public class ChallengeEditDetailModel public int? ContainerExposePort { get; set; } = 80; /// - /// 是否为特权容器 + /// 是否需要记录访问流量 /// - public bool? PrivilegedContainer { get; set; } = false; + public bool? CaptureTraffic { get; set; } = false; #endregion Container @@ -159,7 +159,7 @@ internal static ChallengeEditDetailModel FromChallenge(Challenge chal) CPUCount = chal.CPUCount, StorageLimit = chal.StorageLimit, ContainerExposePort = chal.ContainerExposePort, - PrivilegedContainer = chal.PrivilegedContainer, + CaptureTraffic = chal.CaptureTraffic, OriginalScore = chal.OriginalScore, MinScoreRate = chal.MinScoreRate, Difficulty = chal.Difficulty, diff --git a/src/GZCTF/Models/Request/Edit/ChallengeUpdateModel.cs b/src/GZCTF/Models/Request/Edit/ChallengeUpdateModel.cs index e2e4b5f03..f0e962e5a 100644 --- a/src/GZCTF/Models/Request/Edit/ChallengeUpdateModel.cs +++ b/src/GZCTF/Models/Request/Edit/ChallengeUpdateModel.cs @@ -72,9 +72,9 @@ public class ChallengeUpdateModel public int? ContainerExposePort { get; set; } /// - /// 是否为特权容器 + /// 是否需要记录访问流量 /// - public bool? PrivilegedContainer { get; set; } = false; + public bool? CaptureTraffic { get; set; } = false; #endregion Container diff --git a/src/GZCTF/Repositories/InstanceRepository.cs b/src/GZCTF/Repositories/InstanceRepository.cs index a62711851..a45c4f5fe 100644 --- a/src/GZCTF/Repositories/InstanceRepository.cs +++ b/src/GZCTF/Repositories/InstanceRepository.cs @@ -175,7 +175,7 @@ public async Task> CreateContainer(Instance instance, Team CPUCount = instance.Challenge.CPUCount ?? 1, MemoryLimit = instance.Challenge.MemoryLimit ?? 64, StorageLimit = instance.Challenge.StorageLimit ?? 256, - PrivilegedContainer = instance.Challenge.PrivilegedContainer ?? false, + CaptureTraffic = instance.Challenge.CaptureTraffic, ExposedPort = instance.Challenge.ContainerExposePort ?? throw new ArgumentException("创建容器时遇到无效的端口"), }, token); diff --git a/src/GZCTF/Services/Container/Manager/DockerManager.cs b/src/GZCTF/Services/Container/Manager/DockerManager.cs index 835d6edca..2dec3a07f 100644 --- a/src/GZCTF/Services/Container/Manager/DockerManager.cs +++ b/src/GZCTF/Services/Container/Manager/DockerManager.cs @@ -68,7 +68,6 @@ private CreateContainerParameters GetCreateContainerParameters(ContainerConfig c { Memory = config.MemoryLimit * 1024 * 1024, CPUPercent = config.CPUCount * 10, - Privileged = config.PrivilegedContainer, NetworkMode = _meta.Config.ChallengeNetwork, }, }; diff --git a/src/GZCTF/Services/Container/Manager/K8sManager.cs b/src/GZCTF/Services/Container/Manager/K8sManager.cs index 098589b8c..16955b300 100644 --- a/src/GZCTF/Services/Container/Manager/K8sManager.cs +++ b/src/GZCTF/Services/Container/Manager/K8sManager.cs @@ -71,7 +71,6 @@ public K8sManager(IContainerProvider provider, ILogger< Name = name, Image = config.Image, ImagePullPolicy = "Always", - SecurityContext = new() { Privileged = config.PrivilegedContainer }, Env = config.Flag is null ? new List() : new[] { new V1EnvVar("GZCTF_FLAG", config.Flag) diff --git a/src/GZCTF/Utils/CapturableNetworkStream.cs b/src/GZCTF/Utils/CapturableNetworkStream.cs index 848cbf82d..7a63b0c4c 100644 --- a/src/GZCTF/Utils/CapturableNetworkStream.cs +++ b/src/GZCTF/Utils/CapturableNetworkStream.cs @@ -36,7 +36,7 @@ public class CapturableNetworkStream : NetworkStream { private readonly CapturableNetworkStreamOptions _options; private readonly CaptureFileWriterDevice? _device = null; - private readonly PhysicalAddress _dummyPhysicalAddress = PhysicalAddress.Parse("ba-db-ad-ba-db-ad"); + private readonly PhysicalAddress _dummyPhysicalAddress = PhysicalAddress.Parse("00-11-00-11-00-11"); public CapturableNetworkStream(Socket socket, CapturableNetworkStreamOptions options) : base(socket) { From ac85553f1176132892ad2d121c4feeeb5929d246 Mon Sep 17 00:00:00 2001 From: GZTime Date: Wed, 16 Aug 2023 10:39:40 +0800 Subject: [PATCH 25/67] wip: add migration --- src/GZCTF/ClientApp/src/Api.ts | 24 +- .../games/[id]/challenges/[chalId]/Index.tsx | 6 +- src/GZCTF/Controllers/ProxyController.cs | 2 +- ...230816021558_AddCaptureTraffic.Designer.cs | 1388 +++++++++++++++++ .../20230816021558_AddCaptureTraffic.cs | 39 + .../Migrations/AppDbContextModelSnapshot.cs | 8 +- src/GZCTF/Models/Data/Challenge.cs | 4 +- src/GZCTF/Models/Internal/ContainerConfig.cs | 2 +- .../Request/Edit/ChallengeEditDetailModel.cs | 4 +- .../Request/Edit/ChallengeUpdateModel.cs | 2 +- src/GZCTF/Repositories/ContainerRepository.cs | 4 +- src/GZCTF/Repositories/InstanceRepository.cs | 2 +- 12 files changed, 1457 insertions(+), 28 deletions(-) create mode 100644 src/GZCTF/Migrations/20230816021558_AddCaptureTraffic.Designer.cs create mode 100644 src/GZCTF/Migrations/20230816021558_AddCaptureTraffic.cs diff --git a/src/GZCTF/ClientApp/src/Api.ts b/src/GZCTF/ClientApp/src/Api.ts index a5a7b0454..96bede538 100644 --- a/src/GZCTF/ClientApp/src/Api.ts +++ b/src/GZCTF/ClientApp/src/Api.ts @@ -869,8 +869,8 @@ export interface ChallengeEditDetailModel { * @format int32 */ containerExposePort: number - /** 是否为特权容器 */ - privilegedContainer?: boolean | null + /** 是否需要记录访问流量 */ + enableTrafficCapture?: boolean | null /** * 初始分数 * @format int32 @@ -1050,8 +1050,8 @@ export interface ChallengeUpdateModel { * @format int32 */ containerExposePort?: number | null - /** 是否为特权容器 */ - privilegedContainer?: boolean | null + /** 是否需要记录访问流量 */ + enableTrafficCapture?: boolean | null /** * 初始分数 * @format int32 @@ -4533,11 +4533,11 @@ export class Api extends HttpClient - this.request({ - path: `/inst/${id}`, + this.request({ + path: `/api/proxy/${id}`, method: 'GET', ...params, }), @@ -4547,10 +4547,10 @@ export class Api extends HttpClient - useSWR(doFetch ? `/inst/${id}` : null, options), + useSWR(doFetch ? `/api/proxy/${id}` : null, options), /** * No description @@ -4558,13 +4558,13 @@ export class Api extends HttpClient, + data?: void | Promise, options?: MutatorOptions - ) => mutate(`/inst/${id}`, data, options), + ) => mutate(`/api/proxy/${id}`, data, options), } team = { /** diff --git a/src/GZCTF/ClientApp/src/pages/admin/games/[id]/challenges/[chalId]/Index.tsx b/src/GZCTF/ClientApp/src/pages/admin/games/[id]/challenges/[chalId]/Index.tsx index 98237aa14..0ee6d225e 100644 --- a/src/GZCTF/ClientApp/src/pages/admin/games/[id]/challenges/[chalId]/Index.tsx +++ b/src/GZCTF/ClientApp/src/pages/admin/games/[id]/challenges/[chalId]/Index.tsx @@ -498,10 +498,10 @@ const GameChallengeEdit: FC = () => { - setChallengeInfo({ ...challengeInfo, privilegedContainer: e.target.checked }) + setChallengeInfo({ ...challengeInfo, enableTrafficCapture: e.target.checked }) } /> diff --git a/src/GZCTF/Controllers/ProxyController.cs b/src/GZCTF/Controllers/ProxyController.cs index b5145067c..514a35c54 100644 --- a/src/GZCTF/Controllers/ProxyController.cs +++ b/src/GZCTF/Controllers/ProxyController.cs @@ -93,7 +93,7 @@ public async Task ProxyForInstance(string id, CancellationToken t { Source = new(clientIp, clientPort), Dest = ipEndPoint, - EnableCapture = _enableTrafficCapture, + EnableCapture = _enableTrafficCapture && container.Instance.Challenge.EnableTrafficCapture, FilePath = container.TrafficPath(HttpContext.Connection.Id), }); } diff --git a/src/GZCTF/Migrations/20230816021558_AddCaptureTraffic.Designer.cs b/src/GZCTF/Migrations/20230816021558_AddCaptureTraffic.Designer.cs new file mode 100644 index 000000000..c6ed830c0 --- /dev/null +++ b/src/GZCTF/Migrations/20230816021558_AddCaptureTraffic.Designer.cs @@ -0,0 +1,1388 @@ +// +using System; +using GZCTF.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace GZCTF.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20230816021558_AddEnableTrafficCapture")] + partial class AddTrafficCapture + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "7.0.10") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("GZCTF.Models.Challenge", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AcceptedCount") + .HasColumnType("integer"); + + b.Property("AttachmentId") + .HasColumnType("integer"); + + b.Property("CPUCount") + .HasColumnType("integer"); + + b.Property("EnableTrafficCapture") + .HasColumnType("boolean"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("uuid"); + + b.Property("ContainerExposePort") + .HasColumnType("integer"); + + b.Property("ContainerImage") + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("Difficulty") + .HasColumnType("double precision"); + + b.Property("FileName") + .HasColumnType("text"); + + b.Property("FlagTemplate") + .HasColumnType("text"); + + b.Property("GameId") + .HasColumnType("integer"); + + b.Property("Hints") + .HasColumnType("text"); + + b.Property("IsEnabled") + .HasColumnType("boolean"); + + b.Property("MemoryLimit") + .HasColumnType("integer"); + + b.Property("MinScoreRate") + .HasColumnType("double precision"); + + b.Property("OriginalScore") + .HasColumnType("integer"); + + b.Property("StorageLimit") + .HasColumnType("integer"); + + b.Property("SubmissionCount") + .HasColumnType("integer"); + + b.Property("Tag") + .HasColumnType("smallint"); + + b.Property("TestContainerId") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("AttachmentId"); + + b.HasIndex("GameId"); + + b.HasIndex("TestContainerId"); + + b.ToTable("Challenges"); + }); + + modelBuilder.Entity("GZCTF.Models.Container", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ContainerId") + .IsRequired() + .HasColumnType("text"); + + b.Property("ExpectStopAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IP") + .IsRequired() + .HasColumnType("text"); + + b.Property("Image") + .IsRequired() + .HasColumnType("text"); + + b.Property("InstanceId") + .HasColumnType("integer"); + + b.Property("IsProxy") + .HasColumnType("boolean"); + + b.Property("Port") + .HasColumnType("integer"); + + b.Property("PublicIP") + .HasColumnType("text"); + + b.Property("PublicPort") + .HasColumnType("integer"); + + b.Property("StartedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Status") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("InstanceId"); + + b.ToTable("Containers"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.Attachment", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("LocalFileId") + .HasColumnType("integer"); + + b.Property("RemoteUrl") + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("LocalFileId"); + + b.ToTable("Attachments"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.CheatInfo", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GameId") + .HasColumnType("integer"); + + b.Property("SourceTeamId") + .HasColumnType("integer"); + + b.Property("SubmissionId") + .HasColumnType("integer"); + + b.Property("SubmitTeamId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("SourceTeamId"); + + b.HasIndex("SubmissionId"); + + b.HasIndex("SubmitTeamId"); + + b.ToTable("CheatInfo"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.Config", b => + { + b.Property("ConfigKey") + .HasColumnType("text"); + + b.Property("Value") + .HasColumnType("text"); + + b.HasKey("ConfigKey"); + + b.ToTable("Configs"); + }); + + modelBuilder.Entity("GZCTF.Models.FlagContext", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AttachmentId") + .HasColumnType("integer"); + + b.Property("ChallengeId") + .HasColumnType("integer"); + + b.Property("Flag") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsOccupied") + .HasColumnType("boolean"); + + b.HasKey("Id"); + + b.HasIndex("AttachmentId"); + + b.HasIndex("ChallengeId"); + + b.ToTable("FlagContexts"); + }); + + modelBuilder.Entity("GZCTF.Models.Game", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AcceptWithoutReview") + .HasColumnType("boolean"); + + b.Property("BloodBonus") + .ValueGeneratedOnAdd() + .HasColumnType("bigint") + .HasDefaultValue(0L); + + b.Property("ContainerCountLimit") + .HasColumnType("integer"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("EndTimeUTC") + .HasColumnType("timestamp with time zone") + .HasAnnotation("Relational:JsonPropertyName", "end"); + + b.Property("Hidden") + .HasColumnType("boolean"); + + b.Property("InviteCode") + .HasColumnType("text"); + + b.Property("Organizations") + .HasColumnType("text"); + + b.Property("PosterHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PracticeMode") + .HasColumnType("boolean"); + + b.Property("PrivateKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("text"); + + b.Property("StartTimeUTC") + .HasColumnType("timestamp with time zone") + .HasAnnotation("Relational:JsonPropertyName", "start"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("TeamMemberCountLimit") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("WriteupDeadline") + .HasColumnType("timestamp with time zone") + .HasAnnotation("Relational:JsonPropertyName", "wpddl"); + + b.Property("WriteupNote") + .IsRequired() + .HasColumnType("text") + .HasAnnotation("Relational:JsonPropertyName", "wpnote"); + + b.HasKey("Id"); + + b.ToTable("Games"); + }); + + modelBuilder.Entity("GZCTF.Models.GameEvent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameId") + .HasColumnType("integer"); + + b.Property("PublishTimeUTC") + .HasColumnType("timestamp with time zone") + .HasAnnotation("Relational:JsonPropertyName", "time"); + + b.Property("TeamId") + .HasColumnType("integer"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.Property("UserId") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId"); + + b.ToTable("GameEvents"); + }); + + modelBuilder.Entity("GZCTF.Models.GameNotice", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("GameId") + .HasColumnType("integer"); + + b.Property("PublishTimeUTC") + .HasColumnType("timestamp with time zone") + .HasAnnotation("Relational:JsonPropertyName", "time"); + + b.Property("Type") + .HasColumnType("smallint"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.ToTable("GameNotices"); + }); + + modelBuilder.Entity("GZCTF.Models.Instance", b => + { + b.Property("ChallengeId") + .HasColumnType("integer"); + + b.Property("ParticipationId") + .HasColumnType("integer"); + + b.Property("ContainerId") + .HasColumnType("text"); + + b.Property("FlagId") + .HasColumnType("integer"); + + b.Property("IsLoaded") + .HasColumnType("boolean"); + + b.Property("IsSolved") + .HasColumnType("boolean"); + + b.Property("LastContainerOperation") + .HasColumnType("timestamp with time zone"); + + b.Property("Score") + .HasColumnType("integer"); + + b.HasKey("ChallengeId", "ParticipationId"); + + b.HasIndex("ContainerId") + .IsUnique(); + + b.HasIndex("FlagId") + .IsUnique(); + + b.HasIndex("ParticipationId"); + + b.ToTable("Instances"); + }); + + modelBuilder.Entity("GZCTF.Models.LocalFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FileSize") + .HasColumnType("bigint"); + + b.Property("Hash") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ReferenceCount") + .HasColumnType("bigint"); + + b.Property("UploadTimeUTC") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Hash"); + + b.ToTable("Files"); + }); + + modelBuilder.Entity("GZCTF.Models.LogModel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Exception") + .HasColumnType("text"); + + b.Property("Level") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("Logger") + .IsRequired() + .HasMaxLength(250) + .HasColumnType("character varying(250)"); + + b.Property("Message") + .IsRequired() + .HasColumnType("text"); + + b.Property("RemoteIP") + .HasMaxLength(25) + .HasColumnType("character varying(25)"); + + b.Property("Status") + .HasMaxLength(20) + .HasColumnType("character varying(20)"); + + b.Property("TimeUTC") + .HasColumnType("timestamp with time zone"); + + b.Property("UserName") + .HasMaxLength(25) + .HasColumnType("character varying(25)"); + + b.HasKey("Id"); + + b.ToTable("Logs"); + }); + + modelBuilder.Entity("GZCTF.Models.Participation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("GameId") + .HasColumnType("integer"); + + b.Property("Organization") + .HasColumnType("text"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("TeamId") + .HasColumnType("integer"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.Property("WriteupId") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("GameId"); + + b.HasIndex("TeamId"); + + b.HasIndex("WriteupId"); + + b.HasIndex("TeamId", "GameId"); + + b.ToTable("Participations"); + }); + + modelBuilder.Entity("GZCTF.Models.Post", b => + { + b.Property("Id") + .HasMaxLength(8) + .HasColumnType("character varying(8)"); + + b.Property("AuthorId") + .HasColumnType("text"); + + b.Property("Content") + .IsRequired() + .HasColumnType("text"); + + b.Property("IsPinned") + .HasColumnType("boolean"); + + b.Property("Summary") + .IsRequired() + .HasColumnType("text"); + + b.Property("Tags") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdateTimeUTC") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.ToTable("Posts"); + }); + + modelBuilder.Entity("GZCTF.Models.Submission", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Answer") + .IsRequired() + .HasMaxLength(127) + .HasColumnType("character varying(127)"); + + b.Property("ChallengeId") + .HasColumnType("integer"); + + b.Property("GameId") + .HasColumnType("integer"); + + b.Property("ParticipationId") + .HasColumnType("integer"); + + b.Property("Status") + .IsRequired() + .HasColumnType("text"); + + b.Property("SubmitTimeUTC") + .HasColumnType("timestamp with time zone") + .HasAnnotation("Relational:JsonPropertyName", "time"); + + b.Property("TeamId") + .HasColumnType("integer"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ChallengeId"); + + b.HasIndex("GameId"); + + b.HasIndex("ParticipationId"); + + b.HasIndex("Status"); + + b.HasIndex("UserId"); + + b.HasIndex("TeamId", "ChallengeId", "GameId"); + + b.ToTable("Submissions"); + }); + + modelBuilder.Entity("GZCTF.Models.Team", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("AvatarHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Bio") + .HasMaxLength(31) + .HasColumnType("character varying(31)"); + + b.Property("CaptainId") + .IsRequired() + .HasColumnType("text"); + + b.Property("InviteToken") + .IsRequired() + .HasColumnType("text"); + + b.Property("Locked") + .HasColumnType("boolean"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(15) + .HasColumnType("character varying(15)"); + + b.HasKey("Id"); + + b.HasIndex("CaptainId"); + + b.ToTable("Teams"); + }); + + modelBuilder.Entity("GZCTF.Models.UserInfo", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("AccessFailedCount") + .HasColumnType("integer"); + + b.Property("AvatarHash") + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("Bio") + .IsRequired() + .HasMaxLength(63) + .HasColumnType("character varying(63)"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("IP") + .IsRequired() + .HasColumnType("text"); + + b.Property("LastSignedInUTC") + .HasColumnType("timestamp with time zone"); + + b.Property("LastVisitedUTC") + .HasColumnType("timestamp with time zone"); + + 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("RealName") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)"); + + b.Property("RegisterTimeUTC") + .HasColumnType("timestamp with time zone"); + + b.Property("Role") + .HasColumnType("integer"); + + b.Property("SecurityStamp") + .HasColumnType("text"); + + b.Property("StdNumber") + .IsRequired() + .HasMaxLength(31) + .HasColumnType("character varying(31)"); + + b.Property("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property("UserName") + .HasMaxLength(16) + .HasColumnType("character varying(16)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("GZCTF.Models.UserParticipation", b => + { + b.Property("GameId") + .HasColumnType("integer"); + + b.Property("TeamId") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("text"); + + b.Property("ParticipationId") + .HasColumnType("integer"); + + b.HasKey("GameId", "TeamId", "UserId"); + + b.HasIndex("ParticipationId"); + + b.HasIndex("TeamId"); + + b.HasIndex("UserId", "GameId") + .IsUnique(); + + b.ToTable("UserParticipations"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.DataProtection.EntityFrameworkCore.DataProtectionKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("FriendlyName") + .HasColumnType("text"); + + b.Property("Xml") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("DataProtectionKeys"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .HasColumnType("text"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + 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("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") + .IsRequired() + .HasColumnType("text"); + + 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") + .IsRequired() + .HasColumnType("text"); + + 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") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("text"); + + b.Property("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("text"); + + 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("TeamUserInfo", b => + { + b.Property("MembersId") + .HasColumnType("text"); + + b.Property("TeamsId") + .HasColumnType("integer"); + + b.HasKey("MembersId", "TeamsId"); + + b.HasIndex("TeamsId"); + + b.ToTable("TeamUserInfo"); + }); + + modelBuilder.Entity("GZCTF.Models.Challenge", b => + { + b.HasOne("GZCTF.Models.Data.Attachment", "Attachment") + .WithMany() + .HasForeignKey("AttachmentId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GZCTF.Models.Game", "Game") + .WithMany("Challenges") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Container", "TestContainer") + .WithMany() + .HasForeignKey("TestContainerId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Attachment"); + + b.Navigation("Game"); + + b.Navigation("TestContainer"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.Attachment", b => + { + b.HasOne("GZCTF.Models.LocalFile", "LocalFile") + .WithMany() + .HasForeignKey("LocalFileId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("LocalFile"); + }); + + modelBuilder.Entity("GZCTF.Models.Data.CheatInfo", b => + { + b.HasOne("GZCTF.Models.Game", "Game") + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Participation", "SourceTeam") + .WithMany() + .HasForeignKey("SourceTeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Submission", "Submission") + .WithMany() + .HasForeignKey("SubmissionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Participation", "SubmitTeam") + .WithMany() + .HasForeignKey("SubmitTeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + + b.Navigation("SourceTeam"); + + b.Navigation("Submission"); + + b.Navigation("SubmitTeam"); + }); + + modelBuilder.Entity("GZCTF.Models.FlagContext", b => + { + b.HasOne("GZCTF.Models.Data.Attachment", "Attachment") + .WithMany() + .HasForeignKey("AttachmentId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GZCTF.Models.Challenge", "Challenge") + .WithMany("Flags") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Attachment"); + + b.Navigation("Challenge"); + }); + + modelBuilder.Entity("GZCTF.Models.GameEvent", b => + { + b.HasOne("GZCTF.Models.Game", "Game") + .WithMany("GameEvents") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Team", "Team") + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.UserInfo", "User") + .WithMany() + .HasForeignKey("UserId"); + + b.Navigation("Game"); + + b.Navigation("Team"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GZCTF.Models.GameNotice", b => + { + b.HasOne("GZCTF.Models.Game", "Game") + .WithMany("GameNotices") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + }); + + modelBuilder.Entity("GZCTF.Models.Instance", b => + { + b.HasOne("GZCTF.Models.Challenge", "Challenge") + .WithMany("Instances") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Container", "Container") + .WithOne("Instance") + .HasForeignKey("GZCTF.Models.Instance", "ContainerId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GZCTF.Models.FlagContext", "FlagContext") + .WithMany() + .HasForeignKey("FlagId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("GZCTF.Models.Participation", "Participation") + .WithMany("Instances") + .HasForeignKey("ParticipationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Challenge"); + + b.Navigation("Container"); + + b.Navigation("FlagContext"); + + b.Navigation("Participation"); + }); + + modelBuilder.Entity("GZCTF.Models.Participation", b => + { + b.HasOne("GZCTF.Models.Game", "Game") + .WithMany("Participations") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Team", "Team") + .WithMany("Participations") + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.LocalFile", "Writeup") + .WithMany() + .HasForeignKey("WriteupId"); + + b.Navigation("Game"); + + b.Navigation("Team"); + + b.Navigation("Writeup"); + }); + + modelBuilder.Entity("GZCTF.Models.Post", b => + { + b.HasOne("GZCTF.Models.UserInfo", "Author") + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("GZCTF.Models.Submission", b => + { + b.HasOne("GZCTF.Models.Challenge", "Challenge") + .WithMany("Submissions") + .HasForeignKey("ChallengeId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Game", "Game") + .WithMany("Submissions") + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Participation", "Participation") + .WithMany("Submissions") + .HasForeignKey("ParticipationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Team", "Team") + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.UserInfo", "User") + .WithMany("Submissions") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + + b.Navigation("Challenge"); + + b.Navigation("Game"); + + b.Navigation("Participation"); + + b.Navigation("Team"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("GZCTF.Models.Team", b => + { + b.HasOne("GZCTF.Models.UserInfo", "Captain") + .WithMany() + .HasForeignKey("CaptainId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Captain"); + }); + + modelBuilder.Entity("GZCTF.Models.UserParticipation", b => + { + b.HasOne("GZCTF.Models.Game", "Game") + .WithMany() + .HasForeignKey("GameId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Participation", "Participation") + .WithMany("Members") + .HasForeignKey("ParticipationId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Team", "Team") + .WithMany() + .HasForeignKey("TeamId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.UserInfo", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Game"); + + b.Navigation("Participation"); + + b.Navigation("Team"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("GZCTF.Models.UserInfo", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("GZCTF.Models.UserInfo", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.UserInfo", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("GZCTF.Models.UserInfo", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("TeamUserInfo", b => + { + b.HasOne("GZCTF.Models.UserInfo", null) + .WithMany() + .HasForeignKey("MembersId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("GZCTF.Models.Team", null) + .WithMany() + .HasForeignKey("TeamsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("GZCTF.Models.Challenge", b => + { + b.Navigation("Flags"); + + b.Navigation("Instances"); + + b.Navigation("Submissions"); + }); + + modelBuilder.Entity("GZCTF.Models.Container", b => + { + b.Navigation("Instance"); + }); + + modelBuilder.Entity("GZCTF.Models.Game", b => + { + b.Navigation("Challenges"); + + b.Navigation("GameEvents"); + + b.Navigation("GameNotices"); + + b.Navigation("Participations"); + + b.Navigation("Submissions"); + }); + + modelBuilder.Entity("GZCTF.Models.Participation", b => + { + b.Navigation("Instances"); + + b.Navigation("Members"); + + b.Navigation("Submissions"); + }); + + modelBuilder.Entity("GZCTF.Models.Team", b => + { + b.Navigation("Participations"); + }); + + modelBuilder.Entity("GZCTF.Models.UserInfo", b => + { + b.Navigation("Submissions"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/GZCTF/Migrations/20230816021558_AddCaptureTraffic.cs b/src/GZCTF/Migrations/20230816021558_AddCaptureTraffic.cs new file mode 100644 index 000000000..217ee9596 --- /dev/null +++ b/src/GZCTF/Migrations/20230816021558_AddCaptureTraffic.cs @@ -0,0 +1,39 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace GZCTF.Migrations +{ + /// + public partial class AddTrafficCapture : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "PrivilegedContainer", + table: "Challenges"); + + migrationBuilder.AddColumn( + name: "EnableTrafficCapture", + table: "Challenges", + type: "boolean", + nullable: false, + defaultValue: false); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "EnableTrafficCapture", + table: "Challenges"); + + migrationBuilder.AddColumn( + name: "PrivilegedContainer", + table: "Challenges", + type: "boolean", + nullable: true); + } + } +} diff --git a/src/GZCTF/Migrations/AppDbContextModelSnapshot.cs b/src/GZCTF/Migrations/AppDbContextModelSnapshot.cs index a6f561ba6..bca1a247a 100644 --- a/src/GZCTF/Migrations/AppDbContextModelSnapshot.cs +++ b/src/GZCTF/Migrations/AppDbContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "7.0.8") + .HasAnnotation("ProductVersion", "7.0.10") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -39,6 +39,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("CPUCount") .HasColumnType("integer"); + b.Property("EnableTrafficCapture") + .HasColumnType("boolean"); + b.Property("ConcurrencyStamp") .IsConcurrencyToken() .HasColumnType("uuid"); @@ -80,9 +83,6 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("OriginalScore") .HasColumnType("integer"); - b.Property("PrivilegedContainer") - .HasColumnType("boolean"); - b.Property("StorageLimit") .HasColumnType("integer"); diff --git a/src/GZCTF/Models/Data/Challenge.cs b/src/GZCTF/Models/Data/Challenge.cs index 07bce6e17..32591e62d 100644 --- a/src/GZCTF/Models/Data/Challenge.cs +++ b/src/GZCTF/Models/Data/Challenge.cs @@ -83,7 +83,7 @@ public class Challenge /// /// 是否需要记录访问流量 /// - public bool CaptureTraffic { get; set; } = false; + public bool EnableTrafficCapture { get; set; } = false; /// /// 解决题目人数 @@ -249,7 +249,7 @@ internal Challenge Update(ChallengeUpdateModel model) MemoryLimit = model.MemoryLimit ?? MemoryLimit; StorageLimit = model.StorageLimit ?? StorageLimit; ContainerImage = model.ContainerImage?.Trim() ?? ContainerImage; - CaptureTraffic = model.CaptureTraffic ?? CaptureTraffic; + EnableTrafficCapture = model.EnableTrafficCapture ?? EnableTrafficCapture; ContainerExposePort = model.ContainerExposePort ?? ContainerExposePort; OriginalScore = model.OriginalScore ?? OriginalScore; MinScoreRate = model.MinScoreRate ?? MinScoreRate; diff --git a/src/GZCTF/Models/Internal/ContainerConfig.cs b/src/GZCTF/Models/Internal/ContainerConfig.cs index df44543f1..3caf52160 100644 --- a/src/GZCTF/Models/Internal/ContainerConfig.cs +++ b/src/GZCTF/Models/Internal/ContainerConfig.cs @@ -32,7 +32,7 @@ public class ContainerConfig /// /// 是否需要记录访问流量 /// - public bool CaptureTraffic { get; set; } = false; + public bool EnableTrafficCapture { get; set; } = false; /// /// 内存限制(MB) diff --git a/src/GZCTF/Models/Request/Edit/ChallengeEditDetailModel.cs b/src/GZCTF/Models/Request/Edit/ChallengeEditDetailModel.cs index 9102abfdb..e391780fd 100644 --- a/src/GZCTF/Models/Request/Edit/ChallengeEditDetailModel.cs +++ b/src/GZCTF/Models/Request/Edit/ChallengeEditDetailModel.cs @@ -89,7 +89,7 @@ public class ChallengeEditDetailModel /// /// 是否需要记录访问流量 /// - public bool? CaptureTraffic { get; set; } = false; + public bool? EnableTrafficCapture { get; set; } = false; #endregion Container @@ -159,7 +159,7 @@ internal static ChallengeEditDetailModel FromChallenge(Challenge chal) CPUCount = chal.CPUCount, StorageLimit = chal.StorageLimit, ContainerExposePort = chal.ContainerExposePort, - CaptureTraffic = chal.CaptureTraffic, + EnableTrafficCapture = chal.EnableTrafficCapture, OriginalScore = chal.OriginalScore, MinScoreRate = chal.MinScoreRate, Difficulty = chal.Difficulty, diff --git a/src/GZCTF/Models/Request/Edit/ChallengeUpdateModel.cs b/src/GZCTF/Models/Request/Edit/ChallengeUpdateModel.cs index f0e962e5a..2426df43e 100644 --- a/src/GZCTF/Models/Request/Edit/ChallengeUpdateModel.cs +++ b/src/GZCTF/Models/Request/Edit/ChallengeUpdateModel.cs @@ -74,7 +74,7 @@ public class ChallengeUpdateModel /// /// 是否需要记录访问流量 /// - public bool? CaptureTraffic { get; set; } = false; + public bool? EnableTrafficCapture { get; set; } = false; #endregion Container diff --git a/src/GZCTF/Repositories/ContainerRepository.cs b/src/GZCTF/Repositories/ContainerRepository.cs index 4e60c9fe3..3016b6446 100644 --- a/src/GZCTF/Repositories/ContainerRepository.cs +++ b/src/GZCTF/Repositories/ContainerRepository.cs @@ -13,7 +13,9 @@ public ContainerRepository(AppDbContext context) : base(context) public override Task CountAsync(CancellationToken token = default) => _context.Containers.CountAsync(token); public Task GetContainerById(string guid, CancellationToken token = default) - => _context.Containers.Include(c => c.Instance).FirstOrDefaultAsync(i => i.Id == guid, token); + => _context.Containers.Include(c => c.Instance) + .ThenInclude(i => i!.Challenge) + .FirstOrDefaultAsync(i => i.Id == guid, token); public Task> GetContainers(CancellationToken token = default) => _context.Containers.ToListAsync(token); diff --git a/src/GZCTF/Repositories/InstanceRepository.cs b/src/GZCTF/Repositories/InstanceRepository.cs index a45c4f5fe..53e4b3330 100644 --- a/src/GZCTF/Repositories/InstanceRepository.cs +++ b/src/GZCTF/Repositories/InstanceRepository.cs @@ -175,7 +175,7 @@ public async Task> CreateContainer(Instance instance, Team CPUCount = instance.Challenge.CPUCount ?? 1, MemoryLimit = instance.Challenge.MemoryLimit ?? 64, StorageLimit = instance.Challenge.StorageLimit ?? 256, - CaptureTraffic = instance.Challenge.CaptureTraffic, + EnableTrafficCapture = instance.Challenge.EnableTrafficCapture, ExposedPort = instance.Challenge.ContainerExposePort ?? throw new ArgumentException("创建容器时遇到无效的端口"), }, token); From 0e77927e75b05527b0de6125077170c501385d53 Mon Sep 17 00:00:00 2001 From: GZTime Date: Wed, 16 Aug 2023 15:52:11 +0800 Subject: [PATCH 26/67] Update src/GZCTF/Utils/CapturableNetworkStream.cs Co-authored-by: Steve --- src/GZCTF/Utils/CapturableNetworkStream.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GZCTF/Utils/CapturableNetworkStream.cs b/src/GZCTF/Utils/CapturableNetworkStream.cs index 7a63b0c4c..5ea66c0f7 100644 --- a/src/GZCTF/Utils/CapturableNetworkStream.cs +++ b/src/GZCTF/Utils/CapturableNetworkStream.cs @@ -32,7 +32,7 @@ public class CapturableNetworkStreamOptions public bool EnableCapture { get; set; } = false; } -public class CapturableNetworkStream : NetworkStream +public sealed class CapturableNetworkStream : NetworkStream { private readonly CapturableNetworkStreamOptions _options; private readonly CaptureFileWriterDevice? _device = null; From 1f7f9b28155df87d0cfb042906c9093da6c56b87 Mon Sep 17 00:00:00 2001 From: GZTime Date: Wed, 16 Aug 2023 15:52:20 +0800 Subject: [PATCH 27/67] Update src/GZCTF/Controllers/ProxyController.cs Co-authored-by: Steve --- src/GZCTF/Controllers/ProxyController.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/GZCTF/Controllers/ProxyController.cs b/src/GZCTF/Controllers/ProxyController.cs index 514a35c54..459b7943a 100644 --- a/src/GZCTF/Controllers/ProxyController.cs +++ b/src/GZCTF/Controllers/ProxyController.cs @@ -80,7 +80,7 @@ public async Task ProxyForInstance(string id, CancellationToken t try { IPEndPoint ipEndPoint = new(ipAddress, container.Port); - var socket = new Socket(ipEndPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + using var socket = new Socket(ipEndPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp); await socket.ConnectAsync(ipEndPoint, token); if (!socket.Connected) From d25fff9bd11f2ee92e36c5aa3b093250eec987aa Mon Sep 17 00:00:00 2001 From: GZTime Date: Wed, 16 Aug 2023 15:52:29 +0800 Subject: [PATCH 28/67] Update src/GZCTF/Controllers/ProxyController.cs Co-authored-by: Steve --- src/GZCTF/Controllers/ProxyController.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/GZCTF/Controllers/ProxyController.cs b/src/GZCTF/Controllers/ProxyController.cs index 459b7943a..cf424bb88 100644 --- a/src/GZCTF/Controllers/ProxyController.cs +++ b/src/GZCTF/Controllers/ProxyController.cs @@ -147,8 +147,6 @@ public async Task ProxyForInstance(string id, CancellationToken t var status = await ws.ReceiveAsync(buffer, ct); if (status.CloseStatus.HasValue) { - stream.Close(); - cts.Cancel(); break; } if (status.Count > 0) From 7693192f4d050ff2bb80e49aba3dd3d7d4c1e667 Mon Sep 17 00:00:00 2001 From: GZTime Date: Wed, 16 Aug 2023 15:52:38 +0800 Subject: [PATCH 29/67] Update src/GZCTF/Controllers/ProxyController.cs Co-authored-by: Steve --- src/GZCTF/Controllers/ProxyController.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/src/GZCTF/Controllers/ProxyController.cs b/src/GZCTF/Controllers/ProxyController.cs index cf424bb88..e6a142b1e 100644 --- a/src/GZCTF/Controllers/ProxyController.cs +++ b/src/GZCTF/Controllers/ProxyController.cs @@ -159,7 +159,6 @@ public async Task ProxyForInstance(string id, CancellationToken t catch (TaskCanceledException) { } finally { - stream.Close(); cts.Cancel(); } }, ct); From 7c29d3eb4fc59737e753fba063601a077253da0e Mon Sep 17 00:00:00 2001 From: GZTime Date: Wed, 16 Aug 2023 15:52:47 +0800 Subject: [PATCH 30/67] Update src/GZCTF/Controllers/ProxyController.cs Co-authored-by: Steve --- src/GZCTF/Controllers/ProxyController.cs | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/GZCTF/Controllers/ProxyController.cs b/src/GZCTF/Controllers/ProxyController.cs index e6a142b1e..85e4e4bc6 100644 --- a/src/GZCTF/Controllers/ProxyController.cs +++ b/src/GZCTF/Controllers/ProxyController.cs @@ -174,8 +174,6 @@ public async Task ProxyForInstance(string id, CancellationToken t if (count == 0) { await ws.CloseAsync(WebSocketCloseStatus.Empty, null, token); - stream.Close(); - cts.Cancel(); break; } rx += (ulong)count; From 79044e2db830abb5495c222ce1d858090f1952c7 Mon Sep 17 00:00:00 2001 From: GZTime Date: Wed, 16 Aug 2023 15:52:54 +0800 Subject: [PATCH 31/67] Update src/GZCTF/Utils/CapturableNetworkStream.cs Co-authored-by: Steve --- src/GZCTF/Utils/CapturableNetworkStream.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/GZCTF/Utils/CapturableNetworkStream.cs b/src/GZCTF/Utils/CapturableNetworkStream.cs index 5ea66c0f7..803445434 100644 --- a/src/GZCTF/Utils/CapturableNetworkStream.cs +++ b/src/GZCTF/Utils/CapturableNetworkStream.cs @@ -102,9 +102,9 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, Cancella await base.WriteAsync(buffer, cancellationToken); } - public override void Close() + public override void Dispose() { - base.Close(); + base.Dispose(); _device?.Close(); } } From be960354272ec9f1f0f24f629b2e658282b8b348 Mon Sep 17 00:00:00 2001 From: GZTime Date: Wed, 16 Aug 2023 15:53:21 +0800 Subject: [PATCH 32/67] Update src/GZCTF/Controllers/ProxyController.cs Co-authored-by: Steve --- src/GZCTF/Controllers/ProxyController.cs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/GZCTF/Controllers/ProxyController.cs b/src/GZCTF/Controllers/ProxyController.cs index 85e4e4bc6..012fc94f5 100644 --- a/src/GZCTF/Controllers/ProxyController.cs +++ b/src/GZCTF/Controllers/ProxyController.cs @@ -117,6 +117,8 @@ public async Task ProxyForInstance(string id, CancellationToken t finally { await DecrementConnectionCount(id); + stream.Dispose(); + ws.Dispose(); } return new EmptyResult(); From f6af5b38dd6bf5514ff9db82b829c07e339088b7 Mon Sep 17 00:00:00 2001 From: GZTime Date: Wed, 16 Aug 2023 16:09:42 +0800 Subject: [PATCH 33/67] wip: update proxy --- src/GZCTF/Controllers/ProxyController.cs | 18 +++----------- src/GZCTF/Middlewares/RateLimiter.cs | 29 ++++++++-------------- src/GZCTF/Program.cs | 3 ++- src/GZCTF/Utils/CapturableNetworkStream.cs | 4 +-- 4 files changed, 18 insertions(+), 36 deletions(-) diff --git a/src/GZCTF/Controllers/ProxyController.cs b/src/GZCTF/Controllers/ProxyController.cs index 012fc94f5..39ac34b00 100644 --- a/src/GZCTF/Controllers/ProxyController.cs +++ b/src/GZCTF/Controllers/ProxyController.cs @@ -117,7 +117,7 @@ public async Task ProxyForInstance(string id, CancellationToken t finally { await DecrementConnectionCount(id); - stream.Dispose(); + stream.Close(); ws.Dispose(); } @@ -147,10 +147,7 @@ public async Task ProxyForInstance(string id, CancellationToken t while (true) { var status = await ws.ReceiveAsync(buffer, ct); - if (status.CloseStatus.HasValue) - { - break; - } + if (status.CloseStatus.HasValue) break; if (status.Count > 0) { tx += (ulong)status.Count; @@ -159,10 +156,7 @@ public async Task ProxyForInstance(string id, CancellationToken t } } catch (TaskCanceledException) { } - finally - { - cts.Cancel(); - } + finally { cts.Cancel(); } }, ct); var receiver = Task.Run(async () => @@ -183,11 +177,7 @@ public async Task ProxyForInstance(string id, CancellationToken t } } catch (TaskCanceledException) { } - finally - { - stream.Close(); - cts.Cancel(); - } + finally { cts.Cancel(); } }, ct); await Task.WhenAny(sender, receiver); diff --git a/src/GZCTF/Middlewares/RateLimiter.cs b/src/GZCTF/Middlewares/RateLimiter.cs index 64d841e2c..ed9b014cc 100644 --- a/src/GZCTF/Middlewares/RateLimiter.cs +++ b/src/GZCTF/Middlewares/RateLimiter.cs @@ -56,28 +56,19 @@ public static RateLimiterOptions GetRateLimiterOptions() }); } - string? remoteIPaddress = context?.Connection?.RemoteIpAddress?.ToString(); + var address = context?.Connection?.RemoteIpAddress; - if (context is not null && context.Request.Headers.TryGetValue("X-Forwarded-For", out var value)) - { - // note that we consider the previous reverse proxy server credible - remoteIPaddress = value.ToString().Split(',', StringSplitOptions.RemoveEmptyEntries).FirstOrDefault(); - } + if (address is null || IPAddress.IsLoopback(address)) + return RateLimitPartition.GetNoLimiter(IPAddress.Loopback.ToString()); - if (remoteIPaddress is not null && IPAddress.TryParse(remoteIPaddress, out IPAddress? address)) + return RateLimitPartition.GetSlidingWindowLimiter(address.ToString(), key => new() { - if (!IPAddress.IsLoopback(address)) - return RateLimitPartition.GetSlidingWindowLimiter(remoteIPaddress, key => new() - { - PermitLimit = 150, - Window = TimeSpan.FromMinutes(1), - QueueLimit = 60, - QueueProcessingOrder = QueueProcessingOrder.OldestFirst, - SegmentsPerWindow = 6, - }); - } - - return RateLimitPartition.GetNoLimiter(IPAddress.Loopback.ToString()); + PermitLimit = 150, + Window = TimeSpan.FromMinutes(1), + QueueLimit = 60, + QueueProcessingOrder = QueueProcessingOrder.OldestFirst, + SegmentsPerWindow = 6, + }); }), OnRejected = (context, cancellationToken) => { diff --git a/src/GZCTF/Program.cs b/src/GZCTF/Program.cs index 7b9afea7f..640db6e6f 100644 --- a/src/GZCTF/Program.cs +++ b/src/GZCTF/Program.cs @@ -173,7 +173,8 @@ .AddDefaultTokenProviders(); builder.Services.Configure(o => - o.TokenLifespan = TimeSpan.FromHours(3)); + o.TokenLifespan = TimeSpan.FromHours(3) +); #endregion Identity diff --git a/src/GZCTF/Utils/CapturableNetworkStream.cs b/src/GZCTF/Utils/CapturableNetworkStream.cs index 803445434..5ea66c0f7 100644 --- a/src/GZCTF/Utils/CapturableNetworkStream.cs +++ b/src/GZCTF/Utils/CapturableNetworkStream.cs @@ -102,9 +102,9 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, Cancella await base.WriteAsync(buffer, cancellationToken); } - public override void Dispose() + public override void Close() { - base.Dispose(); + base.Close(); _device?.Close(); } } From d39df384e89a54420af95efa737969d13eedbcbb Mon Sep 17 00:00:00 2001 From: GZTime Date: Wed, 16 Aug 2023 17:03:51 +0800 Subject: [PATCH 34/67] wip: add FilePath class --- src/GZCTF/Controllers/AdminController.cs | 4 +- src/GZCTF/Controllers/AssetsController.cs | 4 +- src/GZCTF/Controllers/GameController.cs | 16 ++++++ src/GZCTF/Controllers/ProxyController.cs | 3 +- src/GZCTF/Models/Data/Container.cs | 4 +- .../Game/ChallengeTrafficRecordModel.cs | 54 +++++++++++++++++++ src/GZCTF/Program.cs | 13 +---- src/GZCTF/Repositories/ChallengeRepository.cs | 3 ++ .../Interface/IChallengeRepository.cs | 8 +++ src/GZCTF/Utils/FilePath.cs | 37 +++++++++++++ src/GZCTF/Utils/LogHelper.cs | 2 +- src/GZCTF/Utils/Shared.cs | 20 ++++--- 12 files changed, 136 insertions(+), 32 deletions(-) create mode 100644 src/GZCTF/Models/Request/Game/ChallengeTrafficRecordModel.cs create mode 100644 src/GZCTF/Utils/FilePath.cs diff --git a/src/GZCTF/Controllers/AdminController.cs b/src/GZCTF/Controllers/AdminController.cs index a82f05d56..763e56fed 100644 --- a/src/GZCTF/Controllers/AdminController.cs +++ b/src/GZCTF/Controllers/AdminController.cs @@ -26,8 +26,6 @@ namespace GZCTF.Controllers; [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status403Forbidden)] public class AdminController : ControllerBase { - private const string BasePath = "files/uploads"; - private readonly ILogger _logger; private readonly UserManager _userManager; private readonly ILogRepository _logRepository; @@ -522,7 +520,7 @@ public async Task DownloadAllWriteups(int id, CancellationToken t var wps = await _participationRepository.GetWriteups(game, token); var filename = $"Writeups-{game.Title}-{DateTimeOffset.UtcNow:yyyyMMdd-HH.mm.ssZ}"; - var stream = await Codec.ZipFilesAsync(wps.Select(p => p.File), BasePath, filename, token); + var stream = await Codec.ZipFilesAsync(wps.Select(p => p.File), FilePath.Uploads, filename, token); stream.Seek(0, SeekOrigin.Begin); return File(stream, "application/zip", $"{filename}.zip"); diff --git a/src/GZCTF/Controllers/AssetsController.cs b/src/GZCTF/Controllers/AssetsController.cs index c56279ca4..aea12f0e8 100644 --- a/src/GZCTF/Controllers/AssetsController.cs +++ b/src/GZCTF/Controllers/AssetsController.cs @@ -16,8 +16,6 @@ namespace GZCTF.Controllers; [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status403Forbidden)] public class AssetsController : ControllerBase { - private const string BasePath = "files/uploads"; - private readonly ILogger _logger; private readonly IFileRepository _fileRepository; private readonly FileExtensionContentTypeProvider _extProvider = new(); @@ -44,7 +42,7 @@ public AssetsController(IFileRepository fileService, ILogger l [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status400BadRequest)] public IActionResult GetFile([RegularExpression("[0-9a-f]{64}")] string hash, string filename) { - var path = Path.GetFullPath(Path.Combine(BasePath, hash[..2], hash[2..4], hash)); + var path = Path.GetFullPath(Path.Combine(FilePath.Uploads, hash[..2], hash[2..4], hash)); if (!System.IO.File.Exists(path)) { diff --git a/src/GZCTF/Controllers/GameController.cs b/src/GZCTF/Controllers/GameController.cs index 1df3bec6f..d2356c1e1 100644 --- a/src/GZCTF/Controllers/GameController.cs +++ b/src/GZCTF/Controllers/GameController.cs @@ -389,6 +389,22 @@ public async Task CheatInfo([FromRoute] int id, CancellationToken .Select(CheatInfoModel.FromCheatInfo)); } + /// + /// 获取开启了流量捕获的比赛题目 + /// + /// + /// 获取开启了流量捕获的比赛题目,需要Monitor权限 + /// + /// 比赛Id + /// + /// 成功获取比赛题目 + [RequireMonitor] + [HttpGet("Games/{id}/Captures")] + [ProducesResponseType(typeof(ChallengeInfoModel[]), StatusCodes.Status200OK)] + public async Task GetGameChallenges([FromRoute] int id, CancellationToken token) + => Ok((await _challengeRepository.GetChallengesWithTrafficCapturing(id, token)) + .Select(ChallengeTrafficRecordModel.FromChallenge)); + /// /// 获取全部比赛题目信息及当前队伍信息 /// diff --git a/src/GZCTF/Controllers/ProxyController.cs b/src/GZCTF/Controllers/ProxyController.cs index 39ac34b00..bf600754f 100644 --- a/src/GZCTF/Controllers/ProxyController.cs +++ b/src/GZCTF/Controllers/ProxyController.cs @@ -147,7 +147,8 @@ public async Task ProxyForInstance(string id, CancellationToken t while (true) { var status = await ws.ReceiveAsync(buffer, ct); - if (status.CloseStatus.HasValue) break; + if (status.CloseStatus.HasValue) + break; if (status.Count > 0) { tx += (ulong)status.Count; diff --git a/src/GZCTF/Models/Data/Container.cs b/src/GZCTF/Models/Data/Container.cs index 1aeac6fea..36a6f23d3 100644 --- a/src/GZCTF/Models/Data/Container.cs +++ b/src/GZCTF/Models/Data/Container.cs @@ -1,5 +1,6 @@ using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations.Schema; +using GZCTF.Utils; namespace GZCTF.Models; @@ -76,7 +77,8 @@ public class Container /// 容器实例流量捕获存储路径 /// public string TrafficPath(string conn) => Instance is null ? string.Empty : - $"files/capture/{Instance.ChallengeId}/{Instance.ParticipationId}/{DateTimeOffset.Now:s}-{conn}.pcap"; + Path.Combine(FilePath.Capture, + $"{Instance.ChallengeId}/{Instance.ParticipationId}/{DateTimeOffset.Now:s}-{conn}.pcap"); #region Db Relationship diff --git a/src/GZCTF/Models/Request/Game/ChallengeTrafficRecordModel.cs b/src/GZCTF/Models/Request/Game/ChallengeTrafficRecordModel.cs new file mode 100644 index 000000000..be458fe9c --- /dev/null +++ b/src/GZCTF/Models/Request/Game/ChallengeTrafficRecordModel.cs @@ -0,0 +1,54 @@ +using System.ComponentModel.DataAnnotations; +using GZCTF.Utils; + +namespace GZCTF.Models.Request.Game; + +public class ChallengeTrafficRecordModel +{ + /// + /// 题目Id + /// + public int Id { get; set; } + + /// + /// 题目名称 + /// + [Required(ErrorMessage = "标题是必需的")] + [MinLength(1, ErrorMessage = "标题过短")] + public string Title { get; set; } = string.Empty; + + /// + /// 题目标签 + /// + public ChallengeTag Tag { get; set; } = ChallengeTag.Misc; + + /// + /// 题目类型 + /// + public ChallengeType Type { get; set; } = ChallengeType.StaticAttachment; + + /// + /// 是否启用题目 + /// + public bool IsEnabled { get; set; } = false; + + /// + /// 题目所捕获到的队伍流量的数量 + /// + public int Count { get; set; } = 0; + + internal static ChallengeTrafficRecordModel FromChallenge(Challenge challenge) + { + string trafficPath = $"{FilePath.Capture}/{challenge.Id}"; + + return new() + { + Id = challenge.Id, + Title = challenge.Title, + Tag = challenge.Tag, + Type = challenge.Type, + IsEnabled = challenge.IsEnabled, + Count = Directory.Exists(trafficPath) ? Directory.GetDirectories(trafficPath).Length : 0 + }; + } +} diff --git a/src/GZCTF/Program.cs b/src/GZCTF/Program.cs index 640db6e6f..73eee5470 100644 --- a/src/GZCTF/Program.cs +++ b/src/GZCTF/Program.cs @@ -26,18 +26,7 @@ Banner(); -#region Directory - -var dirs = new[] { "logs", "uploads", "capture" }; - -foreach (var dir in dirs) -{ - var path = Path.Combine("files", dir); - if (!Directory.Exists(path)) - Directory.CreateDirectory(path); -} - -#endregion Directory +FilePath.EnsureDirs(); #region Logging diff --git a/src/GZCTF/Repositories/ChallengeRepository.cs b/src/GZCTF/Repositories/ChallengeRepository.cs index e9cefec36..d39913080 100644 --- a/src/GZCTF/Repositories/ChallengeRepository.cs +++ b/src/GZCTF/Repositories/ChallengeRepository.cs @@ -73,6 +73,9 @@ public async Task EnsureInstances(Challenge challenge, Game game, Cancella public Task GetChallenges(int gameId, CancellationToken token = default) => _context.Challenges.Where(c => c.GameId == gameId).OrderBy(c => c.Id).ToArrayAsync(token); + public Task GetChallengesWithTrafficCapturing(int gameId, CancellationToken token = default) + => _context.Challenges.IgnoreAutoIncludes().Where(c => c.GameId == gameId && c.EnableTrafficCapture).ToArrayAsync(token); + public async Task RemoveChallenge(Challenge challenge, CancellationToken token = default) { if (challenge.Type == ChallengeType.DynamicAttachment) diff --git a/src/GZCTF/Repositories/Interface/IChallengeRepository.cs b/src/GZCTF/Repositories/Interface/IChallengeRepository.cs index 5bed639c3..4bdf832e3 100644 --- a/src/GZCTF/Repositories/Interface/IChallengeRepository.cs +++ b/src/GZCTF/Repositories/Interface/IChallengeRepository.cs @@ -39,6 +39,14 @@ public interface IChallengeRepository : IRepository /// public Task GetChallenge(int gameId, int id, bool withFlag = false, CancellationToken token = default); + /// + /// 获取全部需要捕获流量的题目 + /// + /// 比赛Id + /// + /// + public Task GetChallengesWithTrafficCapturing(int gameId, CancellationToken token = default); + /// /// 添加 Flag /// diff --git a/src/GZCTF/Utils/FilePath.cs b/src/GZCTF/Utils/FilePath.cs new file mode 100644 index 000000000..d5297702a --- /dev/null +++ b/src/GZCTF/Utils/FilePath.cs @@ -0,0 +1,37 @@ +namespace GZCTF.Utils; + +internal enum DirType : byte +{ + Logs, + Uploads, + Capture +} + +internal static class FilePath +{ + internal const string Base = "files"; + + internal static void EnsureDirs() + { + foreach (DirType type in Enum.GetValues()) + { + string path = Path.Combine(Base, type.ToString().ToLower()); + if (!Directory.Exists(path)) + { + Directory.CreateDirectory(path); + } + } + } + + /// + /// 获取文件夹路径 + /// + /// + /// + internal static string GetDir(DirType type) + => Path.Combine(Base, type.ToString().ToLower()); + + internal static string Logs => GetDir(DirType.Logs); + internal static string Uploads => GetDir(DirType.Uploads); + internal static string Capture => GetDir(DirType.Capture); +} diff --git a/src/GZCTF/Utils/LogHelper.cs b/src/GZCTF/Utils/LogHelper.cs index 4bcf86e67..7b0c5b7a7 100644 --- a/src/GZCTF/Utils/LogHelper.cs +++ b/src/GZCTF/Utils/LogHelper.cs @@ -138,7 +138,7 @@ public static Serilog.ILogger GetLogger(IConfiguration configuration, IServicePr restrictedToMinimumLevel: LogEventLevel.Debug )) .WriteTo.Async(t => t.File( - path: "files/logs/log_.log", + path: $"files/logs/log_.log", formatter: new ExpressionTemplate(LogTemplate), rollingInterval: RollingInterval.Day, fileSizeLimitBytes: 10 * 1024 * 1024, diff --git a/src/GZCTF/Utils/Shared.cs b/src/GZCTF/Utils/Shared.cs index a07a7d46e..2d2b19deb 100644 --- a/src/GZCTF/Utils/Shared.cs +++ b/src/GZCTF/Utils/Shared.cs @@ -117,17 +117,15 @@ public ArrayResponse(T[] array, int? tot = null) /// /// 三血加分 /// -public struct BloodBonus +public struct BloodBonus(long init = BloodBonus.DefaultValue) { public const long DefaultValue = (50 << 20) + (30 << 10) + 10; public const int Mask = 0x3ff; public const int Base = 1000; - public BloodBonus(long init = DefaultValue) => Val = init; - public static BloodBonus Default => new(); - public long Val { get; private set; } = DefaultValue; + public long Val { get; private set; } = init; public static BloodBonus FromValue(long value) { @@ -136,19 +134,19 @@ public static BloodBonus FromValue(long value) return new(value); } - public long FirstBlood => (Val >> 20) & 0x3ff; + public readonly long FirstBlood => (Val >> 20) & 0x3ff; - public float FirstBloodFactor => FirstBlood / 1000f + 1.0f; + public readonly float FirstBloodFactor => FirstBlood / 1000f + 1.0f; - public long SecondBlood => (Val >> 10) & 0x3ff; + public readonly long SecondBlood => (Val >> 10) & 0x3ff; - public float SecondBloodFactor => SecondBlood / 1000f + 1.0f; + public readonly float SecondBloodFactor => SecondBlood / 1000f + 1.0f; - public long ThirdBlood => Val & 0x3ff; + public readonly long ThirdBlood => Val & 0x3ff; - public float ThirdBloodFactor => ThirdBlood / 1000f + 1.0f; + public readonly float ThirdBloodFactor => ThirdBlood / 1000f + 1.0f; - public bool NoBonus => Val == 0; + public readonly bool NoBonus => Val == 0; public static ValueConverter Converter => new(v => v.Val, v => new(v)); From 5be533470a374e69305f8aa58641458eebe9b6c5 Mon Sep 17 00:00:00 2001 From: GZTime Date: Wed, 16 Aug 2023 18:32:03 +0800 Subject: [PATCH 35/67] wip: add traffic record api --- src/GZCTF/Controllers/GameController.cs | 45 +++++++++++- .../Game/ChallengeTrafficRecordModel.cs | 5 +- .../Models/Request/Game/TeamTrafficModel.cs | 71 +++++++++++++++++++ .../Interface/IParticipationRepository.cs | 8 +++ .../Repositories/ParticipationRepository.cs | 5 ++ src/GZCTF/Utils/Shared.cs | 22 ++++++ 6 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 src/GZCTF/Models/Request/Game/TeamTrafficModel.cs diff --git a/src/GZCTF/Controllers/GameController.cs b/src/GZCTF/Controllers/GameController.cs index d2356c1e1..257fcc015 100644 --- a/src/GZCTF/Controllers/GameController.cs +++ b/src/GZCTF/Controllers/GameController.cs @@ -11,6 +11,7 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; +using NPOI.OpenXmlFormats.Dml; namespace GZCTF.Controllers; @@ -401,10 +402,40 @@ public async Task CheatInfo([FromRoute] int id, CancellationToken [RequireMonitor] [HttpGet("Games/{id}/Captures")] [ProducesResponseType(typeof(ChallengeInfoModel[]), StatusCodes.Status200OK)] - public async Task GetGameChallenges([FromRoute] int id, CancellationToken token) + public async Task GetChallengesWithTrafficCapturing([FromRoute] int id, CancellationToken token) => Ok((await _challengeRepository.GetChallengesWithTrafficCapturing(id, token)) .Select(ChallengeTrafficRecordModel.FromChallenge)); + /// + /// 获取开启了比赛题目中捕获到到队伍信息 + /// + /// + /// 获取开启了比赛题目中捕获到到队伍信息,需要Monitor权限 + /// + /// + /// + /// 成功获取比赛题目 + [RequireMonitor] + [HttpGet("Captures/{challengeId}")] + [ProducesResponseType(typeof(TeamTrafficModel[]), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status404NotFound)] + public async Task GetChallengeTraffic([FromRoute] int challengeId, CancellationToken token) + { + var filePath = $"{FilePath.Capture}/{challengeId}"; + + if (!Path.Exists(filePath)) + return NotFound(new RequestResponse("未找到相关捕获信息")); + + var participationIds = await GetDirNamesAsInt(filePath); + + if (participationIds.Count == 0) + return NotFound(new RequestResponse("未找到相关捕获信息")); + + var participations = await _participationRepository.GetParticipationsByIds(participationIds, token); + + return Ok(participations.Select(p => TeamTrafficModel.FromParticipation(p, challengeId))); + } + /// /// 获取全部比赛题目信息及当前队伍信息 /// @@ -975,4 +1006,16 @@ private async Task GetContextInfo(int id, int challengeId = 0, bool return res; } + + private static Task> GetDirNamesAsInt(string dir) + { + if (!Directory.Exists(dir)) + return Task.FromResult(new List()); + + return Task.Run(() => Directory.GetDirectories(dir, "*", SearchOption.TopDirectoryOnly).Select(d => + { + var name = Path.GetFileName(d); + return int.TryParse(name, out var res) ? res : -1; + }).Where(d => d > 0).ToList()); + } } diff --git a/src/GZCTF/Models/Request/Game/ChallengeTrafficRecordModel.cs b/src/GZCTF/Models/Request/Game/ChallengeTrafficRecordModel.cs index be458fe9c..85828cb98 100644 --- a/src/GZCTF/Models/Request/Game/ChallengeTrafficRecordModel.cs +++ b/src/GZCTF/Models/Request/Game/ChallengeTrafficRecordModel.cs @@ -33,7 +33,7 @@ public class ChallengeTrafficRecordModel public bool IsEnabled { get; set; } = false; /// - /// 题目所捕获到的队伍流量的数量 + /// 题目所捕获到的队伍流量数量 /// public int Count { get; set; } = 0; @@ -48,7 +48,8 @@ internal static ChallengeTrafficRecordModel FromChallenge(Challenge challenge) Tag = challenge.Tag, Type = challenge.Type, IsEnabled = challenge.IsEnabled, - Count = Directory.Exists(trafficPath) ? Directory.GetDirectories(trafficPath).Length : 0 + Count = Directory.Exists(trafficPath) ? + Directory.GetDirectories(trafficPath, "*", SearchOption.TopDirectoryOnly).Length : 0 }; } } diff --git a/src/GZCTF/Models/Request/Game/TeamTrafficModel.cs b/src/GZCTF/Models/Request/Game/TeamTrafficModel.cs new file mode 100644 index 000000000..c12db4c0b --- /dev/null +++ b/src/GZCTF/Models/Request/Game/TeamTrafficModel.cs @@ -0,0 +1,71 @@ +using GZCTF.Utils; + +namespace GZCTF.Models; + +/// +/// 队伍流量获取信息 +/// +public class TeamTrafficModel +{ + /// + /// 队伍 Id + /// + public int Id { get; set; } + + /// + /// 参与 Id + /// + public int ParticipationId { get; set; } + + /// + /// 队伍名称 + /// + public string? Name { get; set; } + + /// + /// 队伍签名 + /// + public string? Bio { get; set; } + + /// + /// 头像链接 + /// + public string? Avatar { get; set; } + + /// + /// 题目所捕获到的流量 + /// + public List Records { get; set; } = new(); + + internal static TeamTrafficModel FromParticipation(Participation part, int challengeId) + { + string trafficPath = $"{FilePath.Capture}/{challengeId}/{part.Id}"; + + var records = new List(); + + if (Directory.Exists(trafficPath)) + { + foreach (string file in Directory.EnumerateFiles(trafficPath)) + { + var info = new FileInfo(file)!; + + records.Add(new() + { + FileName = info.Name, + Size = info.Length, + UpdateTime = info.LastAccessTimeUtc + }); + } + } + + return new() + { + Id = part.Team.Id, + Name = part.Team.Name, + Bio = part.Team.Bio, + ParticipationId = part.Id, + Avatar = part.Team.AvatarUrl, + Records = records + }; + } +} diff --git a/src/GZCTF/Repositories/Interface/IParticipationRepository.cs b/src/GZCTF/Repositories/Interface/IParticipationRepository.cs index 9ed8b9c2b..111384e84 100644 --- a/src/GZCTF/Repositories/Interface/IParticipationRepository.cs +++ b/src/GZCTF/Repositories/Interface/IParticipationRepository.cs @@ -21,6 +21,14 @@ public interface IParticipationRepository : IRepository /// public Task GetParticipations(Game game, CancellationToken token = default); + /// + /// 从 Id 数组获取比赛参与列表 + /// + /// + /// + /// + public Task GetParticipationsByIds(IEnumerable ids, CancellationToken token = default); + /// /// 获取比赛 Writeup 列表 /// diff --git a/src/GZCTF/Repositories/ParticipationRepository.cs b/src/GZCTF/Repositories/ParticipationRepository.cs index 634c02c88..fb9b4392a 100644 --- a/src/GZCTF/Repositories/ParticipationRepository.cs +++ b/src/GZCTF/Repositories/ParticipationRepository.cs @@ -88,6 +88,11 @@ public async Task UpdateParticipationStatus(Participation part, ParticipationSta await _gameRepository.FlushScoreboardCache(part.GameId, token); } + public Task GetParticipationsByIds(IEnumerable ids, CancellationToken token = default) + => _context.Participations.Where(p => ids.Contains(p.Id)) + .Include(p => p.Team) + .ToArrayAsync(token); + public async Task RemoveUserParticipations(UserInfo user, Game game, CancellationToken token = default) => _context.RemoveRange(await _context.UserParticipations .Where(p => p.User == user && p.Game == game).ToArrayAsync(token)); diff --git a/src/GZCTF/Utils/Shared.cs b/src/GZCTF/Utils/Shared.cs index 2d2b19deb..2104545a4 100644 --- a/src/GZCTF/Utils/Shared.cs +++ b/src/GZCTF/Utils/Shared.cs @@ -114,6 +114,28 @@ public ArrayResponse(T[] array, int? tot = null) public int Total { get; set; } } +/// +/// 文件记录 +/// +public class FileRecord +{ + /// + /// 文件名 + /// + public string FileName { get; set; } = string.Empty; + + /// + /// 文件大小 + /// + public long Size { get; set; } = 0; + + /// + /// 文件路径 + /// + public DateTimeOffset UpdateTime { get; set; } = DateTimeOffset.Now; +} + + /// /// 三血加分 /// From 43b6bc426969aa73d3a2067ec643269b8a5672df Mon Sep 17 00:00:00 2001 From: GZTime Date: Wed, 16 Aug 2023 19:58:13 +0800 Subject: [PATCH 36/67] wip: add capture file API --- src/GZCTF/ClientApp/src/Api.ts | 347 +++++++++++------- .../ClientApp/template/procedure-call.eta | 3 +- src/GZCTF/Controllers/GameController.cs | 122 +++++- ...ecordModel.cs => ChallengeTrafficModel.cs} | 4 +- .../Models/Request/Game/TeamTrafficModel.cs | 24 +- src/GZCTF/Utils/Codec.cs | 29 ++ src/GZCTF/Utils/FilePath.cs | 30 +- 7 files changed, 393 insertions(+), 166 deletions(-) rename src/GZCTF/Models/Request/Game/{ChallengeTrafficRecordModel.cs => ChallengeTrafficModel.cs} (91%) diff --git a/src/GZCTF/ClientApp/src/Api.ts b/src/GZCTF/ClientApp/src/Api.ts index 96bede538..91a770756 100644 --- a/src/GZCTF/ClientApp/src/Api.ts +++ b/src/GZCTF/ClientApp/src/Api.ts @@ -1446,6 +1446,47 @@ export interface ParticipationModel { organization?: string | null } +/** 队伍流量获取信息 */ +export interface TeamTrafficModel { + /** + * 队伍 Id + * @format int32 + */ + id?: number + /** + * 参与 Id + * @format int32 + */ + participationId?: number + /** 队伍名称 */ + name?: string | null + /** 队伍签名 */ + bio?: string | null + /** 头像链接 */ + avatar?: string | null + /** + * 题目所捕获到的流量数量 + * @format int32 + */ + count?: number +} + +/** 文件记录 */ +export interface FileRecord { + /** 文件名 */ + fileName?: string + /** + * 文件大小 + * @format int64 + */ + size?: number + /** + * 文件路径 + * @format date-time + */ + updateTime?: string +} + export interface GameDetailModel { /** 题目信息 */ challenges?: Record @@ -2081,33 +2122,6 @@ export class Api extends HttpClient useSWR(doFetch ? `/api/admin/writeups/${id}/all` : null, options), - - /** - * @description 使用此接口下载全部 Writeup,需要Admin权限 - * - * @tags Admin - * @name AdminDownloadAllWriteups - * @summary 下载全部 Writeup - * @request GET:/api/admin/writeups/{id}/all - */ - mutateAdminDownloadAllWriteups: ( - id: number, - data?: void | Promise, - options?: MutatorOptions - ) => mutate(`/api/admin/writeups/${id}/all`, data, options), /** * @description 使用此接口获取全部文件,需要Admin权限 @@ -2774,35 +2788,6 @@ export class Api extends HttpClient useSWR(doFetch ? `/assets/${hash}/${filename}` : null, options), - - /** - * @description 根据哈希获取文件,不匹配文件名 - * - * @tags Assets - * @name AssetsGetFile - * @summary 获取文件接口 - * @request GET:/assets/{hash}/{filename} - */ - mutateAssetsGetFile: ( - hash: string, - filename: string, - data?: void | Promise, - options?: MutatorOptions - ) => mutate(`/assets/${hash}/${filename}`, data, options), /** * @description 上传一个或多个文件 @@ -3793,6 +3778,184 @@ export class Api extends HttpClient mutate(`/api/game/${id}/challenges/${challengeId}`, data, options), + /** + * @description 获取开启了流量捕获的比赛题目,需要Monitor权限 + * + * @tags Game + * @name GameGetChallengesWithTrafficCapturing + * @summary 获取开启了流量捕获的比赛题目 + * @request GET:/api/game/games/{id}/captures + */ + gameGetChallengesWithTrafficCapturing: (id: number, params: RequestParams = {}) => + this.request({ + path: `/api/game/games/${id}/captures`, + method: 'GET', + format: 'json', + ...params, + }), + /** + * @description 获取开启了流量捕获的比赛题目,需要Monitor权限 + * + * @tags Game + * @name GameGetChallengesWithTrafficCapturing + * @summary 获取开启了流量捕获的比赛题目 + * @request GET:/api/game/games/{id}/captures + */ + useGameGetChallengesWithTrafficCapturing: ( + id: number, + options?: SWRConfiguration, + doFetch: boolean = true + ) => + useSWR( + doFetch ? `/api/game/games/${id}/captures` : null, + options + ), + + /** + * @description 获取开启了流量捕获的比赛题目,需要Monitor权限 + * + * @tags Game + * @name GameGetChallengesWithTrafficCapturing + * @summary 获取开启了流量捕获的比赛题目 + * @request GET:/api/game/games/{id}/captures + */ + mutateGameGetChallengesWithTrafficCapturing: ( + id: number, + data?: ChallengeInfoModel[] | Promise, + options?: MutatorOptions + ) => mutate(`/api/game/games/${id}/captures`, data, options), + + /** + * @description 获取比赛题目中捕获到到队伍信息,需要Monitor权限 + * + * @tags Game + * @name GameGetChallengeTraffic + * @summary 获取比赛题目中捕获到到队伍信息 + * @request GET:/api/game/captures/{challengeId} + */ + gameGetChallengeTraffic: (challengeId: number, params: RequestParams = {}) => + this.request({ + path: `/api/game/captures/${challengeId}`, + method: 'GET', + format: 'json', + ...params, + }), + /** + * @description 获取比赛题目中捕获到到队伍信息,需要Monitor权限 + * + * @tags Game + * @name GameGetChallengeTraffic + * @summary 获取比赛题目中捕获到到队伍信息 + * @request GET:/api/game/captures/{challengeId} + */ + useGameGetChallengeTraffic: ( + challengeId: number, + options?: SWRConfiguration, + doFetch: boolean = true + ) => + useSWR( + doFetch ? `/api/game/captures/${challengeId}` : null, + options + ), + + /** + * @description 获取比赛题目中捕获到到队伍信息,需要Monitor权限 + * + * @tags Game + * @name GameGetChallengeTraffic + * @summary 获取比赛题目中捕获到到队伍信息 + * @request GET:/api/game/captures/{challengeId} + */ + mutateGameGetChallengeTraffic: ( + challengeId: number, + data?: TeamTrafficModel[] | Promise, + options?: MutatorOptions + ) => mutate(`/api/game/captures/${challengeId}`, data, options), + + /** + * @description 获取流量包文件,需要Monitor权限 + * + * @tags Game + * @name GameGetTeamTraffic + * @summary 获取流量包文件 + * @request GET:/api/game/captures/{challengeId}/{partId}/{filename} + */ + gameGetTeamTraffic: ( + challengeId: number, + partId: number, + filename: string, + params: RequestParams = {} + ) => + this.request({ + path: `/api/game/captures/${challengeId}/${partId}/${filename}`, + method: 'GET', + ...params, + }), + + /** + * @description 获取比赛题目中捕获到到队伍的流量包列表,需要Monitor权限 + * + * @tags Game + * @name GameGetTeamTrafficAll + * @summary 获取比赛题目中捕获到到队伍的流量包列表 + * @request GET:/api/game/captures/{challengeId}/{partId} + */ + gameGetTeamTrafficAll: (challengeId: number, partId: number, params: RequestParams = {}) => + this.request({ + path: `/api/game/captures/${challengeId}/${partId}`, + method: 'GET', + format: 'json', + ...params, + }), + /** + * @description 获取比赛题目中捕获到到队伍的流量包列表,需要Monitor权限 + * + * @tags Game + * @name GameGetTeamTrafficAll + * @summary 获取比赛题目中捕获到到队伍的流量包列表 + * @request GET:/api/game/captures/{challengeId}/{partId} + */ + useGameGetTeamTrafficAll: ( + challengeId: number, + partId: number, + options?: SWRConfiguration, + doFetch: boolean = true + ) => + useSWR( + doFetch ? `/api/game/captures/${challengeId}/${partId}` : null, + options + ), + + /** + * @description 获取比赛题目中捕获到到队伍的流量包列表,需要Monitor权限 + * + * @tags Game + * @name GameGetTeamTrafficAll + * @summary 获取比赛题目中捕获到到队伍的流量包列表 + * @request GET:/api/game/captures/{challengeId}/{partId} + */ + mutateGameGetTeamTrafficAll: ( + challengeId: number, + partId: number, + data?: FileRecord[] | Promise, + options?: MutatorOptions + ) => mutate(`/api/game/captures/${challengeId}/${partId}`, data, options), + + /** + * @description 获取流量包文件,需要Monitor权限 + * + * @tags Game + * @name GameGetTeamTrafficZip + * @summary 获取流量包文件压缩包 + * @request GET:/api/game/captures/{challengeId}/{partId}/all + */ + gameGetTeamTrafficZip: (challengeId: number, partId: number, params: RequestParams = {}) => + this.request({ + path: `/api/game/captures/${challengeId}/${partId}/all`, + method: 'GET', + ...params, + }), + /** * @description 获取赛后题解提交情况,需要User权限 * @@ -4071,30 +4234,6 @@ export class Api extends HttpClient - useSWR(doFetch ? `/api/game/${id}/scoreboardsheet` : null, options), - - /** - * @description 下载比赛积分榜,需要Monitor权限 - * - * @tags Game - * @name GameScoreboardSheet - * @summary 下载比赛积分榜 - * @request GET:/api/game/{id}/scoreboardsheet - */ - mutateGameScoreboardSheet: ( - id: number, - data?: void | Promise, - options?: MutatorOptions - ) => mutate(`/api/game/${id}/scoreboardsheet`, data, options), /** * @description 查询 flag 状态,需要User权限 @@ -4259,30 +4398,6 @@ export class Api extends HttpClient - useSWR(doFetch ? `/api/game/${id}/submissionsheet` : null, options), - - /** - * @description 下载比赛全部提交,需要Monitor权限 - * - * @tags Game - * @name GameSubmissionSheet - * @summary 下载比赛全部提交 - * @request GET:/api/game/{id}/submissionsheet - */ - mutateGameSubmissionSheet: ( - id: number, - data?: void | Promise, - options?: MutatorOptions - ) => mutate(`/api/game/${id}/submissionsheet`, data, options), /** * @description 提交 flag,需要User权限,需要当前激活队伍已经报名 @@ -4541,30 +4656,6 @@ export class Api extends HttpClient - useSWR(doFetch ? `/api/proxy/${id}` : null, options), - - /** - * No description - * - * @tags Proxy - * @name ProxyProxyForInstance - * @summary 采用 websocket 代理 TCP 流量 - * @request GET:/api/proxy/{id} - */ - mutateProxyProxyForInstance: ( - id: string, - data?: void | Promise, - options?: MutatorOptions - ) => mutate(`/api/proxy/${id}`, data, options), } team = { /** diff --git a/src/GZCTF/ClientApp/template/procedure-call.eta b/src/GZCTF/ClientApp/template/procedure-call.eta index 911082441..cd9917eb7 100644 --- a/src/GZCTF/ClientApp/template/procedure-call.eta +++ b/src/GZCTF/ClientApp/template/procedure-call.eta @@ -67,9 +67,10 @@ const wrapperArgs = _ .join(', ') const queryTmpl = (query != null && queryName) || null; +const enableSWR = _.upperCase(method) === "GET" && type !== "void"; %> -<% if (_.upperCase(method) === "GET") { %> +<% if (enableSWR) { %> /** <%~ routeDocs.description %> diff --git a/src/GZCTF/Controllers/GameController.cs b/src/GZCTF/Controllers/GameController.cs index 257fcc015..ab0879b9c 100644 --- a/src/GZCTF/Controllers/GameController.cs +++ b/src/GZCTF/Controllers/GameController.cs @@ -1,5 +1,6 @@ using System.Net.Mime; using System.Security.Claims; +using System.Security.Policy; using System.Threading.Channels; using GZCTF.Middlewares; using GZCTF.Models.Request.Admin; @@ -11,7 +12,9 @@ using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.RateLimiting; +using NPOI.HSSF.Record; using NPOI.OpenXmlFormats.Dml; +using YamlDotNet.Core.Tokens; namespace GZCTF.Controllers; @@ -107,7 +110,7 @@ public async Task Games(int id, CancellationToken token) var context = await GetContextInfo(id, token: token); if (context.Game is null) - return NotFound(new RequestResponse("比赛未找到")); + return NotFound(new RequestResponse("比赛未找到", 404)); var count = await _participationRepository.GetParticipationCount(context.Game, token); @@ -266,7 +269,7 @@ public async Task Scoreboard([FromRoute] int id, CancellationToke var game = await _gameRepository.GetGameById(id, token); if (game is null) - return NotFound(new RequestResponse("比赛未找到")); + return NotFound(new RequestResponse("比赛未找到", 404)); if (DateTimeOffset.UtcNow < game.StartTimeUTC) return BadRequest(new RequestResponse("比赛未开始")); @@ -294,7 +297,7 @@ public async Task Notices([FromRoute] int id, [FromQuery] int cou var game = await _gameRepository.GetGameById(id, token); if (game is null) - return NotFound(new RequestResponse("比赛未找到")); + return NotFound(new RequestResponse("比赛未找到", 404)); if (DateTimeOffset.UtcNow < game.StartTimeUTC) return BadRequest(new RequestResponse("比赛未开始")); @@ -324,7 +327,7 @@ public async Task Events([FromRoute] int id, [FromQuery] bool hid var game = await _gameRepository.GetGameById(id, token); if (game is null) - return NotFound(new RequestResponse("比赛未找到")); + return NotFound(new RequestResponse("比赛未找到", 404)); if (DateTimeOffset.UtcNow < game.StartTimeUTC) return BadRequest(new RequestResponse("比赛未开始")); @@ -354,7 +357,7 @@ public async Task Submissions([FromRoute] int id, [FromQuery] Ans var game = await _gameRepository.GetGameById(id, token); if (game is null) - return NotFound(new RequestResponse("比赛未找到")); + return NotFound(new RequestResponse("比赛未找到", 404)); if (DateTimeOffset.UtcNow < game.StartTimeUTC) return BadRequest(new RequestResponse("比赛未开始")); @@ -381,7 +384,7 @@ public async Task CheatInfo([FromRoute] int id, CancellationToken var game = await _gameRepository.GetGameById(id, token); if (game is null) - return NotFound(new RequestResponse("比赛未找到")); + return NotFound(new RequestResponse("比赛未找到", 404)); if (DateTimeOffset.UtcNow < game.StartTimeUTC) return BadRequest(new RequestResponse("比赛未开始")); @@ -398,23 +401,25 @@ public async Task CheatInfo([FromRoute] int id, CancellationToken /// /// 比赛Id /// - /// 成功获取比赛题目 + /// 成功获取文件列表 + /// 未找到相关捕获信息 [RequireMonitor] [HttpGet("Games/{id}/Captures")] [ProducesResponseType(typeof(ChallengeInfoModel[]), StatusCodes.Status200OK)] public async Task GetChallengesWithTrafficCapturing([FromRoute] int id, CancellationToken token) => Ok((await _challengeRepository.GetChallengesWithTrafficCapturing(id, token)) - .Select(ChallengeTrafficRecordModel.FromChallenge)); + .Select(ChallengeTrafficModel.FromChallenge)); /// - /// 获取开启了比赛题目中捕获到到队伍信息 + /// 获取比赛题目中捕获到到队伍信息 /// /// - /// 获取开启了比赛题目中捕获到到队伍信息,需要Monitor权限 + /// 获取比赛题目中捕获到到队伍信息,需要Monitor权限 /// - /// + /// 题目 Id /// - /// 成功获取比赛题目 + /// 成功获取文件列表 + /// 未找到相关捕获信息 [RequireMonitor] [HttpGet("Captures/{challengeId}")] [ProducesResponseType(typeof(TeamTrafficModel[]), StatusCodes.Status200OK)] @@ -424,18 +429,107 @@ public async Task GetChallengeTraffic([FromRoute] int challengeId var filePath = $"{FilePath.Capture}/{challengeId}"; if (!Path.Exists(filePath)) - return NotFound(new RequestResponse("未找到相关捕获信息")); + return NotFound(new RequestResponse("未找到相关捕获信息", 404)); var participationIds = await GetDirNamesAsInt(filePath); if (participationIds.Count == 0) - return NotFound(new RequestResponse("未找到相关捕获信息")); + return NotFound(new RequestResponse("未找到相关捕获信息", 404)); var participations = await _participationRepository.GetParticipationsByIds(participationIds, token); return Ok(participations.Select(p => TeamTrafficModel.FromParticipation(p, challengeId))); } + /// + /// 获取比赛题目中捕获到到队伍的流量包列表 + /// + /// + /// 获取比赛题目中捕获到到队伍的流量包列表,需要Monitor权限 + /// + /// 题目 Id + /// 队伍参与 Id + /// 成功获取文件列表 + /// 未找到相关捕获信息 + [RequireMonitor] + [HttpGet("Captures/{challengeId}/{partId}")] + [ProducesResponseType(typeof(FileRecord[]), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status404NotFound)] + public IActionResult GetTeamTraffic([FromRoute] int challengeId, [FromRoute] int partId) + { + var filePath = $"{FilePath.Capture}/{challengeId}/{partId}"; + + if (!Path.Exists(filePath)) + return NotFound(new RequestResponse("未找到相关捕获信息", 404)); + + return Ok(FilePath.GetFileRecords(filePath, out long _)); + } + + /// + /// 获取流量包文件压缩包 + /// + /// + /// 获取流量包文件,需要Monitor权限 + /// + /// 题目 Id + /// 队伍参与 Id + /// 队伍参与 Id + /// 成功获取文件 + /// 未找到相关捕获信息 + [RequireMonitor] + [HttpGet("Captures/{challengeId}/{partId}/All")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status404NotFound)] + public async Task GetTeamTrafficZip([FromRoute] int challengeId, [FromRoute] int partId, CancellationToken token) + { + var filePath = $"{FilePath.Capture}/{challengeId}/{partId}"; + + if (!Path.Exists(filePath)) + return NotFound(new RequestResponse("未找到相关捕获信息", 404)); + + var filename = $"Capture-{challengeId}-{partId}-{DateTimeOffset.UtcNow:yyyyMMdd-HH.mm.ssZ}"; + var stream = await Codec.ZipFilesAsync(filePath, filename, token); + stream.Seek(0, SeekOrigin.Begin); + + return File(stream, "application/zip", $"{filename}.zip"); + } + + /// + /// 获取流量包文件 + /// + /// + /// 获取流量包文件,需要Monitor权限 + /// + /// 题目 Id + /// 队伍参与 Id + /// 流量包文件名 + /// 成功获取文件 + /// 未找到相关捕获信息 + [RequireMonitor] + [HttpGet("Captures/{challengeId}/{partId}/{filename}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status404NotFound)] + public IActionResult GetTeamTraffic([FromRoute] int challengeId, [FromRoute] int partId, [FromRoute] string filename) + { + try + { + var file = Path.GetFileName(filename); + var path = Path.GetFullPath(Path.Combine(FilePath.Capture, $"{challengeId}/{partId}", file)); + + if (Path.GetExtension(file) != ".pcap" || !Path.Exists(path)) + return NotFound(new RequestResponse("未找到相关捕获信息")); + + return new PhysicalFileResult(path, MediaTypeNames.Application.Octet) + { + FileDownloadName = file + }; + } + catch + { + return NotFound(new RequestResponse("未找到相关捕获信息")); + } + } + /// /// 获取全部比赛题目信息及当前队伍信息 /// diff --git a/src/GZCTF/Models/Request/Game/ChallengeTrafficRecordModel.cs b/src/GZCTF/Models/Request/Game/ChallengeTrafficModel.cs similarity index 91% rename from src/GZCTF/Models/Request/Game/ChallengeTrafficRecordModel.cs rename to src/GZCTF/Models/Request/Game/ChallengeTrafficModel.cs index 85828cb98..d99904b93 100644 --- a/src/GZCTF/Models/Request/Game/ChallengeTrafficRecordModel.cs +++ b/src/GZCTF/Models/Request/Game/ChallengeTrafficModel.cs @@ -3,7 +3,7 @@ namespace GZCTF.Models.Request.Game; -public class ChallengeTrafficRecordModel +public class ChallengeTrafficModel { /// /// 题目Id @@ -37,7 +37,7 @@ public class ChallengeTrafficRecordModel /// public int Count { get; set; } = 0; - internal static ChallengeTrafficRecordModel FromChallenge(Challenge challenge) + internal static ChallengeTrafficModel FromChallenge(Challenge challenge) { string trafficPath = $"{FilePath.Capture}/{challenge.Id}"; diff --git a/src/GZCTF/Models/Request/Game/TeamTrafficModel.cs b/src/GZCTF/Models/Request/Game/TeamTrafficModel.cs index c12db4c0b..6fd2feb72 100644 --- a/src/GZCTF/Models/Request/Game/TeamTrafficModel.cs +++ b/src/GZCTF/Models/Request/Game/TeamTrafficModel.cs @@ -33,31 +33,14 @@ public class TeamTrafficModel public string? Avatar { get; set; } /// - /// 题目所捕获到的流量 + /// 题目所捕获到的流量数量 /// - public List Records { get; set; } = new(); + public int Count { get; set; } internal static TeamTrafficModel FromParticipation(Participation part, int challengeId) { string trafficPath = $"{FilePath.Capture}/{challengeId}/{part.Id}"; - var records = new List(); - - if (Directory.Exists(trafficPath)) - { - foreach (string file in Directory.EnumerateFiles(trafficPath)) - { - var info = new FileInfo(file)!; - - records.Add(new() - { - FileName = info.Name, - Size = info.Length, - UpdateTime = info.LastAccessTimeUtc - }); - } - } - return new() { Id = part.Team.Id, @@ -65,7 +48,8 @@ internal static TeamTrafficModel FromParticipation(Participation part, int chall Bio = part.Team.Bio, ParticipationId = part.Id, Avatar = part.Team.AvatarUrl, - Records = records + Count = Directory.Exists(trafficPath) ? + Directory.GetDirectories(trafficPath, "*", SearchOption.TopDirectoryOnly).Length : 0 }; } } diff --git a/src/GZCTF/Utils/Codec.cs b/src/GZCTF/Utils/Codec.cs index 0a7f53b3d..1910f6aa6 100644 --- a/src/GZCTF/Utils/Codec.cs +++ b/src/GZCTF/Utils/Codec.cs @@ -2,6 +2,7 @@ using System.Security.Cryptography; using System.Text; using System.Text.RegularExpressions; +using NPOI.HSSF.Record; namespace GZCTF.Utils; @@ -255,6 +256,34 @@ public async static Task ZipFilesAsync(IEnumerable files, str await tmp.FlushAsync(token); return tmp; } + + /// + /// 将文件夹打包为 zip 文件 + /// + /// 根目录 + /// 压缩包根目录 + /// + /// + public async static Task ZipFilesAsync(string basePath, string zipName, CancellationToken token = default) + { + var records = FilePath.GetFileRecords(basePath, out long size); + + Stream tmp = size <= 64 * 1024 * 1024 ? new MemoryStream() : + File.Create(Path.GetTempFileName(), 4096, FileOptions.DeleteOnClose); + + using var zip = new ZipArchive(tmp, ZipArchiveMode.Create, true); + + foreach (var file in records) + { + var entry = zip.CreateEntry(Path.Combine(zipName, file.FileName), CompressionLevel.Optimal); + await using var entryStream = entry.Open(); + await using var fileStream = File.OpenRead(Path.Combine(basePath, file.FileName)); + await fileStream.CopyToAsync(entryStream, token); + } + + await tmp.FlushAsync(token); + return tmp; + } } public static partial class CodecExtensions diff --git a/src/GZCTF/Utils/FilePath.cs b/src/GZCTF/Utils/FilePath.cs index d5297702a..2be5ee4be 100644 --- a/src/GZCTF/Utils/FilePath.cs +++ b/src/GZCTF/Utils/FilePath.cs @@ -34,4 +34,32 @@ internal static string GetDir(DirType type) internal static string Logs => GetDir(DirType.Logs); internal static string Uploads => GetDir(DirType.Uploads); internal static string Capture => GetDir(DirType.Capture); -} + + /// + /// 获取文件夹内容 + /// + /// 文件夹 + /// 总大小 + /// + internal static List GetFileRecords(string dir, out long totSize) + { + totSize = 0; + var records = new List(); + + foreach (string file in Directory.EnumerateFiles(dir, "*", SearchOption.TopDirectoryOnly)) + { + var info = new FileInfo(file)!; + + records.Add(new() + { + FileName = info.Name, + Size = info.Length, + UpdateTime = info.LastAccessTimeUtc + }); + + totSize += info.Length; + } + + return records; + } +} \ No newline at end of file From 95ba85dc15adb3e29977f18b778b68c8dbe9e39d Mon Sep 17 00:00:00 2001 From: Aether Chen <15167799+chenjunyu19@users.noreply.github.com> Date: Thu, 17 Aug 2023 21:33:08 +0800 Subject: [PATCH 37/67] wip: refactor InstanceEntry --- .../src/components/InstanceEntry.tsx | 132 ++++++++++++------ 1 file changed, 90 insertions(+), 42 deletions(-) diff --git a/src/GZCTF/ClientApp/src/components/InstanceEntry.tsx b/src/GZCTF/ClientApp/src/components/InstanceEntry.tsx index 56ee6ecc1..0149a626c 100644 --- a/src/GZCTF/ClientApp/src/components/InstanceEntry.tsx +++ b/src/GZCTF/ClientApp/src/components/InstanceEntry.tsx @@ -1,9 +1,20 @@ import dayjs from 'dayjs' import { FC } from 'react' -import { Stack, Text, Button, Group, Code, Tooltip, Anchor } from '@mantine/core' +import { + Stack, + Text, + Button, + Group, + Tooltip, + Anchor, + TextInput, + ActionIcon, + Center, + Divider, +} from '@mantine/core' import { useClipboard } from '@mantine/hooks' import { showNotification } from '@mantine/notifications' -import { mdiCheck } from '@mdi/js' +import { mdiCheck, mdiServerNetwork, mdiContentCopy, mdiOpenInNew, mdiOpenInApp } from '@mdi/js' import { Icon } from '@mdi/react' import { getProxyUrl } from '@Utils/Shared' import { useTooltipStyles } from '@Utils/ThemeOverride' @@ -28,49 +39,86 @@ export const InstanceEntry: FC = (props) => { const isPlatformProxy = instanceEntry.length === 36 && !instanceEntry.includes(':') const copyEntry = isPlatformProxy ? getProxyUrl(instanceEntry) : instanceEntry + const onCopyEntry = () => { + clipBoard.copy(copyEntry) + showNotification({ + color: 'teal', + title: isPlatformProxy ? '实例入口已复制到剪贴板' : undefined, + message: isPlatformProxy ? '请使用客户端进行访问' : '实例入口已复制到剪贴板', + icon: , + }) + } + + const onOpenInNew = () => { + window.open(`http://${instanceEntry}`) + } + + const onOpenInApp = () => { + if (!isPlatformProxy) { + return + } + const url = new URL('wsrx://open') + url.searchParams.append('url', copyEntry) + window.location.href = url.href + showNotification({ + color: 'teal', + title: '已尝试拉起客户端', + message: '请确保客户端正确安装', + icon: , + }) + } + return ( - - - - 实例入口: - - { - clipBoard.copy(copyEntry) - showNotification({ - color: 'teal', - title: isPlatformProxy ? '实例入口已复制到剪贴板' : undefined, - message: isPlatformProxy ? '请使用客户端进行访问' : '实例入口已复制到剪贴板', - icon: , - }) - }} + + 实例入口} + description={ + isPlatformProxy && ( + + 平台已启用代理模式,建议使用专用客户端。 + + 获取客户端 + + + ) + } + icon={} + value={copyEntry} + readOnly + styles={{ + input: { + fontFamily: `${theme.fontFamilyMonospace}, ${theme.fontFamily}`, + }, + }} + rightSection={ + + + + + + + + - {instanceEntry} - - - + + + + + + } + rightSectionWidth="5rem" + /> +
- - {isPlatformProxy && ( - - - 获取客户端: - - WebSocketReflectorX - - - - )} - +
+ From c126093912eec30bf10d2520b8762c9f51cf6f4a Mon Sep 17 00:00:00 2001 From: GZTime Date: Fri, 18 Aug 2023 20:39:32 +0800 Subject: [PATCH 38/67] fix: remove connection cache when remove a container --- src/GZCTF/Repositories/ContainerRepository.cs | 16 ++++++++++++---- src/GZCTF/Utils/CapturableNetworkStream.cs | 4 +++- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/src/GZCTF/Repositories/ContainerRepository.cs b/src/GZCTF/Repositories/ContainerRepository.cs index 3016b6446..4cde3646a 100644 --- a/src/GZCTF/Repositories/ContainerRepository.cs +++ b/src/GZCTF/Repositories/ContainerRepository.cs @@ -1,13 +1,19 @@ using GZCTF.Models.Request.Admin; using GZCTF.Repositories.Interface; +using GZCTF.Utils; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Distributed; namespace GZCTF.Repositories; public class ContainerRepository : RepositoryBase, IContainerRepository { - public ContainerRepository(AppDbContext context) : base(context) + private readonly IDistributedCache _cache; + + public ContainerRepository(IDistributedCache cache, + AppDbContext context) : base(context) { + _cache = cache; } public override Task CountAsync(CancellationToken token = default) => _context.Containers.CountAsync(token); @@ -34,14 +40,16 @@ public Task> GetDyingContainers(CancellationToken token = defaul return _context.Containers.Where(c => c.ExpectStopAt < now).ToListAsync(token); } - public Task RemoveContainer(Container container, CancellationToken token = default) + public async Task RemoveContainer(Container container, CancellationToken token = default) { // Do not remove running container from database if (container.Status != ContainerStatus.Destroyed) - return Task.FromResult(-1); + return; + + await _cache.RemoveAsync(CacheKey.ConnectionCount(container.Id), token); _context.Containers.Remove(container); - return SaveAsync(token); + await SaveAsync(token); } public async Task ValidateContainer(string guid, CancellationToken token = default) diff --git a/src/GZCTF/Utils/CapturableNetworkStream.cs b/src/GZCTF/Utils/CapturableNetworkStream.cs index 5ea66c0f7..df7f95113 100644 --- a/src/GZCTF/Utils/CapturableNetworkStream.cs +++ b/src/GZCTF/Utils/CapturableNetworkStream.cs @@ -3,7 +3,6 @@ using System.Net.Sockets; using PacketDotNet; using PacketDotNet.Utils; -using Serilog; using SharpPcap; using SharpPcap.LibPcap; @@ -32,6 +31,9 @@ public class CapturableNetworkStreamOptions public bool EnableCapture { get; set; } = false; } +/// +/// 能够被捕获的网络流(Socket) +/// public sealed class CapturableNetworkStream : NetworkStream { private readonly CapturableNetworkStreamOptions _options; From bf58de18e9a6f64645c7225f27b901bda6c2babf Mon Sep 17 00:00:00 2001 From: GZTime Date: Fri, 18 Aug 2023 21:19:27 +0800 Subject: [PATCH 39/67] docs: update --- README.md | 4 +++- docs/pages/changelog.zh.mdx | 2 +- docs/pages/guide/_meta.zh.json | 1 - docs/pages/guide/challenge/_meta.zh.json | 6 ------ docs/pages/guide/challenge/dynamic-attachment.zh.mdx | 3 --- docs/pages/guide/challenge/dynamic-container.zh.mdx | 3 --- docs/pages/guide/challenge/static-attachment.zh.mdx | 3 --- docs/pages/guide/challenge/static-container.zh.mdx | 3 --- docs/pages/guide/platform-proxy.zh.mdx | 4 ++-- docs/pages/thanks.zh.mdx | 2 +- 10 files changed, 7 insertions(+), 24 deletions(-) delete mode 100644 docs/pages/guide/challenge/_meta.zh.json delete mode 100644 docs/pages/guide/challenge/dynamic-attachment.zh.mdx delete mode 100644 docs/pages/guide/challenge/dynamic-container.zh.mdx delete mode 100644 docs/pages/guide/challenge/static-attachment.zh.mdx delete mode 100644 docs/pages/guide/challenge/static-container.zh.mdx diff --git a/README.md b/README.md index 96060b798..771cc202d 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,8 @@ GZ::CTF 是一个基于 ASP.NET Core 的开源 CTF 平台。 +**为了避免不必要的时间浪费,使用前请详细阅读使用文档:[https://docs.ctf.gzti.me/](https://docs.ctf.gzti.me/)** + ## 特性 🛠️ - 创建高度可自定义的题目 @@ -86,7 +88,7 @@ _排名不分先后,欢迎提交 PR 进行补充。_ ## 特别感谢 ❤️‍🔥 -感谢 THUCTF 2022 的组织者 NanoApe 提供的赞助及阿里云公网并发压力测试,帮助验证了 GZCTF 单机实例在千级并发、三分钟 134w 请求压力下的服务稳定性。 +感谢 THUCTF 2022 的组织者 NanoApe 提供的赞助及阿里云公网并发压力测试,帮助验证了 GZCTF 单机实例(16c90g)在千级并发、三分钟 134w 请求压力下的服务稳定性。 ## Stars ✨ diff --git a/docs/pages/changelog.zh.mdx b/docs/pages/changelog.zh.mdx index ad5370083..c9bc20a57 100644 --- a/docs/pages/changelog.zh.mdx +++ b/docs/pages/changelog.zh.mdx @@ -8,7 +8,7 @@ import { Callout } from "nextra-theme-docs"; - **将原有 `uploads` 目录移动至 `files/uploads`,移除了此目录的配置项,更改了日志存储位置** - 更新建议:将原有 `uploads` 目录移动至 `files/uploads`,并重新挂载相关目录,删除 `uploads` 目录的配置项和原有 `log` 目录 + 更新步骤:将原有 `uploads` 目录移动至 `files/uploads`,并重新挂载相关目录,删除 `uploads` 目录的配置项和原有 `log` 目录 ## v0.16-v0.1 diff --git a/docs/pages/guide/_meta.zh.json b/docs/pages/guide/_meta.zh.json index df1a86fe9..b32754e09 100644 --- a/docs/pages/guide/_meta.zh.json +++ b/docs/pages/guide/_meta.zh.json @@ -1,5 +1,4 @@ { - "challenge": "赛题配置", "dynamic-flag": "动态 flag", "platform-proxy": "平台流量代理" } diff --git a/docs/pages/guide/challenge/_meta.zh.json b/docs/pages/guide/challenge/_meta.zh.json deleted file mode 100644 index fb64b32f6..000000000 --- a/docs/pages/guide/challenge/_meta.zh.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "dynamic-container": "动态容器题目", - "dynamic-attachment": "动态附件题目", - "static-attachment": "静态附件题目", - "static-container": "静态容器题目" -} diff --git a/docs/pages/guide/challenge/dynamic-attachment.zh.mdx b/docs/pages/guide/challenge/dynamic-attachment.zh.mdx deleted file mode 100644 index e73a9961e..000000000 --- a/docs/pages/guide/challenge/dynamic-attachment.zh.mdx +++ /dev/null @@ -1,3 +0,0 @@ -# 动态附件题目 - -仍在施工中…… diff --git a/docs/pages/guide/challenge/dynamic-container.zh.mdx b/docs/pages/guide/challenge/dynamic-container.zh.mdx deleted file mode 100644 index e08ad32ad..000000000 --- a/docs/pages/guide/challenge/dynamic-container.zh.mdx +++ /dev/null @@ -1,3 +0,0 @@ -# 动态容器题目 - -仍在施工中…… diff --git a/docs/pages/guide/challenge/static-attachment.zh.mdx b/docs/pages/guide/challenge/static-attachment.zh.mdx deleted file mode 100644 index 5ec763418..000000000 --- a/docs/pages/guide/challenge/static-attachment.zh.mdx +++ /dev/null @@ -1,3 +0,0 @@ -# 静态附件题目 - -仍在施工中…… diff --git a/docs/pages/guide/challenge/static-container.zh.mdx b/docs/pages/guide/challenge/static-container.zh.mdx deleted file mode 100644 index df9e77d98..000000000 --- a/docs/pages/guide/challenge/static-container.zh.mdx +++ /dev/null @@ -1,3 +0,0 @@ -# 静态容器题目 - -仍在施工中…… diff --git a/docs/pages/guide/platform-proxy.zh.mdx b/docs/pages/guide/platform-proxy.zh.mdx index 5dc34ef4e..a4a40cb75 100644 --- a/docs/pages/guide/platform-proxy.zh.mdx +++ b/docs/pages/guide/platform-proxy.zh.mdx @@ -36,13 +36,13 @@ docker network create challenges -d bridge --subnet 192.168.133.0/24 ```yaml version: "3.7" services: -gzctf: + gzctf: ... networks: - default - challenges networks: -challenges: + challenges: external: true ``` diff --git a/docs/pages/thanks.zh.mdx b/docs/pages/thanks.zh.mdx index c3f71e17a..2da73c2ea 100644 --- a/docs/pages/thanks.zh.mdx +++ b/docs/pages/thanks.zh.mdx @@ -22,7 +22,7 @@ _排名不分先后,欢迎提交 PR 进行补充。_ ## 特别感谢 ❤️‍🔥 -感谢 THUCTF 2022 的组织者 NanoApe 提供的赞助及阿里云公网并发压力测试,帮助验证了 GZCTF 单机实例在千级并发、三分钟 134w 请求压力下的服务稳定性。 +感谢 THUCTF 2022 的组织者 NanoApe 提供的赞助及阿里云公网并发压力测试,帮助验证了 GZCTF 单机实例(16c90g)在千级并发、三分钟 134w 请求压力下的服务稳定性。 ## 贡献者 👋 From 9bb7d35012cb30c21fb82139d09f572bd1aa62fa Mon Sep 17 00:00:00 2001 From: GZTime Date: Fri, 18 Aug 2023 22:45:49 +0800 Subject: [PATCH 40/67] wip(frontend): instance entry --- src/GZCTF/ClientApp/package.json | 2 +- src/GZCTF/ClientApp/pnpm-lock.yaml | 14 +- .../src/components/ChallengeDetailModal.tsx | 47 ++---- .../src/components/InstanceEntry.tsx | 139 +++++++++++++++--- .../admin/ChallengePreviewModal.tsx | 50 ++++--- 5 files changed, 173 insertions(+), 79 deletions(-) diff --git a/src/GZCTF/ClientApp/package.json b/src/GZCTF/ClientApp/package.json index b9cce9ace..542dead33 100644 --- a/src/GZCTF/ClientApp/package.json +++ b/src/GZCTF/ClientApp/package.json @@ -61,7 +61,7 @@ "eslint-plugin-react-hooks": "^4.6.0", "form-data": "~4.0.0", "lodash": "^4.17.21", - "prettier": "~3.0.1", + "prettier": "~3.0.2", "rollup": "^3.28.0", "swagger-typescript-api": "^13.0.3", "tslib": "^2.6.1", diff --git a/src/GZCTF/ClientApp/pnpm-lock.yaml b/src/GZCTF/ClientApp/pnpm-lock.yaml index 971eb93f3..d6ff325a1 100644 --- a/src/GZCTF/ClientApp/pnpm-lock.yaml +++ b/src/GZCTF/ClientApp/pnpm-lock.yaml @@ -108,7 +108,7 @@ devDependencies: version: 1.5.0(eslint@8.47.0)(vite@4.4.9) '@trivago/prettier-plugin-sort-imports': specifier: ^4.2.0 - version: 4.2.0(prettier@3.0.1) + version: 4.2.0(prettier@3.0.2) '@types/katex': specifier: ^0.16.2 version: 0.16.2 @@ -152,8 +152,8 @@ devDependencies: specifier: ^4.17.21 version: 4.17.21 prettier: - specifier: ~3.0.1 - version: 3.0.1 + specifier: ~3.0.2 + version: 3.0.2 rollup: specifier: ^3.28.0 version: 3.28.0 @@ -1254,7 +1254,7 @@ packages: engines: {node: '>=10'} dev: true - /@trivago/prettier-plugin-sort-imports@4.2.0(prettier@3.0.1): + /@trivago/prettier-plugin-sort-imports@4.2.0(prettier@3.0.2): resolution: {integrity: sha512-YBepjbt+ZNBVmN3ev1amQH3lWCmHyt5qTbLCp/syXJRu/Kw2koXh44qayB1gMRxcL/gV8egmjN5xWSrYyfUtyw==} peerDependencies: '@vue/compiler-sfc': 3.x @@ -1269,7 +1269,7 @@ packages: '@babel/types': 7.17.0 javascript-natural-sort: 0.7.1 lodash: 4.17.21 - prettier: 3.0.1 + prettier: 3.0.2 transitivePeerDependencies: - supports-color dev: true @@ -3233,8 +3233,8 @@ packages: hasBin: true dev: true - /prettier@3.0.1: - resolution: {integrity: sha512-fcOWSnnpCrovBsmFZIGIy9UqK2FaI7Hqax+DIO0A9UxeVoY4iweyaFjS5TavZN97Hfehph0nhsZnjlVKzEQSrQ==} + /prettier@3.0.2: + resolution: {integrity: sha512-o2YR9qtniXvwEZlOKbveKfDQVyqxbEIWn48Z8m3ZJjBjcCmUy3xZGIv+7AkaeuaTr6yPXJjwv07ZWlsWbEy1rQ==} engines: {node: '>=14'} hasBin: true dev: true diff --git a/src/GZCTF/ClientApp/src/components/ChallengeDetailModal.tsx b/src/GZCTF/ClientApp/src/components/ChallengeDetailModal.tsx index 22c21f691..e87b65f69 100644 --- a/src/GZCTF/ClientApp/src/components/ChallengeDetailModal.tsx +++ b/src/GZCTF/ClientApp/src/components/ChallengeDetailModal.tsx @@ -1,12 +1,9 @@ -import dayjs from 'dayjs' -import duration from 'dayjs/plugin/duration' import { FC, useEffect, useState } from 'react' import React from 'react' import { ActionIcon, Box, Button, - Card, Divider, Group, LoadingOverlay, @@ -40,28 +37,6 @@ interface ChallengeDetailModalProps extends ModalProps { solved?: boolean } -dayjs.extend(duration) - -export const Countdown: FC<{ time: string }> = ({ time }) => { - const end = dayjs(time) - const [now, setNow] = useState(dayjs()) - const countdown = dayjs.duration(end.diff(now)) - - useEffect(() => { - if (dayjs() > end) return - const interval = setInterval(() => setNow(dayjs()), 1000) - return () => clearInterval(interval) - }, []) - - return ( - - - {countdown.asSeconds() > 0 ? countdown.format('HH:mm:ss') : '00:00:00'} - - - ) -} - export const FlagPlaceholders: string[] = [ '横看成岭侧成峰,flag 高低各不同', 'flag 当关,万夫莫开', @@ -152,6 +127,12 @@ const ChallengeDetailModal: FC = (props) => { instanceEntry: res.data.entry, }, }) + showNotification({ + color: 'teal', + title: '实例已创建', + message: '请注意实例到期时间', + icon: , + }) }) .catch(showErrorNotification) .finally(() => setDisabled(false)) @@ -172,6 +153,12 @@ const ChallengeDetailModal: FC = (props) => { instanceEntry: null, }, }) + showNotification({ + color: 'teal', + title: '实例已销毁', + message: '你可以重新创建实例', + icon: , + }) }) .catch(showErrorNotification) .finally(() => setDisabled(false)) @@ -389,16 +376,10 @@ const ChallengeDetailModal: FC = (props) => { ))}
)} - {isDynamic && !challenge?.context?.instanceEntry && ( - - - - )} - {isDynamic && challenge?.context?.instanceEntry && ( + {isDynamic && challenge.context && ( void onProlong: () => void onDestroy: () => void } +dayjs.extend(duration) + +interface CountdownProps { + time: string + prolongNotice: () => void +} + +const Countdown: FC = ({ time, prolongNotice }) => { + const [now, setNow] = useState(dayjs()) + const end = dayjs(time) + const countdown = dayjs.duration(end.diff(now)) + const [haveNoticed, setHaveNoticed] = useState(countdown.asMinutes() < 10) + + useEffect(() => { + if (dayjs() > end) return + const interval = setInterval(() => setNow(dayjs()), 1000) + return () => clearInterval(interval) + }, []) + + useEffect(() => { + if (countdown.asSeconds() <= 0) return + + if (countdown.asMinutes() < 10 && !haveNoticed) { + prolongNotice() + setHaveNoticed(true) + } else if (countdown.asMinutes() > 10) { + setHaveNoticed(false) + } + }, [countdown]) + + return {countdown.asSeconds() > 0 ? countdown.format('HH:mm:ss') : '00:00:00'} +} + export const InstanceEntry: FC = (props) => { - const { context, onProlong, disabled, onDestroy } = props + const { context, disabled, onCreate, onDestroy } = props + const clipBoard = useClipboard() - const instanceCloseTime = dayjs(context.closeTime ?? 0) - const instanceLeft = instanceCloseTime.diff(dayjs(), 'minute') + + const [withContainer, setWithContainer] = useState(!!context.instanceEntry) + const { classes: tooltipClasses, theme } = useTooltipStyles() const instanceEntry = context.instanceEntry ?? '' const isPlatformProxy = instanceEntry.length === 36 && !instanceEntry.includes(':') const copyEntry = isPlatformProxy ? getProxyUrl(instanceEntry) : instanceEntry + const [canProlong, setCanProlong] = useState(false) + + const prolongNotice = () => { + if (canProlong) return + + showNotification({ + color: 'orange', + title: '实例即将到期', + message: '请及时延长时间或销毁实例', + icon: , + }) + + setCanProlong(true) + } + + useEffect(() => { + setWithContainer(!!context.instanceEntry) + const countdown = dayjs.duration(dayjs(context.closeTime ?? 0).diff(dayjs())) + setCanProlong(countdown.asMinutes() < 10) + }, [context]) + + const onProlong = () => { + if (!canProlong) return + + props.onProlong() + setCanProlong(false) + + showNotification({ + color: 'teal', + title: '实例时间已延长', + message: '请注意实例到期时间', + icon: , + }) + } + const onCopyEntry = () => { clipBoard.copy(copyEntry) showNotification({ @@ -68,6 +145,25 @@ export const InstanceEntry: FC = (props) => { }) } + if (!withContainer) { + return ( + + + + 本题为容器题目,解题需开启容器实例 + + + 容器默认有效期为 120 分钟 + + + + + + ) + } + return ( = (props) => { } rightSectionWidth="5rem" /> -
- -
- - - + + + + 剩余时间: + + + + 你可以在到期前 10 分钟内延长时间 + + + + + + +
) diff --git a/src/GZCTF/ClientApp/src/components/admin/ChallengePreviewModal.tsx b/src/GZCTF/ClientApp/src/components/admin/ChallengePreviewModal.tsx index 6f04266fc..253f6aa80 100644 --- a/src/GZCTF/ClientApp/src/components/admin/ChallengePreviewModal.tsx +++ b/src/GZCTF/ClientApp/src/components/admin/ChallengePreviewModal.tsx @@ -34,14 +34,36 @@ interface ChallengePreviewModalProps extends ModalProps { tagData: ChallengeTagItemProps } +interface FakeContext { + closeTime: string | null + instanceEntry: string | null +} + const ChallengePreviewModal: FC = (props) => { const { challenge, type, attachmentType, tagData, ...modalProps } = props const [downloadOpened, { close: downloadClose, open: downloadOpen }] = useDisclosure(false) const [placeholder, setPlaceholder] = useState('') const [flag, setFlag] = useInputState('') - const [startTime, setStartTime] = useState(dayjs()) - const [withContainer, setWithContainer] = useState(false) + + const [context, setContext] = useState({ + closeTime: null, + instanceEntry: null, + }) + + const onCreate = () => { + setContext({ + closeTime: dayjs().add(10, 'm').add(10, 's').toJSON(), + instanceEntry: 'localhost:2333', + }) + } + + const onDestroy = () => { + setContext({ + closeTime: null, + instanceEntry: null, + }) + } const onSubmit = (event: React.FormEvent) => { event.preventDefault() @@ -167,27 +189,13 @@ const ChallengePreviewModal: FC = (props) => { ))} )} - {isDynamic && !withContainer && ( - - - - )} - {isDynamic && withContainer && ( + {isDynamic && ( {}} - onDestroy={() => setWithContainer(false)} + onCreate={onCreate} + onProlong={onCreate} + onDestroy={onDestroy} /> )} From 1dc04894a0d9327f556e55ac9eaea80fad50c22e Mon Sep 17 00:00:00 2001 From: GZTime Date: Sat, 19 Aug 2023 09:53:23 +0800 Subject: [PATCH 41/67] fix: wrong API type declaration --- src/GZCTF/ClientApp/src/Api.ts | 32 +++++++++++++++++++++---- src/GZCTF/Controllers/GameController.cs | 4 ++-- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/src/GZCTF/ClientApp/src/Api.ts b/src/GZCTF/ClientApp/src/Api.ts index 91a770756..22284b34b 100644 --- a/src/GZCTF/ClientApp/src/Api.ts +++ b/src/GZCTF/ClientApp/src/Api.ts @@ -1446,6 +1446,30 @@ export interface ParticipationModel { organization?: string | null } +export interface ChallengeTrafficModel { + /** + * 题目Id + * @format int32 + */ + id?: number + /** + * 题目名称 + * @minLength 1 + */ + title: string + /** 题目标签 */ + tag?: ChallengeTag + /** 题目类型 */ + type?: ChallengeType + /** 是否启用题目 */ + isEnabled?: boolean + /** + * 题目所捕获到的队伍流量数量 + * @format int32 + */ + count?: number +} + /** 队伍流量获取信息 */ export interface TeamTrafficModel { /** @@ -3787,7 +3811,7 @@ export class Api extends HttpClient - this.request({ + this.request({ path: `/api/game/games/${id}/captures`, method: 'GET', format: 'json', @@ -3806,7 +3830,7 @@ export class Api extends HttpClient - useSWR( + useSWR( doFetch ? `/api/game/games/${id}/captures` : null, options ), @@ -3821,9 +3845,9 @@ export class Api extends HttpClient, + data?: ChallengeTrafficModel[] | Promise, options?: MutatorOptions - ) => mutate(`/api/game/games/${id}/captures`, data, options), + ) => mutate(`/api/game/games/${id}/captures`, data, options), /** * @description 获取比赛题目中捕获到到队伍信息,需要Monitor权限 diff --git a/src/GZCTF/Controllers/GameController.cs b/src/GZCTF/Controllers/GameController.cs index ab0879b9c..b73e3629f 100644 --- a/src/GZCTF/Controllers/GameController.cs +++ b/src/GZCTF/Controllers/GameController.cs @@ -401,11 +401,11 @@ public async Task CheatInfo([FromRoute] int id, CancellationToken /// /// 比赛Id /// - /// 成功获取文件列表 + /// 成功获取题目列表 /// 未找到相关捕获信息 [RequireMonitor] [HttpGet("Games/{id}/Captures")] - [ProducesResponseType(typeof(ChallengeInfoModel[]), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ChallengeTrafficModel[]), StatusCodes.Status200OK)] public async Task GetChallengesWithTrafficCapturing([FromRoute] int id, CancellationToken token) => Ok((await _challengeRepository.GetChallengesWithTrafficCapturing(id, token)) .Select(ChallengeTrafficModel.FromChallenge)); From 47b84a76b0fa405cfb2b6bc4ebeef8a352a76d5c Mon Sep 17 00:00:00 2001 From: GZTime Date: Sat, 19 Aug 2023 21:51:26 +0800 Subject: [PATCH 42/67] fix: remove localfiles when remove games --- src/GZCTF/Controllers/AccountController.cs | 2 +- src/GZCTF/Controllers/EditController.cs | 2 +- src/GZCTF/Controllers/TeamController.cs | 2 +- src/GZCTF/Repositories/ChallengeRepository.cs | 69 ++++++++----------- src/GZCTF/Repositories/FileRepository.cs | 2 +- src/GZCTF/Repositories/GameRepository.cs | 40 +++++++++-- .../Repositories/Interface/IFileRepository.cs | 4 +- .../Repositories/ParticipationRepository.cs | 10 ++- 8 files changed, 79 insertions(+), 52 deletions(-) diff --git a/src/GZCTF/Controllers/AccountController.cs b/src/GZCTF/Controllers/AccountController.cs index 0ed9fc64a..0bb67b8e5 100644 --- a/src/GZCTF/Controllers/AccountController.cs +++ b/src/GZCTF/Controllers/AccountController.cs @@ -497,7 +497,7 @@ public async Task Avatar(IFormFile file, CancellationToken token) if (user!.AvatarHash is not null) await _fileService.DeleteFileByHash(user.AvatarHash, token); - var avatar = await _fileService.CreateOrUpdateImage(file, "avatar", token); + var avatar = await _fileService.CreateOrUpdateImage(file, "avatar", 300, token); if (avatar is null) return BadRequest(new RequestResponse("用户头像更新失败")); diff --git a/src/GZCTF/Controllers/EditController.cs b/src/GZCTF/Controllers/EditController.cs index e8f1e5fd7..57f8c91ec 100644 --- a/src/GZCTF/Controllers/EditController.cs +++ b/src/GZCTF/Controllers/EditController.cs @@ -290,7 +290,7 @@ public async Task UpdateGamePoster([FromRoute] int id, IFormFile if (game is null) return NotFound(new RequestResponse("比赛未找到", 404)); - var poster = await _fileService.CreateOrUpdateImage(file, "poster", token, 0); + var poster = await _fileService.CreateOrUpdateImage(file, "poster", 0, token); if (poster is null) return BadRequest(new RequestResponse("文件创建失败")); diff --git a/src/GZCTF/Controllers/TeamController.cs b/src/GZCTF/Controllers/TeamController.cs index d3eec85f3..ef1c86c67 100644 --- a/src/GZCTF/Controllers/TeamController.cs +++ b/src/GZCTF/Controllers/TeamController.cs @@ -486,7 +486,7 @@ public async Task Avatar([FromRoute] int id, IFormFile file, Canc if (team.AvatarHash is not null) _ = await _fileService.DeleteFileByHash(team.AvatarHash, token); - var avatar = await _fileService.CreateOrUpdateImage(file, "avatar", token); + var avatar = await _fileService.CreateOrUpdateImage(file, "avatar", 300, token); if (avatar is null) return BadRequest(new RequestResponse("队伍头像更新失败")); diff --git a/src/GZCTF/Repositories/ChallengeRepository.cs b/src/GZCTF/Repositories/ChallengeRepository.cs index d39913080..cfe651e01 100644 --- a/src/GZCTF/Repositories/ChallengeRepository.cs +++ b/src/GZCTF/Repositories/ChallengeRepository.cs @@ -76,28 +76,12 @@ public Task GetChallenges(int gameId, CancellationToken token = def public Task GetChallengesWithTrafficCapturing(int gameId, CancellationToken token = default) => _context.Challenges.IgnoreAutoIncludes().Where(c => c.GameId == gameId && c.EnableTrafficCapture).ToArrayAsync(token); + public Task VerifyStaticAnswer(Challenge challenge, string flag, CancellationToken token = default) + => _context.Entry(challenge).Collection(e => e.Flags).Query().AnyAsync(f => f.Flag == flag, token); + public async Task RemoveChallenge(Challenge challenge, CancellationToken token = default) { - if (challenge.Type == ChallengeType.DynamicAttachment) - { - foreach (var flag in challenge.Flags) - { - if (flag.Attachment is not null && - flag.Attachment.Type == FileType.Local && - flag.Attachment.LocalFile is not null) - { - await _fileRepository.DeleteFileByHash( - flag.Attachment.LocalFile.Hash, token); - } - } - } - else if (challenge.Attachment is not null && - challenge.Attachment.Type == FileType.Local && - challenge.Attachment.LocalFile is not null) - { - await _fileRepository.DeleteFileByHash( - challenge.Attachment.LocalFile.Hash, token); - } + await DeleteAllAttachment(challenge, true, token); _context.Remove(challenge); await SaveAsync(token); @@ -110,13 +94,7 @@ public async Task RemoveFlag(Challenge challenge, int flagId, Cancel if (flag is null) return TaskStatus.NotFound; - if (flag.Attachment is not null && - flag.Attachment.Type == FileType.Local && - flag.Attachment.LocalFile is not null) - { - await _fileRepository.DeleteFileByHash( - flag.Attachment.LocalFile.Hash, token); - } + await DeleteAttachment(flag.Attachment, token); _context.Remove(flag); @@ -137,17 +115,7 @@ public async Task UpdateAttachment(Challenge challenge, AttachmentCreateModel mo RemoteUrl = model.RemoteUrl }; - if (challenge.Attachment is not null) - { - if (challenge.Attachment.Type == FileType.Local && - challenge.Attachment.LocalFile is not null) - { - await _fileRepository.DeleteFileByHash( - challenge.Attachment.LocalFile.Hash, token); - } - - _context.Remove(challenge.Attachment); - } + await DeleteAllAttachment(challenge, false, token); if (attachment is not null) await _context.AddAsync(attachment, token); @@ -157,6 +125,27 @@ await _fileRepository.DeleteFileByHash( await SaveAsync(token); } - public Task VerifyStaticAnswer(Challenge challenge, string flag, CancellationToken token = default) - => _context.Entry(challenge).Collection(e => e.Flags).Query().AnyAsync(f => f.Flag == flag, token); + internal async Task DeleteAllAttachment(Challenge challenge, bool purge = false, CancellationToken token = default) + { + await DeleteAttachment(challenge.Attachment, token); + + if (purge && challenge.Type == ChallengeType.DynamicAttachment) + { + foreach (var flag in challenge.Flags) + await DeleteAttachment(flag.Attachment, token); + + _context.RemoveRange(challenge.Flags); + } + } + + internal async Task DeleteAttachment(Attachment? attachment, CancellationToken token = default) + { + if (attachment is null) + return; + + if (attachment.Type == FileType.Local && attachment.LocalFile is not null) + await _fileRepository.DeleteFile(attachment.LocalFile, token); + + _context.Remove(attachment); + } } diff --git a/src/GZCTF/Repositories/FileRepository.cs b/src/GZCTF/Repositories/FileRepository.cs index f4556abe6..2ac5d41d3 100644 --- a/src/GZCTF/Repositories/FileRepository.cs +++ b/src/GZCTF/Repositories/FileRepository.cs @@ -72,7 +72,7 @@ public async Task CreateOrUpdateFile(IFormFile file, string? fileName return await StoreLocalFile(fileName ?? file.FileName, tmp, token); } - public async Task CreateOrUpdateImage(IFormFile file, string fileName, CancellationToken token = default, int resize = 300) + public async Task CreateOrUpdateImage(IFormFile file, string fileName, int resize = 300, CancellationToken token = default) { // we do not process images larger than 32MB if (file.Length >= 32 * 1024 * 1024) diff --git a/src/GZCTF/Repositories/GameRepository.cs b/src/GZCTF/Repositories/GameRepository.cs index 56b610d0e..fbd19ff6b 100644 --- a/src/GZCTF/Repositories/GameRepository.cs +++ b/src/GZCTF/Repositories/GameRepository.cs @@ -14,12 +14,16 @@ public class GameRepository : RepositoryBase, IGameRepository { private readonly IDistributedCache _cache; private readonly ITeamRepository _teamRepository; + private readonly IChallengeRepository _challengeRepository; + private readonly IParticipationRepository _participationRepository; private readonly byte[]? _xorkey; private readonly ILogger _logger; private readonly ChannelWriter _cacheRequestChannelWriter; public GameRepository(IDistributedCache cache, ITeamRepository teamRepository, + IChallengeRepository challengeRepository, + IParticipationRepository participationRepository, IConfiguration configuration, ILogger logger, ChannelWriter cacheRequestChannelWriter, @@ -28,6 +32,8 @@ public GameRepository(IDistributedCache cache, _cache = cache; _logger = logger; _teamRepository = teamRepository; + _challengeRepository = challengeRepository; + _participationRepository = participationRepository; _cacheRequestChannelWriter = cacheRequestChannelWriter; var xorkeyStr = configuration["XorKey"]; @@ -92,11 +98,37 @@ public async Task GetScoreboardWithMembers(Game game, Cancellat public async Task DeleteGame(Game game, CancellationToken token = default) { - _context.Remove(game); - _cache.Remove(CacheKey.BasicGameInfo); - _cache.Remove(CacheKey.ScoreBoard(game.Id)); + var trans = await BeginTransactionAsync(token); - await SaveAsync(token); + try + { + await _context.Entry(game).Collection(g => g.Challenges).LoadAsync(token); + + _logger.SystemLog($"正在清理比赛 {game.Title} 的 {game.Challenges.Count} 个题目的相关附件……", TaskStatus.Pending, LogLevel.Debug); + + foreach (var chal in game.Challenges) + await _challengeRepository.RemoveChallenge(chal, token); + + await _context.Entry(game).Collection(g => g.Participations).LoadAsync(token); + + _logger.SystemLog($"正在清理比赛 {game.Title} 的 {game.Participations.Count} 个队伍相关文件……", TaskStatus.Pending, LogLevel.Debug); + + foreach (var part in game.Participations) + await _participationRepository.RemoveParticipation(part, token); + + _context.Remove(game); + + await SaveAsync(token); + await trans.CommitAsync(token); + + _cache.Remove(CacheKey.BasicGameInfo); + _cache.Remove(CacheKey.ScoreBoard(game.Id)); + } + catch + { + await trans.RollbackAsync(token); + throw; + } } public Task GetGames(int count, int skip, CancellationToken token) diff --git a/src/GZCTF/Repositories/Interface/IFileRepository.cs b/src/GZCTF/Repositories/Interface/IFileRepository.cs index bafd7d72a..1b4516478 100644 --- a/src/GZCTF/Repositories/Interface/IFileRepository.cs +++ b/src/GZCTF/Repositories/Interface/IFileRepository.cs @@ -16,10 +16,10 @@ public interface IFileRepository : IRepository ///
/// 文件对象 /// 保存文件名 - /// 取消Token /// 缩放后的宽,设置为 0 则不缩放 + /// 取消Token /// 文件Id - public Task CreateOrUpdateImage(IFormFile file, string fileName, CancellationToken token = default, int resize = 300); + public Task CreateOrUpdateImage(IFormFile file, string fileName, int resize = 300, CancellationToken token = default); /// /// 删除一个文件 diff --git a/src/GZCTF/Repositories/ParticipationRepository.cs b/src/GZCTF/Repositories/ParticipationRepository.cs index fb9b4392a..bd2624525 100644 --- a/src/GZCTF/Repositories/ParticipationRepository.cs +++ b/src/GZCTF/Repositories/ParticipationRepository.cs @@ -7,12 +7,15 @@ namespace GZCTF.Repositories; public class ParticipationRepository : RepositoryBase, IParticipationRepository { private readonly IGameRepository _gameRepository; + private readonly IFileRepository _fileRepository; public ParticipationRepository( IGameRepository gameRepository, + IFileRepository fileRepository, AppDbContext context) : base(context) { _gameRepository = gameRepository; + _fileRepository = fileRepository; } public async Task EnsureInstances(Participation part, Game game, CancellationToken token = default) @@ -101,9 +104,12 @@ public async Task RemoveUserParticipations(UserInfo user, Team team, Cancellatio => _context.RemoveRange(await _context.UserParticipations .Where(p => p.User == user && p.Team == team).ToArrayAsync(token)); - public Task RemoveParticipation(Participation part, CancellationToken token = default) + public async Task RemoveParticipation(Participation part, CancellationToken token = default) { + if (part.Writeup is not null) + await _fileRepository.DeleteFile(part.Writeup, token); + _context.Remove(part); - return SaveAsync(token); + await SaveAsync(token); } } From 785c01709c9022d729348dd5d04585fdcbfc2463 Mon Sep 17 00:00:00 2001 From: GZTime Date: Sat, 19 Aug 2023 22:26:41 +0800 Subject: [PATCH 43/67] fix: independent cache helper from repository --- src/GZCTF/Controllers/EditController.cs | 43 ++++++++- src/GZCTF/Extensions/CacheExtensions.cs | 44 +++++++++ src/GZCTF/Program.cs | 2 + .../Repositories/GameNoticeRepository.cs | 3 +- src/GZCTF/Repositories/GameRepository.cs | 26 +++-- .../Repositories/Interface/IGameRepository.cs | 9 +- .../Interface/IParticipationRepository.cs | 8 ++ .../Repositories/ParticipationRepository.cs | 21 +++-- src/GZCTF/Repositories/PostRepository.cs | 3 +- src/GZCTF/Services/Cache/CacheHelper.cs | 72 ++++++++++++++ src/GZCTF/Services/{ => Cache}/CacheMaker.cs | 0 src/GZCTF/Services/FlagChecker.cs | 5 +- src/GZCTF/Utils/CacheHelper.cs | 94 ------------------- 13 files changed, 206 insertions(+), 124 deletions(-) create mode 100644 src/GZCTF/Extensions/CacheExtensions.cs create mode 100644 src/GZCTF/Services/Cache/CacheHelper.cs rename src/GZCTF/Services/{ => Cache}/CacheMaker.cs (100%) delete mode 100644 src/GZCTF/Utils/CacheHelper.cs diff --git a/src/GZCTF/Controllers/EditController.cs b/src/GZCTF/Controllers/EditController.cs index 57f8c91ec..c6cb4d652 100644 --- a/src/GZCTF/Controllers/EditController.cs +++ b/src/GZCTF/Controllers/EditController.cs @@ -5,6 +5,7 @@ using GZCTF.Models.Request.Game; using GZCTF.Models.Request.Info; using GZCTF.Repositories.Interface; +using GZCTF.Services; using GZCTF.Services.Interface; using GZCTF.Utils; using Microsoft.AspNetCore.Identity; @@ -24,6 +25,7 @@ namespace GZCTF.Controllers; public class EditController : Controller { private readonly ILogger _logger; + private readonly CacheHelper _cacheHelper; private readonly UserManager _userManager; private readonly IPostRepository _postRepository; private readonly IGameNoticeRepository _gameNoticeRepository; @@ -33,7 +35,9 @@ public class EditController : Controller private readonly IContainerManager _containerService; private readonly IContainerRepository _containerRepository; - public EditController(UserManager userManager, + public EditController( + CacheHelper cacheHelper, + UserManager userManager, ILogger logger, IPostRepository postRepository, IContainerRepository containerRepository, @@ -44,6 +48,7 @@ public EditController(UserManager userManager, IFileRepository fileService) { _logger = logger; + _cacheHelper = cacheHelper; _fileService = fileService; _userManager = userManager; _gameRepository = gameRepository; @@ -235,7 +240,7 @@ public async Task UpdateGame([FromRoute] int id, [FromBody] GameI game.Update(model); await _gameRepository.SaveAsync(token); _gameRepository.FlushGameInfoCache(); - await _gameRepository.FlushScoreboardCache(game.Id, token); + await _cacheHelper.FlushScoreboardCache(game.Id, token); return Ok(GameInfoModel.FromGame(game)); } @@ -251,6 +256,7 @@ public async Task UpdateGame([FromRoute] int id, [FromBody] GameI /// 成功删除比赛 [HttpDelete("Games/{id}")] [ProducesResponseType(typeof(GameInfoModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status400BadRequest)] [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status404NotFound)] public async Task DeleteGame([FromRoute] int id, CancellationToken token) { @@ -259,7 +265,34 @@ public async Task DeleteGame([FromRoute] int id, CancellationToke if (game is null) return NotFound(new RequestResponse("比赛未找到", 404)); - await _gameRepository.DeleteGame(game, token); + return await _gameRepository.DeleteGame(game, token) switch + { + TaskStatus.Success => Ok(), + TaskStatus.Failed => BadRequest(new RequestResponse("比赛删除失败,文件可能已受损,请重试")), + _ => throw new NotImplementedException() + }; + } + + /// + /// 删除比赛的全部 WriteUp + /// + /// + /// 删除比赛的全部 WriteUp,需要管理员权限 + /// + /// + /// + /// 成功删除比赛 WriteUps + [HttpDelete("Games/{id}/WriteUps")] + [ProducesResponseType(typeof(GameInfoModel), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status404NotFound)] + public async Task DeleteGameWriteUps([FromRoute] int id, CancellationToken token) + { + var game = await _gameRepository.GetGameById(id, token); + + if (game is null) + return NotFound(new RequestResponse("比赛未找到", 404)); + + await _gameRepository.DeleteAllWriteUps(game, token); return Ok(); } @@ -553,7 +586,7 @@ await _gameNoticeRepository.AddNotice(new() } // always flush scoreboard - await _gameRepository.FlushScoreboardCache(game.Id, token); + await _cacheHelper.FlushScoreboardCache(game.Id, token); return Ok(ChallengeEditDetailModel.FromChallenge(res)); } @@ -676,7 +709,7 @@ public async Task RemoveGameChallenge([FromRoute] int id, [FromRo await _challengeRepository.RemoveChallenge(res, token); // always flush scoreboard - await _gameRepository.FlushScoreboardCache(game.Id, token); + await _cacheHelper.FlushScoreboardCache(game.Id, token); return Ok(); } diff --git a/src/GZCTF/Extensions/CacheExtensions.cs b/src/GZCTF/Extensions/CacheExtensions.cs new file mode 100644 index 000000000..d12b760cf --- /dev/null +++ b/src/GZCTF/Extensions/CacheExtensions.cs @@ -0,0 +1,44 @@ +using GZCTF.Utils; +using MemoryPack; +using Microsoft.Extensions.Caching.Distributed; + +namespace GZCTF.Extensions; + +public static class CacheExtensions +{ + /// + /// 获取缓存或重新构建,如果缓存不存在会阻塞 + /// 使用 CacheMaker 和 CacheRequest 代替处理耗时更久的缓存 + /// + public static async Task GetOrCreateAsync(this IDistributedCache cache, + ILogger logger, + string key, + Func> func, + CancellationToken token = default) + where T : class + { + var value = await cache.GetAsync(key, token); + T? result = default; + + if (value is not null) + { + try + { + result = MemoryPackSerializer.Deserialize(value); + } + catch + { } + if (result is not null) + return result; + } + + var cacheOptions = new DistributedCacheEntryOptions(); + result = await func(cacheOptions); + var bytes = MemoryPackSerializer.Serialize(result); + + await cache.SetAsync(key, bytes, cacheOptions, token); + logger.SystemLog($"重建缓存:{key} @ {bytes.Length} bytes", TaskStatus.Success, LogLevel.Debug); + + return result; + } +} diff --git a/src/GZCTF/Program.cs b/src/GZCTF/Program.cs index 73eee5470..8df23f802 100644 --- a/src/GZCTF/Program.cs +++ b/src/GZCTF/Program.cs @@ -214,6 +214,8 @@ builder.Services.AddChannel(); builder.Services.AddChannel(); +builder.Services.AddSingleton(); + builder.Services.AddHostedService(); builder.Services.AddHostedService(); builder.Services.AddHostedService(); diff --git a/src/GZCTF/Repositories/GameNoticeRepository.cs b/src/GZCTF/Repositories/GameNoticeRepository.cs index a9da81d13..1e55b10ad 100644 --- a/src/GZCTF/Repositories/GameNoticeRepository.cs +++ b/src/GZCTF/Repositories/GameNoticeRepository.cs @@ -1,4 +1,5 @@ -using GZCTF.Hubs; +using GZCTF.Extensions; +using GZCTF.Hubs; using GZCTF.Hubs.Clients; using GZCTF.Repositories.Interface; using GZCTF.Utils; diff --git a/src/GZCTF/Repositories/GameRepository.cs b/src/GZCTF/Repositories/GameRepository.cs index fbd19ff6b..815ec8c1e 100644 --- a/src/GZCTF/Repositories/GameRepository.cs +++ b/src/GZCTF/Repositories/GameRepository.cs @@ -1,5 +1,5 @@ using System.Text; -using System.Threading.Channels; +using GZCTF.Extensions; using GZCTF.Models.Request.Game; using GZCTF.Repositories.Interface; using GZCTF.Services; @@ -18,7 +18,6 @@ public class GameRepository : RepositoryBase, IGameRepository private readonly IParticipationRepository _participationRepository; private readonly byte[]? _xorkey; private readonly ILogger _logger; - private readonly ChannelWriter _cacheRequestChannelWriter; public GameRepository(IDistributedCache cache, ITeamRepository teamRepository, @@ -26,7 +25,6 @@ public GameRepository(IDistributedCache cache, IParticipationRepository participationRepository, IConfiguration configuration, ILogger logger, - ChannelWriter cacheRequestChannelWriter, AppDbContext context) : base(context) { _cache = cache; @@ -34,7 +32,6 @@ public GameRepository(IDistributedCache cache, _teamRepository = teamRepository; _challengeRepository = challengeRepository; _participationRepository = participationRepository; - _cacheRequestChannelWriter = cacheRequestChannelWriter; var xorkeyStr = configuration["XorKey"]; _xorkey = string.IsNullOrEmpty(xorkeyStr) ? null : Encoding.UTF8.GetBytes(xorkeyStr); @@ -96,7 +93,7 @@ public async Task GetScoreboardWithMembers(Game game, Cancellat } - public async Task DeleteGame(Game game, CancellationToken token = default) + public async Task DeleteGame(Game game, CancellationToken token = default) { var trans = await BeginTransactionAsync(token); @@ -123,23 +120,34 @@ public async Task DeleteGame(Game game, CancellationToken token = default) _cache.Remove(CacheKey.BasicGameInfo); _cache.Remove(CacheKey.ScoreBoard(game.Id)); + + return TaskStatus.Success; } catch { + _logger.SystemLog($"删除比赛失败,相关文件可能已受损,请重新删除", TaskStatus.Pending, LogLevel.Debug); await trans.RollbackAsync(token); - throw; + + return TaskStatus.Failed; } } + public async Task DeleteAllWriteUps(Game game, CancellationToken token = default) + { + await _context.Entry(game).Collection(g => g.Participations).LoadAsync(token); + + _logger.SystemLog($"正在清理比赛 {game.Title} 的 {game.Participations.Count} 个队伍相关文件……", TaskStatus.Pending, LogLevel.Debug); + + foreach (var part in game.Participations) + await _participationRepository.DeleteParticipationWriteUp(part, token); + } + public Task GetGames(int count, int skip, CancellationToken token) => _context.Games.OrderByDescending(g => g.Id).Skip(skip).Take(count).ToArrayAsync(token); public void FlushGameInfoCache() => _cache.Remove(CacheKey.BasicGameInfo); - public async Task FlushScoreboardCache(int gameId, CancellationToken token) - => await _cacheRequestChannelWriter.WriteAsync(ScoreboardCacheHandler.MakeCacheRequest(gameId), token); - #region Generate Scoreboard private record Data(Instance Instance, Submission? Submission); diff --git a/src/GZCTF/Repositories/Interface/IGameRepository.cs b/src/GZCTF/Repositories/Interface/IGameRepository.cs index 5ce74a89d..6cc39fb10 100644 --- a/src/GZCTF/Repositories/Interface/IGameRepository.cs +++ b/src/GZCTF/Repositories/Interface/IGameRepository.cs @@ -75,14 +75,15 @@ public interface IGameRepository : IRepository /// 比赛对象 /// /// - public Task DeleteGame(Game game, CancellationToken token = default); + public Task DeleteGame(Game game, CancellationToken token = default); /// - /// 刷新排行榜 + /// 删除比赛的全部 WriteUp /// - /// 比赛Id + /// 比赛对象 /// - public Task FlushScoreboardCache(int gameId, CancellationToken token); + /// + public Task DeleteAllWriteUps(Game game, CancellationToken token = default); /// /// 生成排行榜 diff --git a/src/GZCTF/Repositories/Interface/IParticipationRepository.cs b/src/GZCTF/Repositories/Interface/IParticipationRepository.cs index 111384e84..a7be743bd 100644 --- a/src/GZCTF/Repositories/Interface/IParticipationRepository.cs +++ b/src/GZCTF/Repositories/Interface/IParticipationRepository.cs @@ -99,6 +99,14 @@ public interface IParticipationRepository : IRepository /// public Task GetParticipation(Team team, Game game, CancellationToken token = default); + /// + /// 删除参与对象的 WriteUps + /// + /// 参与对象 + /// + /// + public Task DeleteParticipationWriteUp(Participation part, CancellationToken token = default); + /// /// 删除参与对象 /// diff --git a/src/GZCTF/Repositories/ParticipationRepository.cs b/src/GZCTF/Repositories/ParticipationRepository.cs index bd2624525..9f87ac624 100644 --- a/src/GZCTF/Repositories/ParticipationRepository.cs +++ b/src/GZCTF/Repositories/ParticipationRepository.cs @@ -1,20 +1,21 @@ using GZCTF.Models.Request.Admin; using GZCTF.Repositories.Interface; +using GZCTF.Services; using Microsoft.EntityFrameworkCore; namespace GZCTF.Repositories; public class ParticipationRepository : RepositoryBase, IParticipationRepository { - private readonly IGameRepository _gameRepository; + private readonly CacheHelper _cacheHelper; private readonly IFileRepository _fileRepository; public ParticipationRepository( - IGameRepository gameRepository, + CacheHelper cacheHelper, IFileRepository fileRepository, AppDbContext context) : base(context) { - _gameRepository = gameRepository; + _cacheHelper = cacheHelper; _fileRepository = fileRepository; } @@ -78,7 +79,7 @@ public async Task UpdateParticipationStatus(Participation part, ParticipationSta // also flush scoreboard when a team is re-accepted if (await EnsureInstances(part, part.Game, token) || oldStatus == ParticipationStatus.Suspended) // flush scoreboard when instances are updated - await _gameRepository.FlushScoreboardCache(part.Game.Id, token); + await _cacheHelper.FlushScoreboardCache(part.Game.Id, token); return; } @@ -88,7 +89,7 @@ public async Task UpdateParticipationStatus(Participation part, ParticipationSta // flush scoreboard when a team is suspended if (status == ParticipationStatus.Suspended && part.Game.IsActive) - await _gameRepository.FlushScoreboardCache(part.GameId, token); + await _cacheHelper.FlushScoreboardCache(part.GameId, token); } public Task GetParticipationsByIds(IEnumerable ids, CancellationToken token = default) @@ -106,10 +107,16 @@ public async Task RemoveUserParticipations(UserInfo user, Team team, Cancellatio public async Task RemoveParticipation(Participation part, CancellationToken token = default) { - if (part.Writeup is not null) - await _fileRepository.DeleteFile(part.Writeup, token); + await DeleteParticipationWriteUp(part, token); _context.Remove(part); await SaveAsync(token); } + + public Task DeleteParticipationWriteUp(Participation part, CancellationToken token = default) + { + if (part.Writeup is not null) + return _fileRepository.DeleteFile(part.Writeup, token); + return Task.CompletedTask; + } } diff --git a/src/GZCTF/Repositories/PostRepository.cs b/src/GZCTF/Repositories/PostRepository.cs index 9ce9c8b3d..01d9c93ed 100644 --- a/src/GZCTF/Repositories/PostRepository.cs +++ b/src/GZCTF/Repositories/PostRepository.cs @@ -1,4 +1,5 @@ -using GZCTF.Repositories.Interface; +using GZCTF.Extensions; +using GZCTF.Repositories.Interface; using GZCTF.Utils; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Distributed; diff --git a/src/GZCTF/Services/Cache/CacheHelper.cs b/src/GZCTF/Services/Cache/CacheHelper.cs new file mode 100644 index 000000000..30dd8fe88 --- /dev/null +++ b/src/GZCTF/Services/Cache/CacheHelper.cs @@ -0,0 +1,72 @@ +using System.Threading.Channels; +using GZCTF.Repositories; + +namespace GZCTF.Services +{ + public class CacheHelper + { + private readonly ChannelWriter _channelWriter; + + public CacheHelper(ChannelWriter channelWriter) + { + _channelWriter = channelWriter; + } + + public async Task FlushScoreboardCache(int gameId, CancellationToken token) + => await _channelWriter.WriteAsync(ScoreboardCacheHandler.MakeCacheRequest(gameId), token); + } +} + +namespace GZCTF.Utils +{ + /// + /// 缓存标识 + /// + public static class CacheKey + { + /// + /// 缓存更新锁 + /// + public static string UpdateLock(string key) => $"_CacheUpdateLock_{key}"; + + /// + /// 积分榜缓存 + /// + public static string ScoreBoard(int id) => $"_ScoreBoard_{id}"; + + /// + /// 积分榜缓存 + /// + public static string ScoreBoard(string id) => $"_ScoreBoard_{id}"; + + /// + /// 积分榜缓存根标识 + /// + public const string ScoreBoardBase = "_ScoreBoard"; + + /// + /// 比赛通知缓存 + /// + public static string GameNotice(int id) => $"_GameNotice_{id}"; + + /// + /// 比赛通知缓存 + /// + public static string GameNotice(string id) => $"_ScoreBoard_{id}"; + + /// + /// 比赛基础信息缓存 + /// + public const string BasicGameInfo = "_BasicGameInfo"; + + /// + /// 文章 + /// + public const string Posts = "_Posts"; + + /// + /// 容器连接数缓存 + /// + public static string ConnectionCount(string id) => $"_Container_Conn_{id}"; + } +} diff --git a/src/GZCTF/Services/CacheMaker.cs b/src/GZCTF/Services/Cache/CacheMaker.cs similarity index 100% rename from src/GZCTF/Services/CacheMaker.cs rename to src/GZCTF/Services/Cache/CacheMaker.cs diff --git a/src/GZCTF/Services/FlagChecker.cs b/src/GZCTF/Services/FlagChecker.cs index a5f152611..15b273e14 100644 --- a/src/GZCTF/Services/FlagChecker.cs +++ b/src/GZCTF/Services/FlagChecker.cs @@ -1,5 +1,4 @@ using System.Threading.Channels; -using GZCTF.Models; using GZCTF.Repositories.Interface; using GZCTF.Utils; using Microsoft.EntityFrameworkCore; @@ -38,10 +37,10 @@ private async Task Checker(int id, CancellationToken token = default) await using var scope = _serviceScopeFactory.CreateAsyncScope(); + var cacheHelper = scope.ServiceProvider.GetRequiredService(); var eventRepository = scope.ServiceProvider.GetRequiredService(); var instanceRepository = scope.ServiceProvider.GetRequiredService(); var gameNoticeRepository = scope.ServiceProvider.GetRequiredService(); - var gameRepository = scope.ServiceProvider.GetRequiredService(); var submissionRepository = scope.ServiceProvider.GetRequiredService(); try @@ -58,7 +57,7 @@ private async Task Checker(int id, CancellationToken token = default) // only flush the scoreboard if the contest is not ended and the submission is accepted if (item.Game.EndTimeUTC > item.SubmitTimeUTC) - await gameRepository.FlushScoreboardCache(item.GameId, token); + await cacheHelper.FlushScoreboardCache(item.GameId, token); } else { diff --git a/src/GZCTF/Utils/CacheHelper.cs b/src/GZCTF/Utils/CacheHelper.cs deleted file mode 100644 index dad7aa464..000000000 --- a/src/GZCTF/Utils/CacheHelper.cs +++ /dev/null @@ -1,94 +0,0 @@ -using MemoryPack; -using Microsoft.Extensions.Caching.Distributed; - -namespace GZCTF.Utils; - -public static class CacheHelper -{ - /// - /// 获取缓存或重新构建,如果缓存不存在会阻塞 - /// 使用 CacheMaker 和 CacheRequest 代替处理耗时更久的缓存 - /// - public static async Task GetOrCreateAsync(this IDistributedCache cache, - ILogger logger, - string key, - Func> func, - CancellationToken token = default) - where T : class - { - var value = await cache.GetAsync(key, token); - T? result = default; - - if (value is not null) - { - try - { - result = MemoryPackSerializer.Deserialize(value); - } - catch - { } - if (result is not null) - return result; - } - - var cacheOptions = new DistributedCacheEntryOptions(); - result = await func(cacheOptions); - var bytes = MemoryPackSerializer.Serialize(result); - - await cache.SetAsync(key, bytes, cacheOptions, token); - logger.SystemLog($"重建缓存:{key} @ {bytes.Length} bytes", TaskStatus.Success, LogLevel.Debug); - - return result; - } -} - -/// -/// 缓存标识 -/// -public static class CacheKey -{ - /// - /// 缓存更新锁 - /// - public static string UpdateLock(string key) => $"_CacheUpdateLock_{key}"; - - /// - /// 积分榜缓存 - /// - public static string ScoreBoard(int id) => $"_ScoreBoard_{id}"; - - /// - /// 积分榜缓存 - /// - public static string ScoreBoard(string id) => $"_ScoreBoard_{id}"; - - /// - /// 积分榜缓存根标识 - /// - public const string ScoreBoardBase = "_ScoreBoard"; - - /// - /// 比赛通知缓存 - /// - public static string GameNotice(int id) => $"_GameNotice_{id}"; - - /// - /// 比赛通知缓存 - /// - public static string GameNotice(string id) => $"_ScoreBoard_{id}"; - - /// - /// 比赛基础信息缓存 - /// - public const string BasicGameInfo = "_BasicGameInfo"; - - /// - /// 文章 - /// - public const string Posts = "_Posts"; - - /// - /// 容器连接数缓存 - /// - public static string ConnectionCount(string id) => $"_Container_Conn_{id}"; -} From 1aef269e7eb2a347f5a9ed2b18e47cc7496da660 Mon Sep 17 00:00:00 2001 From: GZTime Date: Sat, 19 Aug 2023 22:27:39 +0800 Subject: [PATCH 44/67] api: add editDeleteGameWriteUps --- src/GZCTF/ClientApp/src/Api.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/src/GZCTF/ClientApp/src/Api.ts b/src/GZCTF/ClientApp/src/Api.ts index 22284b34b..e05dd47cd 100644 --- a/src/GZCTF/ClientApp/src/Api.ts +++ b/src/GZCTF/ClientApp/src/Api.ts @@ -2978,6 +2978,22 @@ export class Api extends HttpClient + this.request({ + path: `/api/edit/games/${id}/writeups`, + method: 'DELETE', + format: 'json', + ...params, + }), + /** * @description 删除文章,需要管理员权限 * From 3f5f9648ce2a3ff18c8b2e57e9e4c440cb43553c Mon Sep 17 00:00:00 2001 From: GZTime Date: Sat, 19 Aug 2023 22:50:29 +0800 Subject: [PATCH 45/67] fix: cannot upload file --- src/GZCTF/Repositories/FileRepository.cs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/src/GZCTF/Repositories/FileRepository.cs b/src/GZCTF/Repositories/FileRepository.cs index 2ac5d41d3..e30b26619 100644 --- a/src/GZCTF/Repositories/FileRepository.cs +++ b/src/GZCTF/Repositories/FileRepository.cs @@ -9,13 +9,10 @@ namespace GZCTF.Repositories; public class FileRepository : RepositoryBase, IFileRepository { private readonly ILogger _logger; - private readonly string _uploadPath; - public FileRepository(AppDbContext context, IConfiguration configuration, - ILogger logger) : base(context) + public FileRepository(AppDbContext context, ILogger logger) : base(context) { _logger = logger; - _uploadPath = configuration.GetSection("UploadFolder")?.Value ?? "uploads"; } public override Task CountAsync(CancellationToken token = default) => _context.Files.CountAsync(token); @@ -47,7 +44,7 @@ private async Task StoreLocalFile(string fileName, Stream contentStre localFile = new() { Hash = fileHash, Name = fileName, FileSize = contentStream.Length }; await _context.AddAsync(localFile, token); - var path = Path.Combine(_uploadPath, localFile.Location); + var path = Path.Combine(FilePath.Uploads, localFile.Location); if (!Directory.Exists(path)) Directory.CreateDirectory(path); @@ -108,7 +105,7 @@ public async Task CreateOrUpdateFile(IFormFile file, string? fileName public async Task DeleteFile(LocalFile file, CancellationToken token = default) { - var path = Path.Combine(_uploadPath, file.Location, file.Hash); + var path = Path.Combine(FilePath.Uploads, file.Location, file.Hash); if (file.ReferenceCount > 1) { From 73cdc9a8ff38ca23862c06bc191679c3b0a5b665 Mon Sep 17 00:00:00 2001 From: GZTime Date: Sat, 19 Aug 2023 23:31:53 +0800 Subject: [PATCH 46/67] capture: add metadata packet --- src/GZCTF/Controllers/ProxyController.cs | 41 +++++++++++--- src/GZCTF/Repositories/ContainerRepository.cs | 9 ++- .../Interface/IContainerRepository.cs | 8 +++ src/GZCTF/Utils/CapturableNetworkStream.cs | 55 ++++++++++--------- src/GZCTF/Utils/HubHelper.cs | 2 +- src/GZCTF/Utils/LogHelper.cs | 5 +- 6 files changed, 79 insertions(+), 41 deletions(-) diff --git a/src/GZCTF/Controllers/ProxyController.cs b/src/GZCTF/Controllers/ProxyController.cs index bf600754f..d94b4b036 100644 --- a/src/GZCTF/Controllers/ProxyController.cs +++ b/src/GZCTF/Controllers/ProxyController.cs @@ -1,6 +1,7 @@ using System.Net; using System.Net.Sockets; using System.Net.WebSockets; +using System.Text.Json; using GZCTF.Models.Internal; using GZCTF.Repositories.Interface; using GZCTF.Utils; @@ -25,6 +26,10 @@ public class ProxyController : ControllerBase private readonly bool _enableTrafficCapture = false; private const int BUFFER_SIZE = 1024 * 4; private const uint CONNECTION_LIMIT = 64; + private readonly JsonSerializerOptions _JsonOptions = new() + { + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }; public ProxyController(ILogger logger, IDistributedCache cache, IOptions provider, IContainerRepository containerRepository) @@ -60,7 +65,7 @@ public async Task ProxyForInstance(string id, CancellationToken t if (!await IncrementConnectionCount(id)) return BadRequest(new RequestResponse("容器连接数已达上限")); - var container = await _containerRepository.GetContainerById(id, token); + var container = await _containerRepository.GetContainerWithInstanceById(id, token); if (container is null || container.Instance is null || !container.IsProxy) return NotFound(new RequestResponse("不存在的容器")); @@ -76,6 +81,24 @@ public async Task ProxyForInstance(string id, CancellationToken t if (clientIp is null) return BadRequest(new RequestResponse("无效的访问地址")); + var enable = _enableTrafficCapture && container.Instance.Challenge.EnableTrafficCapture; + byte[]? metadata = null; + + if (enable) + { + metadata = JsonSerializer.SerializeToUtf8Bytes(new + { + Challenge = container.Instance.Challenge.Title, + container.Instance.ChallengeId, + + Team = container.Instance.Participation.Team.Name, + container.Instance.Participation.TeamId, + + container.ContainerId, + container.Instance.FlagContext?.Flag + }, _JsonOptions); + } + CapturableNetworkStream? stream; try { @@ -89,13 +112,15 @@ public async Task ProxyForInstance(string id, CancellationToken t return BadRequest(new RequestResponse("容器连接失败")); } - stream = new CapturableNetworkStream(socket, new() - { - Source = new(clientIp, clientPort), - Dest = ipEndPoint, - EnableCapture = _enableTrafficCapture && container.Instance.Challenge.EnableTrafficCapture, - FilePath = container.TrafficPath(HttpContext.Connection.Id), - }); + stream = new CapturableNetworkStream(socket, metadata, + new() + { + Source = new(clientIp, clientPort), + Dest = ipEndPoint, + EnableCapture = enable, + FilePath = container.TrafficPath(HttpContext.Connection.Id), + } + ); } catch (Exception e) { diff --git a/src/GZCTF/Repositories/ContainerRepository.cs b/src/GZCTF/Repositories/ContainerRepository.cs index 4cde3646a..abf057e93 100644 --- a/src/GZCTF/Repositories/ContainerRepository.cs +++ b/src/GZCTF/Repositories/ContainerRepository.cs @@ -19,8 +19,13 @@ public ContainerRepository(IDistributedCache cache, public override Task CountAsync(CancellationToken token = default) => _context.Containers.CountAsync(token); public Task GetContainerById(string guid, CancellationToken token = default) - => _context.Containers.Include(c => c.Instance) - .ThenInclude(i => i!.Challenge) + => _context.Containers.FirstOrDefaultAsync(i => i.Id == guid, token); + + public Task GetContainerWithInstanceById(string guid, CancellationToken token = default) + => _context.Containers.IgnoreAutoIncludes() + .Include(c => c.Instance).ThenInclude(i => i!.Challenge) + .Include(c => c.Instance).ThenInclude(i => i!.FlagContext) + .Include(c => c.Instance).ThenInclude(i => i!.Participation).ThenInclude(p => p.Team) .FirstOrDefaultAsync(i => i.Id == guid, token); public Task> GetContainers(CancellationToken token = default) diff --git a/src/GZCTF/Repositories/Interface/IContainerRepository.cs b/src/GZCTF/Repositories/Interface/IContainerRepository.cs index efc719910..c785706c5 100644 --- a/src/GZCTF/Repositories/Interface/IContainerRepository.cs +++ b/src/GZCTF/Repositories/Interface/IContainerRepository.cs @@ -19,6 +19,14 @@ public interface IContainerRepository : IRepository /// public Task GetContainerById(string guid, CancellationToken token = default); + /// + /// 根据容器数据库 ID 获取容器及实例信息 + /// + /// 容器数据库 ID + /// + /// + public Task GetContainerWithInstanceById(string guid, CancellationToken token = default); + /// /// 容器数据库 ID 对应容器是否存在 /// diff --git a/src/GZCTF/Utils/CapturableNetworkStream.cs b/src/GZCTF/Utils/CapturableNetworkStream.cs index df7f95113..d875be709 100644 --- a/src/GZCTF/Utils/CapturableNetworkStream.cs +++ b/src/GZCTF/Utils/CapturableNetworkStream.cs @@ -39,8 +39,9 @@ public sealed class CapturableNetworkStream : NetworkStream private readonly CapturableNetworkStreamOptions _options; private readonly CaptureFileWriterDevice? _device = null; private readonly PhysicalAddress _dummyPhysicalAddress = PhysicalAddress.Parse("00-11-00-11-00-11"); + private readonly IPEndPoint _host = new(IPAddress.Parse("0.0.0.0"), 65535); - public CapturableNetworkStream(Socket socket, CapturableNetworkStreamOptions options) : base(socket) + public CapturableNetworkStream(Socket socket, byte[]? metadata, CapturableNetworkStreamOptions options) : base(socket) { _options = options; @@ -55,6 +56,9 @@ public CapturableNetworkStream(Socket socket, CapturableNetworkStreamOptions opt _device = new(_options.FilePath, FileMode.Open); _device.Open(LinkLayers.Ethernet); + + if (metadata is not null) + WriteCapturedData(_host, _options.Source, metadata); } } @@ -65,19 +69,7 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation if (!_options.EnableCapture) return count; - var udp = new UdpPacket((ushort)_options.Dest.Port, (ushort)_options.Source.Port) - { - PayloadDataSegment = new ByteArraySegment(buffer[..count].ToArray()) - }; - - var packet = new EthernetPacket(_dummyPhysicalAddress, _dummyPhysicalAddress, EthernetType.IPv6) - { - PayloadPacket = new IPv6Packet(_options.Dest.Address, _options.Source.Address) { PayloadPacket = udp } - }; - - udp.UpdateUdpChecksum(); - - _device?.Write(new RawCapture(LinkLayers.Ethernet, new(), packet.Bytes)); + WriteCapturedData(_options.Dest, _options.Source, buffer); return count; } @@ -85,23 +77,32 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation public override async ValueTask WriteAsync(ReadOnlyMemory buffer, CancellationToken cancellationToken = default) { if (_options.EnableCapture) - { - var udp = new UdpPacket((ushort)_options.Source.Port, (ushort)_options.Dest.Port) - { - PayloadDataSegment = new ByteArraySegment(buffer.ToArray()) - }; + WriteCapturedData(_options.Source, _options.Dest, buffer); - var packet = new EthernetPacket(_dummyPhysicalAddress, _dummyPhysicalAddress, EthernetType.IPv6) - { - PayloadPacket = new IPv6Packet(_options.Source.Address, _options.Dest.Address) { PayloadPacket = udp } - }; + await base.WriteAsync(buffer, cancellationToken); + } - udp.UpdateUdpChecksum(); + /// + /// 向文件写入一条数据记录 + /// + /// 源地址 + /// 目的地址 + /// 数据 + internal void WriteCapturedData(IPEndPoint source, IPEndPoint dest, ReadOnlyMemory buffer) + { + var udp = new UdpPacket((ushort)source.Port, (ushort)dest.Port) + { + PayloadDataSegment = new ByteArraySegment(buffer.ToArray()) + }; - _device?.Write(new RawCapture(LinkLayers.Ethernet, new(), packet.Bytes)); - } + var packet = new EthernetPacket(_dummyPhysicalAddress, _dummyPhysicalAddress, EthernetType.IPv6) + { + PayloadPacket = new IPv6Packet(source.Address, dest.Address) { PayloadPacket = udp } + }; - await base.WriteAsync(buffer, cancellationToken); + udp.UpdateUdpChecksum(); + + _device?.Write(new RawCapture(LinkLayers.Ethernet, new(), packet.Bytes)); } public override void Close() diff --git a/src/GZCTF/Utils/HubHelper.cs b/src/GZCTF/Utils/HubHelper.cs index d362b20b6..8fe3ef480 100644 --- a/src/GZCTF/Utils/HubHelper.cs +++ b/src/GZCTF/Utils/HubHelper.cs @@ -3,7 +3,7 @@ namespace GZCTF.Utils; -public class HubHelper +public static class HubHelper { /// /// 当前请求是否具有权限 diff --git a/src/GZCTF/Utils/LogHelper.cs b/src/GZCTF/Utils/LogHelper.cs index 7b0c5b7a7..dbeb60469 100644 --- a/src/GZCTF/Utils/LogHelper.cs +++ b/src/GZCTF/Utils/LogHelper.cs @@ -3,7 +3,6 @@ using GZCTF.Extensions; using NpgsqlTypes; using Serilog; -using Serilog.Core; using Serilog.Events; using Serilog.Sinks.File.Archive; using Serilog.Sinks.PostgreSQL; @@ -138,14 +137,14 @@ public static Serilog.ILogger GetLogger(IConfiguration configuration, IServicePr restrictedToMinimumLevel: LogEventLevel.Debug )) .WriteTo.Async(t => t.File( - path: $"files/logs/log_.log", + path: $"{FilePath.Logs}/log_.log", formatter: new ExpressionTemplate(LogTemplate), rollingInterval: RollingInterval.Day, fileSizeLimitBytes: 10 * 1024 * 1024, restrictedToMinimumLevel: LogEventLevel.Debug, rollOnFileSizeLimit: true, retainedFileCountLimit: 5, - hooks: new ArchiveHooks(CompressionLevel.Optimal, "files/logs/archive/{UtcDate:yyyy-MM}") + hooks: new ArchiveHooks(CompressionLevel.Optimal, $"{FilePath.Logs}/archive/{{UtcDate:yyyy-MM}}") )) .WriteTo.Async(t => t.PostgreSQL( connectionString: configuration.GetConnectionString("Database"), From 56c0770f8ab3cd38b751c6b5c80dc647948a3489 Mon Sep 17 00:00:00 2001 From: GZTime Date: Sun, 20 Aug 2023 00:26:03 +0800 Subject: [PATCH 47/67] fix: traffic capture --- src/GZCTF/Controllers/ProxyController.cs | 17 ++++++++++------- src/GZCTF/Models/Data/Container.cs | 2 +- src/GZCTF/Utils/CapturableNetworkStream.cs | 2 +- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/GZCTF/Controllers/ProxyController.cs b/src/GZCTF/Controllers/ProxyController.cs index d94b4b036..f26881f4d 100644 --- a/src/GZCTF/Controllers/ProxyController.cs +++ b/src/GZCTF/Controllers/ProxyController.cs @@ -99,11 +99,13 @@ public async Task ProxyForInstance(string id, CancellationToken t }, _JsonOptions); } + IPEndPoint ipEndPoint = new(ipAddress, container.Port); + + using var socket = new Socket(ipEndPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + CapturableNetworkStream? stream; try { - IPEndPoint ipEndPoint = new(ipAddress, container.Port); - using var socket = new Socket(ipEndPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp); await socket.ConnectAsync(ipEndPoint, token); if (!socket.Connected) @@ -128,7 +130,7 @@ public async Task ProxyForInstance(string id, CancellationToken t return BadRequest(new RequestResponse("容器连接失败")); } - var ws = await HttpContext.WebSockets.AcceptWebSocketAsync(); + using var ws = await HttpContext.WebSockets.AcceptWebSocketAsync(); try { @@ -143,7 +145,6 @@ public async Task ProxyForInstance(string id, CancellationToken t { await DecrementConnectionCount(id); stream.Close(); - ws.Dispose(); } return new EmptyResult(); @@ -182,15 +183,16 @@ public async Task ProxyForInstance(string id, CancellationToken t } } catch (TaskCanceledException) { } + catch (Exception) { throw; } finally { cts.Cancel(); } }, ct); var receiver = Task.Run(async () => { var buffer = new byte[BUFFER_SIZE]; - try - { - while (true) + try + { + while (true) { var count = await stream.ReadAsync(buffer, ct); if (count == 0) @@ -203,6 +205,7 @@ public async Task ProxyForInstance(string id, CancellationToken t } } catch (TaskCanceledException) { } + catch (Exception) { throw; } finally { cts.Cancel(); } }, ct); diff --git a/src/GZCTF/Models/Data/Container.cs b/src/GZCTF/Models/Data/Container.cs index 36a6f23d3..1a7bd1eb9 100644 --- a/src/GZCTF/Models/Data/Container.cs +++ b/src/GZCTF/Models/Data/Container.cs @@ -78,7 +78,7 @@ public class Container /// public string TrafficPath(string conn) => Instance is null ? string.Empty : Path.Combine(FilePath.Capture, - $"{Instance.ChallengeId}/{Instance.ParticipationId}/{DateTimeOffset.Now:s}-{conn}.pcap"); + $"{Instance.ChallengeId}/{Instance.ParticipationId}/{DateTimeOffset.Now:yyyyMMdd-HH.mm.ssZ}-{conn}.pcap"); #region Db Relationship diff --git a/src/GZCTF/Utils/CapturableNetworkStream.cs b/src/GZCTF/Utils/CapturableNetworkStream.cs index d875be709..732367af6 100644 --- a/src/GZCTF/Utils/CapturableNetworkStream.cs +++ b/src/GZCTF/Utils/CapturableNetworkStream.cs @@ -69,7 +69,7 @@ public override async ValueTask ReadAsync(Memory buffer, Cancellation if (!_options.EnableCapture) return count; - WriteCapturedData(_options.Dest, _options.Source, buffer); + WriteCapturedData(_options.Dest, _options.Source, buffer[..count]); return count; } From 05fb758f80853421e7d39fe5e9c5714d2e8ab50e Mon Sep 17 00:00:00 2001 From: GZTime Date: Sun, 20 Aug 2023 01:41:44 +0800 Subject: [PATCH 48/67] wip: proxy for test containers --- src/GZCTF/ClientApp/src/Api.ts | 15 ++++ .../src/components/InstanceEntry.tsx | 64 ++++++++------ .../games/[id]/challenges/[chalId]/Index.tsx | 30 ++----- src/GZCTF/ClientApp/src/utils/Shared.tsx | 4 +- src/GZCTF/Controllers/ProxyController.cs | 86 +++++++++++++++---- src/GZCTF/Utils/CapturableNetworkStream.cs | 8 +- 6 files changed, 133 insertions(+), 74 deletions(-) diff --git a/src/GZCTF/ClientApp/src/Api.ts b/src/GZCTF/ClientApp/src/Api.ts index e05dd47cd..89ef7023b 100644 --- a/src/GZCTF/ClientApp/src/Api.ts +++ b/src/GZCTF/ClientApp/src/Api.ts @@ -4696,6 +4696,21 @@ export class Api extends HttpClient + this.request({ + path: `/api/proxy/noinst/${id}`, + method: 'GET', + ...params, + }), } team = { /** diff --git a/src/GZCTF/ClientApp/src/components/InstanceEntry.tsx b/src/GZCTF/ClientApp/src/components/InstanceEntry.tsx index 288e53ac8..3f4eb7c26 100644 --- a/src/GZCTF/ClientApp/src/components/InstanceEntry.tsx +++ b/src/GZCTF/ClientApp/src/components/InstanceEntry.tsx @@ -28,11 +28,12 @@ import { useTooltipStyles } from '@Utils/ThemeOverride' import { ClientFlagContext } from '@Api' interface InstanceEntryProps { + test?: boolean context: ClientFlagContext disabled: boolean - onCreate: () => void - onProlong: () => void - onDestroy: () => void + onCreate?: () => void + onProlong?: () => void + onDestroy?: () => void } dayjs.extend(duration) @@ -69,7 +70,7 @@ const Countdown: FC = ({ time, prolongNotice }) => { } export const InstanceEntry: FC = (props) => { - const { context, disabled, onCreate, onDestroy } = props + const { test, context, disabled, onCreate, onDestroy } = props const clipBoard = useClipboard() @@ -79,7 +80,7 @@ export const InstanceEntry: FC = (props) => { const instanceEntry = context.instanceEntry ?? '' const isPlatformProxy = instanceEntry.length === 36 && !instanceEntry.includes(':') - const copyEntry = isPlatformProxy ? getProxyUrl(instanceEntry) : instanceEntry + const copyEntry = isPlatformProxy ? getProxyUrl(instanceEntry, test) : instanceEntry const [canProlong, setCanProlong] = useState(false) @@ -103,7 +104,7 @@ export const InstanceEntry: FC = (props) => { }, [context]) const onProlong = () => { - if (!canProlong) return + if (!canProlong || !props.onProlong) return props.onProlong() setCanProlong(false) @@ -146,7 +147,11 @@ export const InstanceEntry: FC = (props) => { } if (!withContainer) { - return ( + return test ? ( + + 测试容器未开启 + + ) : ( @@ -165,11 +170,12 @@ export const InstanceEntry: FC = (props) => { } return ( - + 实例入口} description={ - isPlatformProxy && ( + isPlatformProxy && + !test && ( 平台已启用代理模式,建议使用专用客户端。 = (props) => { } rightSectionWidth="5rem" /> - - - - 剩余时间: - - - - 你可以在到期前 10 分钟内延长时间 - - - - - - + {!test && ( + + + + 剩余时间: + + + + 你可以在到期前 10 分钟内延长时间 + + + + + + + - + )} ) } diff --git a/src/GZCTF/ClientApp/src/pages/admin/games/[id]/challenges/[chalId]/Index.tsx b/src/GZCTF/ClientApp/src/pages/admin/games/[id]/challenges/[chalId]/Index.tsx index 0ee6d225e..0b1dcbc55 100644 --- a/src/GZCTF/ClientApp/src/pages/admin/games/[id]/challenges/[chalId]/Index.tsx +++ b/src/GZCTF/ClientApp/src/pages/admin/games/[id]/challenges/[chalId]/Index.tsx @@ -12,11 +12,9 @@ import { Textarea, TextInput, Grid, - Code, Switch, Title, } from '@mantine/core' -import { useClipboard } from '@mantine/hooks' import { useModals } from '@mantine/modals' import { showNotification } from '@mantine/notifications' import { @@ -29,6 +27,7 @@ import { } from '@mdi/js' import { Icon } from '@mdi/react' import HintList from '@Components/HintList' +import InstanceEntry from '@Components/InstanceEntry' import ChallengePreviewModal from '@Components/admin/ChallengePreviewModal' import ScoreFunc from '@Components/admin/ScoreFunc' import { SwitchLabel } from '@Components/admin/SwitchLabel' @@ -63,7 +62,6 @@ const GameChallengeEdit: FC = () => { const [previewOpend, setPreviewOpend] = useState(false) const modals = useModals() - const clipBoard = useClipboard() useEffect(() => { if (challenge) { @@ -416,24 +414,14 @@ const GameChallengeEdit: FC = () => { /> - - {challenge?.testContainer ? ( - ({ - backgroundColor: 'transparent', - fontSize: theme.fontSizes.sm, - fontWeight: 'bold', - })} - onClick={() => clipBoard.copy(challenge?.testContainer?.entry ?? '')} - > - {challenge?.testContainer?.entry ?? ''} - - ) : ( - - 测试容器未开启 - - )} - + ([ [null, 'gray'], ]) -export const getProxyUrl = (guid: string) => { +export const getProxyUrl = (guid: string, test: boolean = false) => { const protocol = window.location.protocol.replace('http', 'ws') - return `${protocol}//${window.location.host}/api/proxy/${guid}` + return `${protocol}//${window.location.host}/api/proxy${test && '/noinst'}/${guid}` } diff --git a/src/GZCTF/Controllers/ProxyController.cs b/src/GZCTF/Controllers/ProxyController.cs index f26881f4d..0e4494d2e 100644 --- a/src/GZCTF/Controllers/ProxyController.cs +++ b/src/GZCTF/Controllers/ProxyController.cs @@ -99,30 +99,78 @@ public async Task ProxyForInstance(string id, CancellationToken t }, _JsonOptions); } - IPEndPoint ipEndPoint = new(ipAddress, container.Port); + IPEndPoint client = new(clientIp, clientPort); + IPEndPoint target = new(ipAddress, container.Port); - using var socket = new Socket(ipEndPoint.AddressFamily, SocketType.Stream, ProtocolType.Tcp); + return await DoContainerProxy(id, client, target, metadata, new() + { + Source = client, + Dest = target, + EnableCapture = enable, + FilePath = container.TrafficPath(HttpContext.Connection.Id), + }, token); + } + + /// + /// 采用 websocket 代理 TCP 流量,为测试容器使用 + /// + /// 测试容器 id + /// + /// + [Route("NoInst/{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status404NotFound)] + [ProducesResponseType(typeof(RequestResponse), StatusCodes.Status400BadRequest)] + public async Task ProxyForNoInstance(string id, CancellationToken token = default) + { + if (!_enablePlatformProxy) + return BadRequest(new RequestResponse("TCP 代理已禁用")); + + if (!await ValidateContainer(id, token)) + return NotFound(new RequestResponse("不存在的容器")); + + if (!HttpContext.WebSockets.IsWebSocketRequest) + return NoContent(); + + var container = await _containerRepository.GetContainerById(id, token); + + if (container is null || container.InstanceId != 0 || !container.IsProxy) + return NotFound(new RequestResponse("不存在的容器")); + + var ipAddress = (await Dns.GetHostAddressesAsync(container.IP, token)).FirstOrDefault(); + + if (ipAddress is null) + return BadRequest(new RequestResponse("容器地址解析失败")); + + var clientIp = HttpContext.Connection.RemoteIpAddress; + var clientPort = HttpContext.Connection.RemotePort; + + if (clientIp is null) + return BadRequest(new RequestResponse("无效的访问地址")); + + IPEndPoint client = new(clientIp, clientPort); + IPEndPoint target = new(ipAddress, container.Port); + + return await DoContainerProxy(id, client, target, null, new(), token); + } + + internal async Task DoContainerProxy(string id, IPEndPoint client, IPEndPoint target, + byte[]? metadata, CapturableNetworkStreamOptions options, CancellationToken token = default) + { + using var socket = new Socket(target.AddressFamily, SocketType.Stream, ProtocolType.Tcp); CapturableNetworkStream? stream; try { - await socket.ConnectAsync(ipEndPoint, token); + await socket.ConnectAsync(target, token); if (!socket.Connected) { - _logger.SystemLog($"容器连接失败,请检查网络配置 -> {container.IP}:{container.Port}", TaskStatus.Failed, LogLevel.Warning); + _logger.SystemLog($"容器连接失败,请检查网络配置 -> {target.Address}:{target.Port}", TaskStatus.Failed, LogLevel.Warning); return BadRequest(new RequestResponse("容器连接失败")); } - stream = new CapturableNetworkStream(socket, metadata, - new() - { - Source = new(clientIp, clientPort), - Dest = ipEndPoint, - EnableCapture = enable, - FilePath = container.TrafficPath(HttpContext.Connection.Id), - } - ); + stream = new CapturableNetworkStream(socket, metadata, options); } catch (Exception e) { @@ -135,7 +183,7 @@ public async Task ProxyForInstance(string id, CancellationToken t try { var (tx, rx) = await RunProxy(stream, ws, token); - _logger.SystemLog($"[{id}] {clientIp} -> {container.IP}:{container.Port}, tx {tx}, rx {rx}", TaskStatus.Success, LogLevel.Debug); + _logger.SystemLog($"[{id}] {client.Address} -> {target.Address}:{target.Port}, tx {tx}, rx {rx}", TaskStatus.Success, LogLevel.Debug); } catch (Exception e) { @@ -235,13 +283,13 @@ internal async Task ValidateContainer(string id, CancellationToken token = var key = CacheKey.ConnectionCount(id); var bytes = await _cache.GetAsync(key, token); + // avoid DoS attack with cache -1 if (bytes is not null) - return true; + return BitConverter.ToInt32(bytes) >= 0; var valid = await _containerRepository.ValidateContainer(id, token); - if (valid) - await _cache.SetAsync(key, BitConverter.GetBytes(0), _validOption, token); + await _cache.SetAsync(key, BitConverter.GetBytes(valid ? 0 : -1), _validOption, token); return valid; } @@ -259,7 +307,7 @@ internal async Task IncrementConnectionCount(string id) if (bytes is null) return false; - var count = BitConverter.ToUInt32(bytes); + var count = BitConverter.ToInt32(bytes); if (count > CONNECTION_LIMIT) return false; @@ -282,7 +330,7 @@ internal async Task DecrementConnectionCount(string id) if (bytes is null) return; - var count = BitConverter.ToUInt32(bytes); + var count = BitConverter.ToInt32(bytes); if (count > 1) { diff --git a/src/GZCTF/Utils/CapturableNetworkStream.cs b/src/GZCTF/Utils/CapturableNetworkStream.cs index 732367af6..8d55f1f10 100644 --- a/src/GZCTF/Utils/CapturableNetworkStream.cs +++ b/src/GZCTF/Utils/CapturableNetworkStream.cs @@ -13,12 +13,12 @@ public class CapturableNetworkStreamOptions /// /// 流量源地址 /// - public required IPEndPoint Source { get; set; } + public IPEndPoint Source { get; set; } = new(0, 0); /// /// 流量目的地址 /// - public required IPEndPoint Dest { get; set; } + public IPEndPoint Dest { get; set; } = new(0, 0); /// /// 记录文件位置 @@ -39,7 +39,7 @@ public sealed class CapturableNetworkStream : NetworkStream private readonly CapturableNetworkStreamOptions _options; private readonly CaptureFileWriterDevice? _device = null; private readonly PhysicalAddress _dummyPhysicalAddress = PhysicalAddress.Parse("00-11-00-11-00-11"); - private readonly IPEndPoint _host = new(IPAddress.Parse("0.0.0.0"), 65535); + private readonly IPEndPoint _host = new(0, 65535); public CapturableNetworkStream(Socket socket, byte[]? metadata, CapturableNetworkStreamOptions options) : base(socket) { @@ -83,7 +83,7 @@ public override async ValueTask WriteAsync(ReadOnlyMemory buffer, Cancella } /// - /// 向文件写入一条数据记录 + /// 向文件写入一条流量记录 /// /// 源地址 /// 目的地址 From 032759395ba68c4bba0b3692c00d5d5552ada596 Mon Sep 17 00:00:00 2001 From: GZTime Date: Sun, 20 Aug 2023 01:47:43 +0800 Subject: [PATCH 49/67] fix: getProxyUrl --- src/GZCTF/ClientApp/src/utils/Shared.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/GZCTF/ClientApp/src/utils/Shared.tsx b/src/GZCTF/ClientApp/src/utils/Shared.tsx index d8c186d04..acdd0b924 100644 --- a/src/GZCTF/ClientApp/src/utils/Shared.tsx +++ b/src/GZCTF/ClientApp/src/utils/Shared.tsx @@ -312,5 +312,6 @@ export const TaskStatusColorMap = new Map([ export const getProxyUrl = (guid: string, test: boolean = false) => { const protocol = window.location.protocol.replace('http', 'ws') - return `${protocol}//${window.location.host}/api/proxy${test && '/noinst'}/${guid}` + const api = test ? 'api/proxy/noinst' : 'api/proxy' + return `${protocol}//${window.location.host}/${api}/${guid}` } From 97059b0559f6c9ee2ccdbd2846e96f41ff696066 Mon Sep 17 00:00:00 2001 From: GZTime Date: Sun, 20 Aug 2023 11:46:27 +0800 Subject: [PATCH 50/67] logging: ignore healthz & http 204 request --- src/GZCTF/Program.cs | 4 +++- src/GZCTF/Utils/LogHelper.cs | 7 +++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/GZCTF/Program.cs b/src/GZCTF/Program.cs index 8df23f802..6b720fcf4 100644 --- a/src/GZCTF/Program.cs +++ b/src/GZCTF/Program.cs @@ -269,7 +269,6 @@ } app.UseRouting(); -app.MapHealthChecks("/healthz"); app.UseAuthentication(); app.UseAuthorization(); @@ -282,10 +281,13 @@ app.UseWebSockets(new() { KeepAliveInterval = TimeSpan.FromMinutes(30) }); +app.MapHealthChecks("/healthz"); app.MapControllers(); + app.MapHub("/hub/user"); app.MapHub("/hub/monitor"); app.MapHub("/hub/admin"); + app.MapFallbackToFile("index.html"); await using var scope = app.Services.CreateAsyncScope(); diff --git a/src/GZCTF/Utils/LogHelper.cs b/src/GZCTF/Utils/LogHelper.cs index dbeb60469..2274faa38 100644 --- a/src/GZCTF/Utils/LogHelper.cs +++ b/src/GZCTF/Utils/LogHelper.cs @@ -4,6 +4,7 @@ using NpgsqlTypes; using Serilog; using Serilog.Events; +using Serilog.Filters; using Serilog.Sinks.File.Archive; using Serilog.Sinks.PostgreSQL; using Serilog.Templates; @@ -85,8 +86,10 @@ public static void UseRequestLogging(this WebApplication app) { options.MessageTemplate = "[{StatusCode}] {Elapsed,8:####0.00}ms HTTP {RequestMethod,-6} {RequestPath} @ {RemoteIP}"; options.GetLevel = (context, time, ex) => + context.Response.StatusCode == 204 ? LogEventLevel.Verbose : time > 10000 && context.Response.StatusCode != 101 ? LogEventLevel.Warning : (context.Response.StatusCode > 499 || ex is not null) ? LogEventLevel.Error : LogEventLevel.Debug; + options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => { diagnosticContext.Set("RemoteIP", httpContext.Connection.RemoteIpAddress); @@ -125,6 +128,10 @@ public static Serilog.ILogger GetInitLogger() public static Serilog.ILogger GetLogger(IConfiguration configuration, IServiceProvider serviceProvider) => new LoggerConfiguration() .Enrich.FromLogContext() + .Filter.ByExcluding( + Matching.WithProperty("RequestPath", v => + "/healthz".Equals(v, StringComparison.OrdinalIgnoreCase)) + ) .MinimumLevel.Debug() .Filter.ByExcluding(logEvent => logEvent.Exception != null && From f6adb3eae0445995e11a30d93980a31f1c6cb65a Mon Sep 17 00:00:00 2001 From: GZTime Date: Sun, 20 Aug 2023 19:10:40 +0800 Subject: [PATCH 51/67] wip: update API --- src/GZCTF/ClientApp/src/Api.ts | 6 +++--- src/GZCTF/Models/Request/Game/TeamTrafficModel.cs | 6 +++--- src/GZCTF/Utils/FilePath.cs | 7 +------ src/GZCTF/Utils/Shared.cs | 10 +++++++++- 4 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/GZCTF/ClientApp/src/Api.ts b/src/GZCTF/ClientApp/src/Api.ts index 89ef7023b..b86c93014 100644 --- a/src/GZCTF/ClientApp/src/Api.ts +++ b/src/GZCTF/ClientApp/src/Api.ts @@ -1484,8 +1484,8 @@ export interface TeamTrafficModel { participationId?: number /** 队伍名称 */ name?: string | null - /** 队伍签名 */ - bio?: string | null + /** 参赛所属组织 */ + organization?: string | null /** 头像链接 */ avatar?: string | null /** @@ -1505,7 +1505,7 @@ export interface FileRecord { */ size?: number /** - * 文件路径 + * 文件修改日期 * @format date-time */ updateTime?: string diff --git a/src/GZCTF/Models/Request/Game/TeamTrafficModel.cs b/src/GZCTF/Models/Request/Game/TeamTrafficModel.cs index 6fd2feb72..c5018f26e 100644 --- a/src/GZCTF/Models/Request/Game/TeamTrafficModel.cs +++ b/src/GZCTF/Models/Request/Game/TeamTrafficModel.cs @@ -23,9 +23,9 @@ public class TeamTrafficModel public string? Name { get; set; } /// - /// 队伍签名 + /// 参赛所属组织 /// - public string? Bio { get; set; } + public string? Organization { get; set; } /// /// 头像链接 @@ -45,7 +45,7 @@ internal static TeamTrafficModel FromParticipation(Participation part, int chall { Id = part.Team.Id, Name = part.Team.Name, - Bio = part.Team.Bio, + Organization = part.Organization, ParticipationId = part.Id, Avatar = part.Team.AvatarUrl, Count = Directory.Exists(trafficPath) ? diff --git a/src/GZCTF/Utils/FilePath.cs b/src/GZCTF/Utils/FilePath.cs index 2be5ee4be..f75e7df2e 100644 --- a/src/GZCTF/Utils/FilePath.cs +++ b/src/GZCTF/Utils/FilePath.cs @@ -50,12 +50,7 @@ internal static List GetFileRecords(string dir, out long totSize) { var info = new FileInfo(file)!; - records.Add(new() - { - FileName = info.Name, - Size = info.Length, - UpdateTime = info.LastAccessTimeUtc - }); + records.Add(FileRecord.FromFileInfo(info)); totSize += info.Length; } diff --git a/src/GZCTF/Utils/Shared.cs b/src/GZCTF/Utils/Shared.cs index 2104545a4..2516be53a 100644 --- a/src/GZCTF/Utils/Shared.cs +++ b/src/GZCTF/Utils/Shared.cs @@ -130,9 +130,17 @@ public class FileRecord public long Size { get; set; } = 0; /// - /// 文件路径 + /// 文件修改日期 /// public DateTimeOffset UpdateTime { get; set; } = DateTimeOffset.Now; + + internal static FileRecord FromFileInfo(FileInfo info) + => new() + { + FileName = info.Name, + UpdateTime = info.LastWriteTimeUtc, + Size = info.Length, + }; } From 9944bee49e0c7b448071d0975dcc13ab8539ed3c Mon Sep 17 00:00:00 2001 From: GZTime Date: Sun, 20 Aug 2023 22:11:38 +0800 Subject: [PATCH 52/67] style: use if-return pattern --- .../src/components/ChallengeDetailModal.tsx | 115 +++++---- .../components/MobileScoreboardItemModal.tsx | 2 +- .../src/components/ScoreboardItemModal.tsx | 2 +- .../ClientApp/src/components/TeamCard.tsx | 2 +- .../src/components/TeamEditModal.tsx | 223 +++++++++--------- .../src/components/WriteupSubmitModal.tsx | 19 +- .../admin/AttachmentRemoteEditModal.tsx | 33 ++- .../admin/AttachmentUploadModal.tsx | 103 ++++---- .../src/components/admin/BloodBonusModel.tsx | 31 ++- .../components/admin/ChallengeCreateModal.tsx | 44 ++-- .../ClientApp/src/pages/account/Profile.tsx | 219 +++++++++-------- src/GZCTF/ClientApp/src/pages/admin/Teams.tsx | 2 +- .../ClientApp/src/pages/admin/games/Index.tsx | 27 +-- .../src/pages/admin/games/[id]/Info.tsx | 135 ++++++----- .../src/pages/admin/games/[id]/Review.tsx | 4 +- .../games/[id]/challenges/[chalId]/Flags.tsx | 69 +++--- .../games/[id]/challenges/[chalId]/Index.tsx | 50 ++-- .../src/pages/posts/[postId]/edit.tsx | 47 ++-- src/GZCTF/ClientApp/src/utils/Shared.tsx | 12 + src/GZCTF/Utils/Shared.cs | 1 - 20 files changed, 559 insertions(+), 581 deletions(-) diff --git a/src/GZCTF/ClientApp/src/components/ChallengeDetailModal.tsx b/src/GZCTF/ClientApp/src/components/ChallengeDetailModal.tsx index e87b65f69..9e8f8ec1a 100644 --- a/src/GZCTF/ClientApp/src/components/ChallengeDetailModal.tsx +++ b/src/GZCTF/ClientApp/src/components/ChallengeDetailModal.tsx @@ -114,74 +114,71 @@ const ChallengeDetailModal: FC = (props) => { const [flag, setFlag] = useInputState('') const onCreateContainer = () => { - if (challengeId) { - setDisabled(true) - api.game - .gameCreateContainer(gameId, challengeId) - .then((res) => { - mutate({ - ...challenge, - context: { - ...challenge?.context, - closeTime: res.data.expectStopAt, - instanceEntry: res.data.entry, - }, - }) - showNotification({ - color: 'teal', - title: '实例已创建', - message: '请注意实例到期时间', - icon: , - }) + if (!challengeId) return + setDisabled(true) + api.game + .gameCreateContainer(gameId, challengeId) + .then((res) => { + mutate({ + ...challenge, + context: { + ...challenge?.context, + closeTime: res.data.expectStopAt, + instanceEntry: res.data.entry, + }, }) - .catch(showErrorNotification) - .finally(() => setDisabled(false)) - } + showNotification({ + color: 'teal', + title: '实例已创建', + message: '请注意实例到期时间', + icon: , + }) + }) + .catch(showErrorNotification) + .finally(() => setDisabled(false)) } const onDestroyContainer = () => { - if (challengeId) { - setDisabled(true) - api.game - .gameDeleteContainer(gameId, challengeId) - .then(() => { - mutate({ - ...challenge, - context: { - ...challenge?.context, - closeTime: null, - instanceEntry: null, - }, - }) - showNotification({ - color: 'teal', - title: '实例已销毁', - message: '你可以重新创建实例', - icon: , - }) + if (!challengeId) return + setDisabled(true) + api.game + .gameDeleteContainer(gameId, challengeId) + .then(() => { + mutate({ + ...challenge, + context: { + ...challenge?.context, + closeTime: null, + instanceEntry: null, + }, }) - .catch(showErrorNotification) - .finally(() => setDisabled(false)) - } + showNotification({ + color: 'teal', + title: '实例已销毁', + message: '你可以重新创建实例', + icon: , + }) + }) + .catch(showErrorNotification) + .finally(() => setDisabled(false)) } const onProlongContainer = () => { - if (challengeId) { - setDisabled(true) - api.game - .gameProlongContainer(gameId, challengeId) - .then((res) => { - mutate({ - ...challenge, - context: { - ...challenge?.context, - closeTime: res.data.expectStopAt, - }, - }) + if (!challengeId) return + setDisabled(true) + api.game + .gameProlongContainer(gameId, challengeId) + .then((res) => { + mutate({ + ...challenge, + context: { + ...challenge?.context, + closeTime: res.data.expectStopAt, + }, }) - .catch(showErrorNotification) - .finally(() => setDisabled(false)) - } + }) + .catch(showErrorNotification) + .finally(() => setDisabled(false)) } const onSubmit = (event: React.FormEvent) => { diff --git a/src/GZCTF/ClientApp/src/components/MobileScoreboardItemModal.tsx b/src/GZCTF/ClientApp/src/components/MobileScoreboardItemModal.tsx index 6041b5f1d..cbbdfbc9f 100644 --- a/src/GZCTF/ClientApp/src/components/MobileScoreboardItemModal.tsx +++ b/src/GZCTF/ClientApp/src/components/MobileScoreboardItemModal.tsx @@ -81,7 +81,7 @@ const MobileScoreboardItemModal: FC = (props) => )} - + {item?.bio || '这只队伍很懒,什么都没留下'} diff --git a/src/GZCTF/ClientApp/src/components/ScoreboardItemModal.tsx b/src/GZCTF/ClientApp/src/components/ScoreboardItemModal.tsx index ab8a8a776..890e86530 100644 --- a/src/GZCTF/ClientApp/src/components/ScoreboardItemModal.tsx +++ b/src/GZCTF/ClientApp/src/components/ScoreboardItemModal.tsx @@ -80,7 +80,7 @@ const ScoreboardItemModal: FC = (props) => { )} - + {item?.bio || '这只队伍很懒,什么都没留下'} diff --git a/src/GZCTF/ClientApp/src/components/TeamCard.tsx b/src/GZCTF/ClientApp/src/components/TeamCard.tsx index 6fcd5967c..64852ae70 100644 --- a/src/GZCTF/ClientApp/src/components/TeamCard.tsx +++ b/src/GZCTF/ClientApp/src/components/TeamCard.tsx @@ -57,7 +57,7 @@ const TeamCard: FC = (props) => { {team.name} - + {team.bio} diff --git a/src/GZCTF/ClientApp/src/components/TeamEditModal.tsx b/src/GZCTF/ClientApp/src/components/TeamEditModal.tsx index a89e66abb..8dd7e9103 100644 --- a/src/GZCTF/ClientApp/src/components/TeamEditModal.tsx +++ b/src/GZCTF/ClientApp/src/components/TeamEditModal.tsx @@ -108,63 +108,61 @@ const TeamEditModal: FC = (props) => { }, [inviteCode, isCaptain, teamId]) const onConfirmLeaveTeam = () => { - if (teamInfo && !isCaptain) { - api.team - .teamLeave(teamInfo.id!) - .then(() => { - showNotification({ - color: 'teal', - title: '退出队伍成功', - message: '队伍信息已更新', - icon: , - }) - mutateTeams((teams) => teams?.filter((x) => x.id !== teamInfo?.id)) - props.onClose() + if (!teamInfo || isCaptain) return + + api.team + .teamLeave(teamInfo.id!) + .then(() => { + showNotification({ + color: 'teal', + title: '退出队伍成功', + message: '队伍信息已更新', + icon: , }) - .catch(showErrorNotification) - } + mutateTeams((teams) => teams?.filter((x) => x.id !== teamInfo?.id)) + props.onClose() + }) + .catch(showErrorNotification) } const onConfirmDisbandTeam = () => { - if (teamInfo && isCaptain) { - api.team - .teamDeleteTeam(teamInfo.id!) - .then(() => { - showNotification({ - color: 'teal', - title: '解散队伍成功', - message: '队伍信息已更新', - icon: , - }) - setInviteCode('') - setTeamInfo(null) - mutateTeams((teams) => teams?.filter((x) => x.id !== teamInfo.id), { revalidate: false }) - props.onClose() + if (!teamInfo || !isCaptain) return + api.team + .teamDeleteTeam(teamInfo.id!) + .then(() => { + showNotification({ + color: 'teal', + title: '解散队伍成功', + message: '队伍信息已更新', + icon: , }) - .catch(showErrorNotification) - } + setInviteCode('') + setTeamInfo(null) + mutateTeams((teams) => teams?.filter((x) => x.id !== teamInfo.id), { revalidate: false }) + props.onClose() + }) + .catch(showErrorNotification) } const onTransferCaptain = (userId: string) => { - if (teamInfo && isCaptain) { - api.team - .teamTransfer(teamInfo.id!, { - newCaptainId: userId, + if (!teamInfo || !isCaptain) return + api.team + .teamTransfer(teamInfo.id!, { + newCaptainId: userId, + }) + .then((team) => { + showNotification({ + color: 'teal', + title: '队伍成功', + message: '队伍信息已更新', + icon: , }) - .then((team) => { - showNotification({ - color: 'teal', - title: '队伍成功', - message: '队伍信息已更新', - icon: , - }) - setTeamInfo(team.data) - mutateTeams((teams) => teams?.map((x) => (x.id === teamInfo.id ? team.data : x)), { - revalidate: false, - }) + setTeamInfo(team.data) + mutateTeams((teams) => teams?.map((x) => (x.id === teamInfo.id ? team.data : x)), { + revalidate: false, }) - .catch(showErrorNotification) - } + }) + .catch(showErrorNotification) } const onConfirmKickUser = (userId: string) => { @@ -186,85 +184,82 @@ const TeamEditModal: FC = (props) => { } const onRefreshInviteCode = () => { - if (inviteCode) { - api.team - .teamUpdateInviteToken(team?.id!) - .then((data) => { - setInviteCode(data.data) - showNotification({ - color: 'teal', - message: '队伍邀请码已更新', - icon: , - }) + if (!inviteCode) return + + api.team + .teamUpdateInviteToken(team?.id!) + .then((data) => { + setInviteCode(data.data) + showNotification({ + color: 'teal', + message: '队伍邀请码已更新', + icon: , }) - .catch(showErrorNotification) - } + }) + .catch(showErrorNotification) } const onChangeAvatar = () => { - if (avatarFile && teamInfo?.id) { - setDisabled(true) - notifications.clean() - showNotification({ - id: 'upload-avatar', - color: 'orange', - message: '正在上传头像', - loading: true, - autoClose: false, - }) + if (!avatarFile || !teamInfo?.id) return + setDisabled(true) + notifications.clean() + showNotification({ + id: 'upload-avatar', + color: 'orange', + message: '正在上传头像', + loading: true, + autoClose: false, + }) - api.team - .teamAvatar(teamInfo?.id, { - file: avatarFile, - }) - .then((data) => { - updateNotification({ - id: 'upload-avatar', - color: 'teal', - message: '头像已更新', - icon: , - autoClose: true, - }) - setAvatarFile(null) - const newTeamInfo = { ...teamInfo, avatar: data.data } - setTeamInfo(newTeamInfo) - mutateTeams((teams) => teams?.map((x) => (x.id === teamInfo.id ? newTeamInfo : x)), { - revalidate: false, - }) + api.team + .teamAvatar(teamInfo?.id, { + file: avatarFile, + }) + .then((data) => { + updateNotification({ + id: 'upload-avatar', + color: 'teal', + message: '头像已更新', + icon: , + autoClose: true, }) - .catch(() => { - updateNotification({ - id: 'upload-avatar', - color: 'red', - message: '头像更新失败', - icon: , - autoClose: true, - }) + setAvatarFile(null) + const newTeamInfo = { ...teamInfo, avatar: data.data } + setTeamInfo(newTeamInfo) + mutateTeams((teams) => teams?.map((x) => (x.id === teamInfo.id ? newTeamInfo : x)), { + revalidate: false, }) - .finally(() => { - setDisabled(false) - setDropzoneOpened(false) + }) + .catch(() => { + updateNotification({ + id: 'upload-avatar', + color: 'red', + message: '头像更新失败', + icon: , + autoClose: true, }) - } + }) + .finally(() => { + setDisabled(false) + setDropzoneOpened(false) + }) } const onSaveChange = () => { - if (teamInfo && teamInfo?.id) { - api.team - .teamUpdateTeam(teamInfo.id, teamInfo) - .then(() => { - // Updated TeamInfoModel - showNotification({ - color: 'teal', - message: '队伍信息已更新', - icon: , - }) - mutateTeams((teams) => teams?.map((x) => (x.id === teamInfo.id ? teamInfo : x)), { - revalidate: false, - }) + if (!teamInfo || !teamInfo?.id) return + api.team + .teamUpdateTeam(teamInfo.id, teamInfo) + .then(() => { + showNotification({ + color: 'teal', + message: '队伍信息已更新', + icon: , }) - .catch(showErrorNotification) - } + mutateTeams((teams) => teams?.map((x) => (x.id === teamInfo.id ? teamInfo : x)), { + revalidate: false, + }) + }) + .catch(showErrorNotification) } return ( diff --git a/src/GZCTF/ClientApp/src/components/WriteupSubmitModal.tsx b/src/GZCTF/ClientApp/src/components/WriteupSubmitModal.tsx index 147b65283..0b9340ae5 100644 --- a/src/GZCTF/ClientApp/src/components/WriteupSubmitModal.tsx +++ b/src/GZCTF/ClientApp/src/components/WriteupSubmitModal.tsx @@ -20,6 +20,7 @@ import { mdiCheck, mdiExclamationThick, mdiFileDocumentOutline, mdiFileHidden } import { Icon } from '@mdi/react' import MarkdownRender from '@Components/MarkdownRender' import { showErrorNotification } from '@Utils/ApiErrorHandler' +import { HunamizeSize } from '@Utils/Shared' import { useUploadStyles } from '@Utils/ThemeOverride' import { OnceSWRConfig } from '@Utils/useConfig' import api from '@Api' @@ -77,18 +78,6 @@ export const WriteupSubmitModal: FC = ({ gameId, wpddl, }) } - const hunamize = (size: number) => { - if (size < 1024) { - return `${size} B` - } else if (size < 1024 * 1024) { - return `${(size / 1024).toFixed(2)} KB` - } else if (size < 1024 * 1024 * 1024) { - return `${(size / 1024 / 1024).toFixed(2)} MB` - } else { - return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB` - } - } - return ( = ({ gameId, wpddl, )} 当前提交 - {data?.submitted ? ( + {data && data.submitted ? ( - {data?.name ?? 'Writeup-1-2-2022-10-11T12:00:00.pdf'} + {data.name} - {data?.fileSize ? hunamize(data.fileSize) : '456.64 KB'} + {data.fileSize && HunamizeSize(data.fileSize)} diff --git a/src/GZCTF/ClientApp/src/components/admin/AttachmentRemoteEditModal.tsx b/src/GZCTF/ClientApp/src/components/admin/AttachmentRemoteEditModal.tsx index 08d7fa437..9625839f3 100644 --- a/src/GZCTF/ClientApp/src/components/admin/AttachmentRemoteEditModal.tsx +++ b/src/GZCTF/ClientApp/src/components/admin/AttachmentRemoteEditModal.tsx @@ -39,24 +39,23 @@ const AttachmentRemoteEditModal: FC = (props) => { }, [text]) const onUpload = () => { - if (flags.length > 0) { - api.edit - .editAddFlags(numId, numCId, flags) - .then(() => { - showNotification({ - color: 'teal', - message: '附件已更新', - icon: , - }) - setText('') - mutate() - props.onClose() + if (flags.length <= 0) return + api.edit + .editAddFlags(numId, numCId, flags) + .then(() => { + showNotification({ + color: 'teal', + message: '附件已更新', + icon: , }) - .catch((err) => showErrorNotification(err)) - .finally(() => { - setDisabled(false) - }) - } + setText('') + mutate() + props.onClose() + }) + .catch((err) => showErrorNotification(err)) + .finally(() => { + setDisabled(false) + }) } return ( diff --git a/src/GZCTF/ClientApp/src/components/admin/AttachmentUploadModal.tsx b/src/GZCTF/ClientApp/src/components/admin/AttachmentUploadModal.tsx index 78e10b7e1..2f045fe54 100644 --- a/src/GZCTF/ClientApp/src/components/admin/AttachmentUploadModal.tsx +++ b/src/GZCTF/ClientApp/src/components/admin/AttachmentUploadModal.tsx @@ -38,63 +38,64 @@ const AttachmentUploadModal: FC = (props) => { const { classes, theme } = useUploadStyles() const onUpload = () => { - if (files.length > 0) { - setProgress(0) - setDisabled(true) - - api.assets - .assetsUpload( - { - files, - }, - { filename: uploadFileName }, - { - onUploadProgress: (e) => { - setProgress((e.loaded / (e.total ?? 1)) * 90) - }, - } - ) - .then((data) => { - setProgress(95) - if (data.data) { - api.edit - .editAddFlags( - numId, - numCId, - data.data.map((f, idx) => ({ - flag: files[idx].name, - attachmentType: FileType.Local, - fileHash: f.hash, - })) - ) - .then(() => { - setProgress(0) - showNotification({ - color: 'teal', - message: '附件已更新', - icon: , - }) - setFiles([]) - mutate() - props.onClose() - }) - .catch((err) => showErrorNotification(err)) - .finally(() => { - setDisabled(false) - }) - } - }) - .catch((err) => showErrorNotification(err)) - .finally(() => { - setDisabled(false) - }) - } else { + if (files.length <= 0) { showNotification({ color: 'red', message: '请选择至少一个文件', icon: , }) + return } + + setProgress(0) + setDisabled(true) + + api.assets + .assetsUpload( + { + files, + }, + { filename: uploadFileName }, + { + onUploadProgress: (e) => { + setProgress((e.loaded / (e.total ?? 1)) * 90) + }, + } + ) + .then((data) => { + setProgress(95) + if (data.data) { + api.edit + .editAddFlags( + numId, + numCId, + data.data.map((f, idx) => ({ + flag: files[idx].name, + attachmentType: FileType.Local, + fileHash: f.hash, + })) + ) + .then(() => { + setProgress(0) + showNotification({ + color: 'teal', + message: '附件已更新', + icon: , + }) + setFiles([]) + mutate() + props.onClose() + }) + .catch((err) => showErrorNotification(err)) + .finally(() => { + setDisabled(false) + }) + } + }) + .catch((err) => showErrorNotification(err)) + .finally(() => { + setDisabled(false) + }) } return ( diff --git a/src/GZCTF/ClientApp/src/components/admin/BloodBonusModel.tsx b/src/GZCTF/ClientApp/src/components/admin/BloodBonusModel.tsx index 263c89a27..cc779807e 100644 --- a/src/GZCTF/ClientApp/src/components/admin/BloodBonusModel.tsx +++ b/src/GZCTF/ClientApp/src/components/admin/BloodBonusModel.tsx @@ -24,22 +24,21 @@ const BloodBonusModel: FC = (props) => { }, [gameSource]) const onUpdate = () => { - if (gameSource && gameSource.title) { - setDisabled(true) - api.edit - .editUpdateGame(numId, { - ...gameSource, - bloodBonus: BloodBonus.fromBonus(firstBloodBonus, secondBloodBonus, thirdBloodBonus) - .value, - }) - .then(() => { - mutate() - props.onClose() - }) - .finally(() => { - setDisabled(false) - }) - } + if (!gameSource?.title) return + + setDisabled(true) + api.edit + .editUpdateGame(numId, { + ...gameSource, + bloodBonus: BloodBonus.fromBonus(firstBloodBonus, secondBloodBonus, thirdBloodBonus).value, + }) + .then(() => { + mutate() + props.onClose() + }) + .finally(() => { + setDisabled(false) + }) } return ( diff --git a/src/GZCTF/ClientApp/src/components/admin/ChallengeCreateModal.tsx b/src/GZCTF/ClientApp/src/components/admin/ChallengeCreateModal.tsx index a8320d13c..6f6f923d1 100644 --- a/src/GZCTF/ClientApp/src/components/admin/ChallengeCreateModal.tsx +++ b/src/GZCTF/ClientApp/src/components/admin/ChallengeCreateModal.tsx @@ -29,30 +29,30 @@ const ChallengeCreateModal: FC = (props) => { const [type, setType] = useState(null) const onCreate = () => { - if (title && tag && type) { - setDisabled(true) - const numId = parseInt(id ?? '-1') + if (!title || !tag || !type) return - api.edit - .editAddGameChallenge(numId, { - title: title, - tag: tag as ChallengeTag, - type: type as ChallengeType, - }) - .then((data) => { - showNotification({ - color: 'teal', - message: '比赛题目已添加', - icon: , - }) - onAddChallenge(data.data) - navigate(`/admin/games/${id}/challenges/${data.data.id}`) - }) - .catch((err) => { - showErrorNotification(err) - setDisabled(false) + setDisabled(true) + const numId = parseInt(id ?? '-1') + + api.edit + .editAddGameChallenge(numId, { + title: title, + tag: tag as ChallengeTag, + type: type as ChallengeType, + }) + .then((data) => { + showNotification({ + color: 'teal', + message: '比赛题目已添加', + icon: , }) - } + onAddChallenge(data.data) + navigate(`/admin/games/${id}/challenges/${data.data.id}`) + }) + .catch((err) => { + showErrorNotification(err) + setDisabled(false) + }) } return ( diff --git a/src/GZCTF/ClientApp/src/pages/account/Profile.tsx b/src/GZCTF/ClientApp/src/pages/account/Profile.tsx index 082e3a066..aeb31359a 100644 --- a/src/GZCTF/ClientApp/src/pages/account/Profile.tsx +++ b/src/GZCTF/ClientApp/src/pages/account/Profile.tsx @@ -15,6 +15,7 @@ import { Image, Center, SimpleGrid, + Title, } from '@mantine/core' import { Dropzone } from '@mantine/dropzone' import { notifications, showNotification, updateNotification } from '@mantine/notifications' @@ -63,47 +64,47 @@ const Profile: FC = () => { }, [user]) const onChangeAvatar = () => { - if (avatarFile) { - setDisabled(true) - notifications.clean() - showNotification({ - id: 'upload-avatar', - color: 'orange', - message: '正在上传头像', - loading: true, - autoClose: false, - }) + if (!avatarFile) return - api.account - .accountAvatar({ - file: avatarFile, - }) - .then(() => { - updateNotification({ - id: 'upload-avatar', - color: 'teal', - message: '头像已更新', - icon: , - autoClose: true, - }) - setDisabled(false) - mutate() - setAvatarFile(null) - }) - .catch(() => { - updateNotification({ - id: 'upload-avatar', - color: 'red', - message: '头像更新失败', - icon: , - autoClose: true, - }) + setDisabled(true) + notifications.clean() + showNotification({ + id: 'upload-avatar', + color: 'orange', + message: '正在上传头像', + loading: true, + autoClose: false, + }) + + api.account + .accountAvatar({ + file: avatarFile, + }) + .then(() => { + updateNotification({ + id: 'upload-avatar', + color: 'teal', + message: '头像已更新', + icon: , + autoClose: true, }) - .finally(() => { - setDisabled(false) - setDropzoneOpened(false) + setDisabled(false) + mutate() + setAvatarFile(null) + }) + .catch(() => { + updateNotification({ + id: 'upload-avatar', + color: 'red', + message: '头像更新失败', + icon: , + autoClose: true, }) - } + }) + .finally(() => { + setDisabled(false) + setDropzoneOpened(false) + }) } const onChangeProfile = () => { @@ -122,80 +123,71 @@ const Profile: FC = () => { } const onChangeEmail = () => { - if (email) { - api.account - .accountChangeEmail({ - newMail: email, - }) - .then((res) => { - if (res.data.data) { - showNotification({ - color: 'teal', - title: '验证邮件已发送', - message: '请检查你的邮箱及垃圾邮件~', - icon: , - }) - } else { - mutate({ ...user, email: email }) - } - setMailEditOpened(false) - }) - .catch(showErrorNotification) - } + if (!email) return + + api.account + .accountChangeEmail({ + newMail: email, + }) + .then((res) => { + if (res.data.data) { + showNotification({ + color: 'teal', + title: '验证邮件已发送', + message: '请检查你的邮箱及垃圾邮件~', + icon: , + }) + } else { + mutate({ ...user, email: email }) + } + setMailEditOpened(false) + }) + .catch(showErrorNotification) } const context = ( <> - {/* Header */} - -

个人信息

-
- - - {/* User Info */} - - - - setProfile({ ...profile, userName: event.target.value })} - /> - - -
- setDropzoneOpened(true)} - > - {user?.userName?.slice(0, 1) ?? 'U'} - -
-
-
- - setProfile({ ...profile, phone: event.target.value })} - /> + 个人信息 + + + + setProfile({ ...profile, userName: event.target.value })} + /> +
+ setDropzoneOpened(true)} + > + {user?.userName?.slice(0, 1) ?? 'U'} + +
+
+ + setProfile({ ...profile, phone: event.target.value })} + /> { return ( {isMobile ? ( - context + {context} ) : ( -
- +
+ {context}
)} - {/* Change Password */} setPwdChangeOpened(false)} title="更改密码" /> - {/* Change Email */} setMailEditOpened(false)} title="更改邮箱"> - 更改邮箱后,您将不能通过原邮箱登录。一封邮件将会发送至新邮箱,请点击邮件中的链接完成验证。 + 更改邮箱后,您将不能通过原邮箱登录。 +
+ 一封邮件将会发送至新邮箱,请点击邮件中的链接完成验证。
{
- {/* Change avatar */} setDropzoneOpened(false)} diff --git a/src/GZCTF/ClientApp/src/pages/admin/Teams.tsx b/src/GZCTF/ClientApp/src/pages/admin/Teams.tsx index 88924fb19..ef52c40b1 100644 --- a/src/GZCTF/ClientApp/src/pages/admin/Teams.tsx +++ b/src/GZCTF/ClientApp/src/pages/admin/Teams.tsx @@ -263,7 +263,7 @@ const Teams: FC = () => { - + {team.bio ?? '这个队伍很懒,什么都没有写'} diff --git a/src/GZCTF/ClientApp/src/pages/admin/games/Index.tsx b/src/GZCTF/ClientApp/src/pages/admin/games/Index.tsx index 82f42b801..f72c8fdc5 100644 --- a/src/GZCTF/ClientApp/src/pages/admin/games/Index.tsx +++ b/src/GZCTF/ClientApp/src/pages/admin/games/Index.tsx @@ -50,20 +50,19 @@ const Games: FC = () => { const { classes } = useTableStyles() const onToggleHidden = (game: GameInfoModel) => { - if (game.id) { - setDisabled(true) - api.edit - .editUpdateGame(game.id, { - ...game, - hidden: !game.hidden, - }) - .then(() => { - games && - updateGames(games.map((g) => (g.id === game.id ? { ...g, hidden: !g.hidden } : g))) - }) - .catch(showErrorNotification) - .finally(() => setDisabled(false)) - } + if (!game.id) return + + setDisabled(true) + api.edit + .editUpdateGame(game.id, { + ...game, + hidden: !game.hidden, + }) + .then(() => { + games && updateGames(games.map((g) => (g.id === game.id ? { ...g, hidden: !g.hidden } : g))) + }) + .catch(showErrorNotification) + .finally(() => setDisabled(false)) } useEffect(() => { diff --git a/src/GZCTF/ClientApp/src/pages/admin/games/[id]/Info.tsx b/src/GZCTF/ClientApp/src/pages/admin/games/[id]/Info.tsx index e6ed166f4..12eae6ae8 100644 --- a/src/GZCTF/ClientApp/src/pages/admin/games/[id]/Info.tsx +++ b/src/GZCTF/ClientApp/src/pages/admin/games/[id]/Info.tsx @@ -88,85 +88,84 @@ const GameInfoEdit: FC = () => { }, [id, gameSource]) const onUpdatePoster = (file: File | undefined) => { - if (game && file) { - setDisabled(true) - notifications.clean() - showNotification({ - id: 'upload-poster', - color: 'orange', - message: '正在上传海报', - loading: true, - autoClose: false, - }) + if (!game || !file) return - api.edit - .editUpdateGamePoster(game.id!, { file }) - .then((res) => { - updateNotification({ - id: 'upload-poster', - color: 'teal', - message: '比赛海报已更新', - icon: , - autoClose: true, - }) - mutate({ ...game, poster: res.data }) - }) - .catch(() => { - updateNotification({ - id: 'upload-poster', - color: 'red', - message: '比赛海报更新失败', - icon: , - autoClose: true, - }) + setDisabled(true) + notifications.clean() + showNotification({ + id: 'upload-poster', + color: 'orange', + message: '正在上传海报', + loading: true, + autoClose: false, + }) + + api.edit + .editUpdateGamePoster(game.id!, { file }) + .then((res) => { + updateNotification({ + id: 'upload-poster', + color: 'teal', + message: '比赛海报已更新', + icon: , + autoClose: true, }) - .finally(() => { - setDisabled(false) + mutate({ ...game, poster: res.data }) + }) + .catch(() => { + updateNotification({ + id: 'upload-poster', + color: 'red', + message: '比赛海报更新失败', + icon: , + autoClose: true, }) - } + }) + .finally(() => { + setDisabled(false) + }) } const onUpdateInfo = () => { - if (game && game.title) { - setDisabled(true) - api.edit - .editUpdateGame(game.id!, { - ...game, - inviteCode: game.inviteCode?.length ?? 0 > 6 ? game.inviteCode : null, - start: start.toJSON(), - end: end.toJSON(), - wpddl: end.add(wpddl, 'h').toJSON(), - }) - .then(() => { - showNotification({ - color: 'teal', - message: '比赛信息已更新', - icon: , - }) - mutate() - api.game.mutateGameGamesAll() - }) - .catch(showErrorNotification) - .finally(() => { - setDisabled(false) + if (!game?.title) return + + setDisabled(true) + api.edit + .editUpdateGame(game.id!, { + ...game, + inviteCode: game.inviteCode?.length ?? 0 > 6 ? game.inviteCode : null, + start: start.toJSON(), + end: end.toJSON(), + wpddl: end.add(wpddl, 'h').toJSON(), + }) + .then(() => { + showNotification({ + color: 'teal', + message: '比赛信息已更新', + icon: , }) - } + mutate() + api.game.mutateGameGamesAll() + }) + .catch(showErrorNotification) + .finally(() => { + setDisabled(false) + }) } const onConfirmDelete = () => { - if (game) { - api.edit - .editDeleteGame(game.id!) - .then(() => { - showNotification({ - color: 'teal', - message: '比赛已删除', - icon: , - }) - navigate('/admin/games') + if (!game) return + api.edit + .editDeleteGame(game.id!) + .then(() => { + showNotification({ + color: 'teal', + message: '比赛已删除', + icon: , }) - .catch(showErrorNotification) - } + navigate('/admin/games') + }) + .catch(showErrorNotification) } return ( diff --git a/src/GZCTF/ClientApp/src/pages/admin/games/[id]/Review.tsx b/src/GZCTF/ClientApp/src/pages/admin/games/[id]/Review.tsx index 6aa570bc0..c98ed5861 100644 --- a/src/GZCTF/ClientApp/src/pages/admin/games/[id]/Review.tsx +++ b/src/GZCTF/ClientApp/src/pages/admin/games/[id]/Review.tsx @@ -118,10 +118,10 @@ const ParticipationItem: FC = (props) => { {!participation.team?.name ? 'T' : participation.team.name.slice(0, 1)} - + {!participation.team?.name ? '(无名队伍)' : participation.team.name} - + {!participation.team?.bio ? '(未设置签名)' : participation.team.bio} diff --git a/src/GZCTF/ClientApp/src/pages/admin/games/[id]/challenges/[chalId]/Flags.tsx b/src/GZCTF/ClientApp/src/pages/admin/games/[id]/challenges/[chalId]/Flags.tsx index 13a3a26f7..e795e55c1 100644 --- a/src/GZCTF/ClientApp/src/pages/admin/games/[id]/challenges/[chalId]/Flags.tsx +++ b/src/GZCTF/ClientApp/src/pages/admin/games/[id]/challenges/[chalId]/Flags.tsx @@ -140,46 +140,45 @@ const OneAttachmentWithFlags: FC = ({ onDelete }) => { } const onRemote = () => { - if (remoteUrl.startsWith('http')) { - setDisabled(true) - api.edit - .editUpdateAttachment(numId, numCId, { - attachmentType: FileType.Remote, - remoteUrl: remoteUrl, - }) - .then(() => { - showNotification({ - color: 'teal', - message: '附件已更新', - icon: , - }) - }) - .catch((err) => showErrorNotification(err)) - .finally(() => { - setDisabled(false) + if (!remoteUrl.startsWith('http')) return + setDisabled(true) + api.edit + .editUpdateAttachment(numId, numCId, { + attachmentType: FileType.Remote, + remoteUrl: remoteUrl, + }) + .then(() => { + showNotification({ + color: 'teal', + message: '附件已更新', + icon: , }) - } + }) + .catch((err) => showErrorNotification(err)) + .finally(() => { + setDisabled(false) + }) } const onChangeFlagTemplate = () => { - if (flagTemplate !== challenge?.flagTemplate) { - setDisabled(true) - api.edit - // allow empty flag template to be set (but not null or undefined) - .editUpdateGameChallenge(numId, numCId, { flagTemplate }) - .then(() => { - showNotification({ - color: 'teal', - message: 'flag 模板已更新', - icon: , - }) - challenge && mutate({ ...challenge, flagTemplate: flagTemplate }) - }) - .catch(showErrorNotification) - .finally(() => { - setDisabled(false) + if (flagTemplate === challenge?.flagTemplate) return + + setDisabled(true) + api.edit + // allow empty flag template to be set (but not null or undefined) + .editUpdateGameChallenge(numId, numCId, { flagTemplate }) + .then(() => { + showNotification({ + color: 'teal', + message: 'flag 模板已更新', + icon: , }) - } + challenge && mutate({ ...challenge, flagTemplate: flagTemplate }) + }) + .catch(showErrorNotification) + .finally(() => { + setDisabled(false) + }) } return ( diff --git a/src/GZCTF/ClientApp/src/pages/admin/games/[id]/challenges/[chalId]/Index.tsx b/src/GZCTF/ClientApp/src/pages/admin/games/[id]/challenges/[chalId]/Index.tsx index 0b1dcbc55..4ccf0c4a8 100644 --- a/src/GZCTF/ClientApp/src/pages/admin/games/[id]/challenges/[chalId]/Index.tsx +++ b/src/GZCTF/ClientApp/src/pages/admin/games/[id]/challenges/[chalId]/Index.tsx @@ -74,31 +74,31 @@ const GameChallengeEdit: FC = () => { }, [challenge]) const onUpdate = (challenge: ChallengeUpdateModel, noFeedback?: boolean) => { - if (challenge) { - setDisabled(true) - return api.edit - .editUpdateGameChallenge(numId, numCId, { - ...challenge, - isEnabled: undefined, - }) - .then((data) => { - if (!noFeedback) { - showNotification({ - color: 'teal', - message: '题目已更新', - icon: , - }) - } - mutate(data.data) - mutateChals() - }) - .catch(showErrorNotification) - .finally(() => { - if (!noFeedback) { - setDisabled(false) - } - }) - } + if (!challenge) return + + setDisabled(true) + return api.edit + .editUpdateGameChallenge(numId, numCId, { + ...challenge, + isEnabled: undefined, + }) + .then((data) => { + if (!noFeedback) { + showNotification({ + color: 'teal', + message: '题目已更新', + icon: , + }) + } + mutate(data.data) + mutateChals() + }) + .catch(showErrorNotification) + .finally(() => { + if (!noFeedback) { + setDisabled(false) + } + }) } const onConfirmDelete = () => { diff --git a/src/GZCTF/ClientApp/src/pages/posts/[postId]/edit.tsx b/src/GZCTF/ClientApp/src/pages/posts/[postId]/edit.tsx index 057802e2a..87580c4d0 100644 --- a/src/GZCTF/ClientApp/src/pages/posts/[postId]/edit.tsx +++ b/src/GZCTF/ClientApp/src/pages/posts/[postId]/edit.tsx @@ -96,33 +96,32 @@ const PostEdit: FC = () => { } const onDelete = () => { - if (postId) { - setDisabled(true) - api.edit - .editDeletePost(postId) - .then(() => { - api.info.mutateInfoGetPosts() - api.info.mutateInfoGetLatestPosts() - navigate('/posts') - }) - .catch(showErrorNotification) - .finally(() => { - setDisabled(false) - }) - } + if (!postId) return + setDisabled(true) + api.edit + .editDeletePost(postId) + .then(() => { + api.info.mutateInfoGetPosts() + api.info.mutateInfoGetLatestPosts() + navigate('/posts') + }) + .catch(showErrorNotification) + .finally(() => { + setDisabled(false) + }) } useEffect(() => { - if (curPost) { - setPost({ - title: curPost.title, - content: curPost.content, - summary: curPost.summary, - isPinned: curPost.isPinned, - tags: curPost.tags ?? [], - }) - setTags(curPost.tags ?? []) - } + if (!curPost) return + + setPost({ + title: curPost.title, + content: curPost.content, + summary: curPost.summary, + isPinned: curPost.isPinned, + tags: curPost.tags ?? [], + }) + setTags(curPost.tags ?? []) }, [curPost]) const isChanged = () => diff --git a/src/GZCTF/ClientApp/src/utils/Shared.tsx b/src/GZCTF/ClientApp/src/utils/Shared.tsx index acdd0b924..e54ea1809 100644 --- a/src/GZCTF/ClientApp/src/utils/Shared.tsx +++ b/src/GZCTF/ClientApp/src/utils/Shared.tsx @@ -315,3 +315,15 @@ export const getProxyUrl = (guid: string, test: boolean = false) => { const api = test ? 'api/proxy/noinst' : 'api/proxy' return `${protocol}//${window.location.host}/${api}/${guid}` } + +export const HunamizeSize = (size: number) => { + if (size < 1024) { + return `${size} B` + } else if (size < 1024 * 1024) { + return `${(size / 1024).toFixed(2)} KB` + } else if (size < 1024 * 1024 * 1024) { + return `${(size / 1024 / 1024).toFixed(2)} MB` + } else { + return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB` + } +} diff --git a/src/GZCTF/Utils/Shared.cs b/src/GZCTF/Utils/Shared.cs index 2516be53a..83cd83962 100644 --- a/src/GZCTF/Utils/Shared.cs +++ b/src/GZCTF/Utils/Shared.cs @@ -143,7 +143,6 @@ internal static FileRecord FromFileInfo(FileInfo info) }; } - /// /// 三血加分 /// From d87e760c374fadfee350dd9b9b2cd4fdc48ead35 Mon Sep 17 00:00:00 2001 From: Aether Chen <15167799+chenjunyu19@users.noreply.github.com> Date: Sun, 20 Aug 2023 23:23:01 +0800 Subject: [PATCH 53/67] wip: add ui for traffic capturing --- .../src/components/WithGameMonitor.tsx | 9 +- .../src/pages/games/[id]/monitor/Traffic.tsx | 181 ++++++++++++++++++ 2 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 src/GZCTF/ClientApp/src/pages/games/[id]/monitor/Traffic.tsx diff --git a/src/GZCTF/ClientApp/src/components/WithGameMonitor.tsx b/src/GZCTF/ClientApp/src/components/WithGameMonitor.tsx index 229ed3cd0..84bcda10e 100644 --- a/src/GZCTF/ClientApp/src/components/WithGameMonitor.tsx +++ b/src/GZCTF/ClientApp/src/components/WithGameMonitor.tsx @@ -1,7 +1,13 @@ import React, { FC, useEffect, useState } from 'react' import { useLocation, useNavigate, useParams } from 'react-router-dom' import { Button, Group, LoadingOverlay, Stack, Tabs, useMantineTheme } from '@mantine/core' -import { mdiFileTableOutline, mdiFlag, mdiLightningBolt, mdiExclamationThick } from '@mdi/js' +import { + mdiFileTableOutline, + mdiFlag, + mdiLightningBolt, + mdiExclamationThick, + mdiPackageVariant, +} from '@mdi/js' import { Icon } from '@mdi/react' import WithGameTab from '@Components/WithGameTab' import WithNavBar from '@Components/WithNavbar' @@ -12,6 +18,7 @@ const pages = [ { icon: mdiLightningBolt, title: '事件监控', path: 'events' }, { icon: mdiFlag, title: '提交记录', path: 'submissions' }, { icon: mdiExclamationThick, title: '作弊信息', path: 'cheatinfo' }, + { icon: mdiPackageVariant, title: '流量捕获', path: 'traffic' }, ] interface WithGameMonitorProps extends React.PropsWithChildren { diff --git a/src/GZCTF/ClientApp/src/pages/games/[id]/monitor/Traffic.tsx b/src/GZCTF/ClientApp/src/pages/games/[id]/monitor/Traffic.tsx new file mode 100644 index 000000000..83c264344 --- /dev/null +++ b/src/GZCTF/ClientApp/src/pages/games/[id]/monitor/Traffic.tsx @@ -0,0 +1,181 @@ +import dayjs from 'dayjs' +import { FC, useEffect, useState } from 'react' +import { useParams } from 'react-router-dom' +import { Avatar, Group, NavLink, Paper, ScrollArea } from '@mantine/core' +import { showNotification } from '@mantine/notifications' +import { mdiFileOutline, mdiClose, mdiPuzzle } from '@mdi/js' +import { Icon } from '@mdi/react' +import WithGameMonitorTab from '@Components/WithGameMonitor' +import { ChallengeTypeLabelMap, ChallengeTagLabelMap, HunamizeSize } from '@Utils/Shared' +import api, { ChallengeTrafficModel, FileRecord, TeamTrafficModel } from '@Api' + +const Traffic: FC = () => { + const { id } = useParams() + const gameId = parseInt(id ?? '-1') + const [challengeId, setChallengeId] = useState(null) + const [participationId, setParticipationId] = useState(null) + + const [challengeTraffic, setChallengeTraffic] = useState< + ChallengeTrafficModel[] | null | undefined + >(null) + const [teamTraffic, setTeamTraffic] = useState(null) + const [fileRecords, setFileRecords] = useState(null) + + useEffect(() => { + setChallengeId(null) + setChallengeTraffic(undefined) + api.game + .gameGetChallengesWithTrafficCapturing(gameId) + .then((data) => { + setChallengeTraffic(data.data) + }) + .catch((err) => { + showNotification({ + color: 'red', + title: '获取题目列表失败', + message: err.response.data.title, + icon: , + }) + }) + }, [gameId]) + + useEffect(() => { + setParticipationId(null) + if (challengeId) { + setTeamTraffic(undefined) + api.game + .gameGetChallengeTraffic(challengeId) + .then((data) => { + setTeamTraffic(data.data) + }) + .catch((err) => { + showNotification({ + color: 'red', + title: '获取队伍列表失败', + message: err.response.data.title, + icon: , + }) + }) + } else { + setTeamTraffic(null) + } + }, [challengeId]) + + useEffect(() => { + if (challengeId && participationId) { + setFileRecords(undefined) + api.game + .gameGetTeamTrafficAll(challengeId, participationId) + .then((data) => { + setFileRecords(data.data) + }) + .catch((err) => { + showNotification({ + color: 'red', + title: '获取流量包列表失败', + message: err.response.data.title, + icon: , + }) + }) + } else { + setFileRecords(null) + } + }, [challengeId, participationId]) + + const onDownload = (filename: string) => { + if (challengeId && participationId && filename) { + window.open(`/api/game/captures/${challengeId}/${participationId}/${filename}`) + } else { + showNotification({ + color: 'red', + title: '客户端发生错误', + message: '请尝试刷新页面', + icon: , + }) + } + } + + return ( + + + + + + {challengeTraffic === undefined + ? 'Loading' + : challengeTraffic === null || challengeTraffic.length === 0 + ? 'No Data' + : challengeTraffic.map((value) => ( + + } + label={value.title} + description={ChallengeTypeLabelMap.get(value.type!)?.label} + rightSection={value.count} + key={value.id} + active={value.id === challengeId} + onClick={() => setChallengeId(value.id!)} + variant="filled" + /> + ))} + + + {teamTraffic === undefined + ? 'Loading' + : teamTraffic === null || teamTraffic.length === 0 + ? 'No Data' + : teamTraffic.map((value) => ( + + {value.name?.slice(0, 1) ?? 'T'} + + } + label={value.name} + description={value.organization} + rightSection={value.count} + key={value.participationId} + active={value.participationId === participationId} + onClick={() => setParticipationId(value.participationId!)} + variant="filled" + /> + ))} + + + + {fileRecords === undefined + ? 'Loading' + : fileRecords === null || fileRecords.length === 0 + ? 'No Data' + : fileRecords.map((value) => ( + } + label={value.fileName} + description={dayjs(value.updateTime).format('YYYY/MM/DD HH:mm:ss')} + rightSection={HunamizeSize(value.size!)} + key={value.fileName} + onClick={() => onDownload(value.fileName!)} + /> + ))} + + + + + ) +} + +export default Traffic From b0ba199bd338eda437fbf75b967b73d47dfbef99 Mon Sep 17 00:00:00 2001 From: Aether Chen <15167799+chenjunyu19@users.noreply.github.com> Date: Sun, 20 Aug 2023 23:25:33 +0800 Subject: [PATCH 54/67] chore: use IEC standard for HunamizeSize --- src/GZCTF/ClientApp/src/utils/Shared.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/GZCTF/ClientApp/src/utils/Shared.tsx b/src/GZCTF/ClientApp/src/utils/Shared.tsx index e54ea1809..555be4389 100644 --- a/src/GZCTF/ClientApp/src/utils/Shared.tsx +++ b/src/GZCTF/ClientApp/src/utils/Shared.tsx @@ -320,10 +320,10 @@ export const HunamizeSize = (size: number) => { if (size < 1024) { return `${size} B` } else if (size < 1024 * 1024) { - return `${(size / 1024).toFixed(2)} KB` + return `${(size / 1024).toFixed(2)} KiB` } else if (size < 1024 * 1024 * 1024) { - return `${(size / 1024 / 1024).toFixed(2)} MB` + return `${(size / 1024 / 1024).toFixed(2)} MiB` } else { - return `${(size / 1024 / 1024 / 1024).toFixed(2)} GB` + return `${(size / 1024 / 1024 / 1024).toFixed(2)} GiB` } } From 86a09dd9349b4ffc781dbbb4665e4f99f9f37169 Mon Sep 17 00:00:00 2001 From: GZTime Date: Mon, 21 Aug 2023 01:24:41 +0800 Subject: [PATCH 55/67] wip: Traffic page --- .../src/components/ChallengeDetailModal.tsx | 2 +- .../src/components/InstanceEntry.tsx | 2 +- src/GZCTF/ClientApp/src/pages/About.tsx | 2 +- .../src/pages/admin/games/[id]/Writeups.tsx | 2 +- .../games/[id]/challenges/[chalId]/Flags.tsx | 4 +- .../src/pages/games/[id]/monitor/Traffic.tsx | 314 +++++++++--------- 6 files changed, 162 insertions(+), 164 deletions(-) diff --git a/src/GZCTF/ClientApp/src/components/ChallengeDetailModal.tsx b/src/GZCTF/ClientApp/src/components/ChallengeDetailModal.tsx index 9e8f8ec1a..a4029f4d7 100644 --- a/src/GZCTF/ClientApp/src/components/ChallengeDetailModal.tsx +++ b/src/GZCTF/ClientApp/src/components/ChallengeDetailModal.tsx @@ -330,7 +330,7 @@ const ChallengeDetailModal: FC = (props) => { color="brand" onMouseEnter={downloadOpen} onMouseLeave={downloadClose} - onClick={() => window.open(challenge.context?.url ?? '#')} + onClick={() => window.open(challenge.context?.url ?? '#', '_blank')} top={0} right={0} pos="absolute" diff --git a/src/GZCTF/ClientApp/src/components/InstanceEntry.tsx b/src/GZCTF/ClientApp/src/components/InstanceEntry.tsx index 3f4eb7c26..329448822 100644 --- a/src/GZCTF/ClientApp/src/components/InstanceEntry.tsx +++ b/src/GZCTF/ClientApp/src/components/InstanceEntry.tsx @@ -128,7 +128,7 @@ export const InstanceEntry: FC = (props) => { } const onOpenInNew = () => { - window.open(`http://${instanceEntry}`) + window.open(`http://${instanceEntry}`, '_blank') } const onOpenInApp = () => { diff --git a/src/GZCTF/ClientApp/src/pages/About.tsx b/src/GZCTF/ClientApp/src/pages/About.tsx index 21e173013..ce3bcec2c 100644 --- a/src/GZCTF/ClientApp/src/pages/About.tsx +++ b/src/GZCTF/ClientApp/src/pages/About.tsx @@ -31,7 +31,7 @@ const About: FC = () => { window.open(repo)} + onClick={() => window.open(repo, '_blank')} style={{ cursor: 'pointer', }} diff --git a/src/GZCTF/ClientApp/src/pages/admin/games/[id]/Writeups.tsx b/src/GZCTF/ClientApp/src/pages/admin/games/[id]/Writeups.tsx index bf8920d8d..6e9328567 100644 --- a/src/GZCTF/ClientApp/src/pages/admin/games/[id]/Writeups.tsx +++ b/src/GZCTF/ClientApp/src/pages/admin/games/[id]/Writeups.tsx @@ -39,7 +39,7 @@ const GameWriteups: FC = () => { diff --git a/src/GZCTF/ClientApp/src/pages/admin/games/[id]/challenges/[chalId]/Flags.tsx b/src/GZCTF/ClientApp/src/pages/admin/games/[id]/challenges/[chalId]/Flags.tsx index e795e55c1..ec04afe5a 100644 --- a/src/GZCTF/ClientApp/src/pages/admin/games/[id]/challenges/[chalId]/Flags.tsx +++ b/src/GZCTF/ClientApp/src/pages/admin/games/[id]/challenges/[chalId]/Flags.tsx @@ -250,7 +250,9 @@ const OneAttachmentWithFlags: FC = ({ onDelete }) => { disabled={disabled || type === FileType.None} value={challenge?.attachment?.url ?? ''} w="calc(100% - 320px)" - onClick={() => challenge?.attachment?.url && window.open(challenge?.attachment?.url)} + onClick={() => + challenge?.attachment?.url && window.open(challenge?.attachment?.url, '_blank') + } /> ) : ( void + active: boolean +} + +interface ScrollSelectProps extends ScrollAreaProps { + itemComponent: React.FC + emptyPlaceholder?: React.ReactNode + items?: any[] + customClick?: boolean + selectedId?: number | null + onSelectId: (item: any | null) => void +} + +const ScrollSelect: FC = (props) => { + const { + itemComponent: ItemComponent, + emptyPlaceholder, + items, + selectedId, + onSelectId, + customClick, + ...ScrollAreaProps + } = props + + return ( + + {!items || items.length === 0 ? ( +
{emptyPlaceholder}
+ ) : ( + + {customClick + ? items.map((item) => ( + onSelectId(item)} + active={false} + {...item} + /> + )) + : items.map((item) => ( + onSelectId(item.id)} + active={selectedId === item.id} + {...item} + /> + ))} + + )} +
+ ) +} + +const ChallengeItem: FC = (itemProps) => { + const { onClick, active, ...props } = itemProps + + return ( + + ) +} + +const TeamItem: FC = (itemProps) => { + const { onClick, active, ...props } = itemProps + + return ( + + ) +} + +const FileItem: FC = (itemProps) => { + const { onClick, active, ...props } = itemProps + + return ( + + ) +} + const Traffic: FC = () => { const { id } = useParams() const gameId = parseInt(id ?? '-1') + const [challengeId, setChallengeId] = useState(null) const [participationId, setParticipationId] = useState(null) - const [challengeTraffic, setChallengeTraffic] = useState< - ChallengeTrafficModel[] | null | undefined - >(null) - const [teamTraffic, setTeamTraffic] = useState(null) - const [fileRecords, setFileRecords] = useState(null) - - useEffect(() => { - setChallengeId(null) - setChallengeTraffic(undefined) - api.game - .gameGetChallengesWithTrafficCapturing(gameId) - .then((data) => { - setChallengeTraffic(data.data) - }) - .catch((err) => { - showNotification({ - color: 'red', - title: '获取题目列表失败', - message: err.response.data.title, - icon: , - }) - }) - }, [gameId]) - - useEffect(() => { - setParticipationId(null) - if (challengeId) { - setTeamTraffic(undefined) - api.game - .gameGetChallengeTraffic(challengeId) - .then((data) => { - setTeamTraffic(data.data) - }) - .catch((err) => { - showNotification({ - color: 'red', - title: '获取队伍列表失败', - message: err.response.data.title, - icon: , - }) - }) - } else { - setTeamTraffic(null) - } - }, [challengeId]) - - useEffect(() => { - if (challengeId && participationId) { - setFileRecords(undefined) - api.game - .gameGetTeamTrafficAll(challengeId, participationId) - .then((data) => { - setFileRecords(data.data) - }) - .catch((err) => { - showNotification({ - color: 'red', - title: '获取流量包列表失败', - message: err.response.data.title, - icon: , - }) - }) - } else { - setFileRecords(null) - } - }, [challengeId, participationId]) - - const onDownload = (filename: string) => { - if (challengeId && participationId && filename) { - window.open(`/api/game/captures/${challengeId}/${participationId}/${filename}`) - } else { - showNotification({ - color: 'red', - title: '客户端发生错误', - message: '请尝试刷新页面', - icon: , - }) - } + const { data: challengeTraffic } = api.game.useGameGetChallengesWithTrafficCapturing(gameId) + const { data: teamTraffic } = api.game.useGameGetChallengeTraffic( + challengeId ?? 0, + {}, + !!challengeId + ) + const { data: fileRecords } = api.game.useGameGetTeamTrafficAll( + challengeId ?? 0, + participationId ?? 0, + {}, + !!challengeId && !!participationId + ) + + const onDownload = (item: FileRecord) => { + if (!challengeId || !participationId || !item.fileName) return + + window.open(`/api/game/captures/${challengeId}/${participationId}/${item.fileName}`, '_blank') + } + + const innerStyle: CSSProperties = { + borderRight: '1px solid gray', } return ( - - - - {challengeTraffic === undefined - ? 'Loading' - : challengeTraffic === null || challengeTraffic.length === 0 - ? 'No Data' - : challengeTraffic.map((value) => ( - - } - label={value.title} - description={ChallengeTypeLabelMap.get(value.type!)?.label} - rightSection={value.count} - key={value.id} - active={value.id === challengeId} - onClick={() => setChallengeId(value.id!)} - variant="filled" - /> - ))} - - - {teamTraffic === undefined - ? 'Loading' - : teamTraffic === null || teamTraffic.length === 0 - ? 'No Data' - : teamTraffic.map((value) => ( - - {value.name?.slice(0, 1) ?? 'T'} - - } - label={value.name} - description={value.organization} - rightSection={value.count} - key={value.participationId} - active={value.participationId === participationId} - onClick={() => setParticipationId(value.participationId!)} - variant="filled" - /> - ))} - - - - {fileRecords === undefined - ? 'Loading' - : fileRecords === null || fileRecords.length === 0 - ? 'No Data' - : fileRecords.map((value) => ( - } - label={value.fileName} - description={dayjs(value.updateTime).format('YYYY/MM/DD HH:mm:ss')} - rightSection={HunamizeSize(value.size!)} - key={value.fileName} - onClick={() => onDownload(value.fileName!)} - /> - ))} - - + + + + + + + + + + + ) From 49200e2ec9e9166c8dcdc3211d01a0bc8ddb29a3 Mon Sep 17 00:00:00 2001 From: GZTime Date: Mon, 21 Aug 2023 01:29:49 +0800 Subject: [PATCH 56/67] fix: use participation id as the model id --- src/GZCTF/ClientApp/src/Api.ts | 6 +++--- src/GZCTF/Models/Request/Game/TeamTrafficModel.cs | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/GZCTF/ClientApp/src/Api.ts b/src/GZCTF/ClientApp/src/Api.ts index b86c93014..e661bd8ad 100644 --- a/src/GZCTF/ClientApp/src/Api.ts +++ b/src/GZCTF/ClientApp/src/Api.ts @@ -1473,15 +1473,15 @@ export interface ChallengeTrafficModel { /** 队伍流量获取信息 */ export interface TeamTrafficModel { /** - * 队伍 Id + * 参与 Id * @format int32 */ id?: number /** - * 参与 Id + * 队伍 Id * @format int32 */ - participationId?: number + teamId?: number /** 队伍名称 */ name?: string | null /** 参赛所属组织 */ diff --git a/src/GZCTF/Models/Request/Game/TeamTrafficModel.cs b/src/GZCTF/Models/Request/Game/TeamTrafficModel.cs index c5018f26e..c46a9f0fd 100644 --- a/src/GZCTF/Models/Request/Game/TeamTrafficModel.cs +++ b/src/GZCTF/Models/Request/Game/TeamTrafficModel.cs @@ -8,14 +8,14 @@ namespace GZCTF.Models; public class TeamTrafficModel { /// - /// 队伍 Id + /// 参与 Id /// public int Id { get; set; } /// - /// 参与 Id + /// 队伍 Id /// - public int ParticipationId { get; set; } + public int TeamId { get; set; } /// /// 队伍名称 @@ -43,10 +43,10 @@ internal static TeamTrafficModel FromParticipation(Participation part, int chall return new() { - Id = part.Team.Id, + Id = part.Id, + TeamId = part.Team.Id, Name = part.Team.Name, Organization = part.Organization, - ParticipationId = part.Id, Avatar = part.Team.AvatarUrl, Count = Directory.Exists(trafficPath) ? Directory.GetDirectories(trafficPath, "*", SearchOption.TopDirectoryOnly).Length : 0 From 97e3cc4e29e0ba0e54d27b872da3072168cbad48 Mon Sep 17 00:00:00 2001 From: GZTime Date: Mon, 21 Aug 2023 01:35:01 +0800 Subject: [PATCH 57/67] fix: traffic api --- .../src/pages/games/[id]/monitor/Traffic.tsx | 12 +++++++++--- src/GZCTF/Models/Request/Game/TeamTrafficModel.cs | 2 +- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/GZCTF/ClientApp/src/pages/games/[id]/monitor/Traffic.tsx b/src/GZCTF/ClientApp/src/pages/games/[id]/monitor/Traffic.tsx index 913cca460..32c2e3f82 100644 --- a/src/GZCTF/ClientApp/src/pages/games/[id]/monitor/Traffic.tsx +++ b/src/GZCTF/ClientApp/src/pages/games/[id]/monitor/Traffic.tsx @@ -105,6 +105,12 @@ const FileItem: FC = (itemProps) => { ) } +const SWROptions = { + refreshInterval: 0, + shouldRetryOnError: false, + revalidateOnFocus: false, +} + const Traffic: FC = () => { const { id } = useParams() const gameId = parseInt(id ?? '-1') @@ -112,16 +118,16 @@ const Traffic: FC = () => { const [challengeId, setChallengeId] = useState(null) const [participationId, setParticipationId] = useState(null) - const { data: challengeTraffic } = api.game.useGameGetChallengesWithTrafficCapturing(gameId) + const { data: challengeTraffic } = api.game.useGameGetChallengesWithTrafficCapturing(gameId, SWROptions) const { data: teamTraffic } = api.game.useGameGetChallengeTraffic( challengeId ?? 0, - {}, + SWROptions, !!challengeId ) const { data: fileRecords } = api.game.useGameGetTeamTrafficAll( challengeId ?? 0, participationId ?? 0, - {}, + SWROptions, !!challengeId && !!participationId ) diff --git a/src/GZCTF/Models/Request/Game/TeamTrafficModel.cs b/src/GZCTF/Models/Request/Game/TeamTrafficModel.cs index c46a9f0fd..bda978f0c 100644 --- a/src/GZCTF/Models/Request/Game/TeamTrafficModel.cs +++ b/src/GZCTF/Models/Request/Game/TeamTrafficModel.cs @@ -49,7 +49,7 @@ internal static TeamTrafficModel FromParticipation(Participation part, int chall Organization = part.Organization, Avatar = part.Team.AvatarUrl, Count = Directory.Exists(trafficPath) ? - Directory.GetDirectories(trafficPath, "*", SearchOption.TopDirectoryOnly).Length : 0 + Directory.GetFiles(trafficPath, "*", SearchOption.TopDirectoryOnly).Length : 0 }; } } From cd07f720636fd8fa96fa53a76a68dd619028da1a Mon Sep 17 00:00:00 2001 From: GZTime Date: Mon, 21 Aug 2023 01:50:22 +0800 Subject: [PATCH 58/67] wip: add indent in traffic metadata --- src/GZCTF/ClientApp/package.json | 6 +-- src/GZCTF/ClientApp/pnpm-lock.yaml | 54 ++++++++++++------------ src/GZCTF/Controllers/ProxyController.cs | 3 +- 3 files changed, 32 insertions(+), 31 deletions(-) diff --git a/src/GZCTF/ClientApp/package.json b/src/GZCTF/ClientApp/package.json index 542dead33..8c18d8e48 100644 --- a/src/GZCTF/ClientApp/package.json +++ b/src/GZCTF/ClientApp/package.json @@ -32,7 +32,7 @@ "embla-carousel-react": "^7.1.0", "katex": "^0.16.8", "lz-string": "^1.5.0", - "marked": "^7.0.3", + "marked": "^7.0.4", "pdfjs-dist": "3.6.172", "prismjs": "^1.29.0", "react": "^18.2.0", @@ -48,7 +48,7 @@ "@nabla/vite-plugin-eslint": "^1.5.0", "@trivago/prettier-plugin-sort-imports": "^4.2.0", "@types/katex": "^0.16.2", - "@types/node": "20.5.0", + "@types/node": "20.5.1", "@types/prismjs": "^1.26.0", "@types/react": "^18.2.20", "@types/react-dom": "^18.2.7", @@ -64,7 +64,7 @@ "prettier": "~3.0.2", "rollup": "^3.28.0", "swagger-typescript-api": "^13.0.3", - "tslib": "^2.6.1", + "tslib": "^2.6.2", "typescript": "5.1.6", "vite": "^4.4.9", "vite-plugin-pages": "^0.31.0", diff --git a/src/GZCTF/ClientApp/pnpm-lock.yaml b/src/GZCTF/ClientApp/pnpm-lock.yaml index d6ff325a1..2babd9538 100644 --- a/src/GZCTF/ClientApp/pnpm-lock.yaml +++ b/src/GZCTF/ClientApp/pnpm-lock.yaml @@ -69,8 +69,8 @@ dependencies: specifier: ^1.5.0 version: 1.5.0 marked: - specifier: ^7.0.3 - version: 7.0.3 + specifier: ^7.0.4 + version: 7.0.4 pdfjs-dist: specifier: 3.6.172 version: 3.6.172 @@ -113,8 +113,8 @@ devDependencies: specifier: ^0.16.2 version: 0.16.2 '@types/node': - specifier: 20.5.0 - version: 20.5.0 + specifier: 20.5.1 + version: 20.5.1 '@types/prismjs': specifier: ^1.26.0 version: 1.26.0 @@ -161,14 +161,14 @@ devDependencies: specifier: ^13.0.3 version: 13.0.3 tslib: - specifier: ^2.6.1 - version: 2.6.1 + specifier: ^2.6.2 + version: 2.6.2 typescript: specifier: 5.1.6 version: 5.1.6 vite: specifier: ^4.4.9 - version: 4.4.9(@types/node@20.5.0) + version: 4.4.9(@types/node@20.5.1) vite-plugin-pages: specifier: ^0.31.0 version: 0.31.0(vite@4.4.9) @@ -1102,7 +1102,7 @@ packages: '@types/eslint': 8.40.0 chalk: 4.1.2 eslint: 8.47.0 - vite: 4.4.9(@types/node@20.5.0) + vite: 4.4.9(@types/node@20.5.1) dev: true /@nicolo-ribaudo/eslint-scope-5-internals@5.1.1-v1: @@ -1307,8 +1307,8 @@ packages: resolution: {integrity: sha512-iiUgKzV9AuaEkZqkOLDIvlQiL6ltuZd9tGcW3gwpnX8JbuiuhFlEGmmFXEXkN50Cvq7Os88IY2v0dkDqXYWVgA==} dev: true - /@types/node@20.5.0: - resolution: {integrity: sha512-Mgq7eCtoTjT89FqNoTzzXg2XvCi5VMhRV6+I2aYanc6kQCBImeNaAYRs/DyoVqk1YEUJK5gN9VO7HRIdz4Wo3Q==} + /@types/node@20.5.1: + resolution: {integrity: sha512-4tT2UrL5LBqDwoed9wZ6N3umC4Yhz3W3FloMmiiG4JwmUJWpie0c7lcnUNd4gtMKuDEO4wRVS8B6Xa0uMRsMKg==} /@types/parse-json@4.0.0: resolution: {integrity: sha512-//oorEZjL6sbPcKUaCdIGlIUeH26mgzimjBB77G6XRgnDl/L5wOnpyBGRe/Mmf5CVW3PwEBE1NjiMZ/ssFh4wA==} @@ -1486,7 +1486,7 @@ packages: '@babel/plugin-transform-react-jsx-self': 7.22.5(@babel/core@7.22.10) '@babel/plugin-transform-react-jsx-source': 7.22.5(@babel/core@7.22.10) react-refresh: 0.14.0 - vite: 4.4.9(@types/node@20.5.0) + vite: 4.4.9(@types/node@20.5.1) transitivePeerDependencies: - supports-color dev: true @@ -1579,7 +1579,7 @@ packages: resolution: {integrity: sha512-xcLxITLe2HYa1cnYnwCjkOO1PqUHQpozB8x9AR0OgWN2woOBi5kSDVxKfd0b7sb1hw5qFeJhXm9H1nu3xSfLeQ==} engines: {node: '>=10'} dependencies: - tslib: 2.6.1 + tslib: 2.6.2 dev: false /array-buffer-byte-length@1.0.0: @@ -2289,7 +2289,7 @@ packages: resolution: {integrity: sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==} engines: {node: '>= 12'} dependencies: - tslib: 2.6.1 + tslib: 2.6.2 dev: false /fill-range@7.0.1: @@ -2869,8 +2869,8 @@ packages: resolution: {integrity: sha512-JhvWq/iz1BvlmnPvLJjXv+xnMPJZuychrDC68V+yCGQJn5chcA8rLGKo5EP1XwIKVrigSXKLmbeXAGkf36wdCQ==} dev: false - /marked@7.0.3: - resolution: {integrity: sha512-ev2uM40p0zQ/GbvqotfKcSWEa59fJwluGZj5dcaUOwDRrB1F3dncdXy8NWUApk4fi8atU3kTBOwjyjZ0ud0dxw==} + /marked@7.0.4: + resolution: {integrity: sha512-t8eP0dXRJMtMvBojtkcsA7n48BkauktUKzfkPSCq85ZMTJ0v76Rke4DYz01omYpPTUh4p/f7HePgRo3ebG8+QQ==} engines: {node: '>= 16'} hasBin: true dev: false @@ -3341,7 +3341,7 @@ packages: '@types/react': 18.2.20 react: 18.2.0 react-style-singleton: 2.2.1(@types/react@18.2.20)(react@18.2.0) - tslib: 2.6.1 + tslib: 2.6.2 dev: false /react-remove-scroll@2.5.6(@types/react@18.2.20)(react@18.2.0): @@ -3358,7 +3358,7 @@ packages: react: 18.2.0 react-remove-scroll-bar: 2.3.4(@types/react@18.2.20)(react@18.2.0) react-style-singleton: 2.2.1(@types/react@18.2.20)(react@18.2.0) - tslib: 2.6.1 + tslib: 2.6.2 use-callback-ref: 1.3.0(@types/react@18.2.20)(react@18.2.0) use-sidecar: 1.1.2(@types/react@18.2.20)(react@18.2.0) dev: false @@ -3400,7 +3400,7 @@ packages: get-nonce: 1.0.1 invariant: 2.2.4 react: 18.2.0 - tslib: 2.6.1 + tslib: 2.6.2 dev: false /react-textarea-autosize@8.3.4(@types/react@18.2.20)(react@18.2.0): @@ -3848,8 +3848,8 @@ packages: resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} dev: false - /tslib@2.6.1: - resolution: {integrity: sha512-t0hLfiEKfMUoqhG+U1oid7Pva4bbDPHYfJNiB7BiIjRkj1pyC++4N3huJfqY6aRH6VTB0rvtzQwjM4K6qpfOig==} + /tslib@2.6.2: + resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} /type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} @@ -3913,7 +3913,7 @@ packages: dependencies: '@types/react': 18.2.20 react: 18.2.0 - tslib: 2.6.1 + tslib: 2.6.2 dev: false /use-composed-ref@1.3.0(react@18.2.0): @@ -3964,7 +3964,7 @@ packages: '@types/react': 18.2.20 detect-node-es: 1.1.0 react: 18.2.0 - tslib: 2.6.1 + tslib: 2.6.2 dev: false /use-sync-external-store@1.2.0(react@18.2.0): @@ -3998,7 +3998,7 @@ packages: json5: 2.2.3 local-pkg: 0.4.3 picocolors: 1.0.0 - vite: 4.4.9(@types/node@20.5.0) + vite: 4.4.9(@types/node@20.5.1) yaml: 2.3.1 transitivePeerDependencies: - supports-color @@ -4024,7 +4024,7 @@ packages: clean-css: 5.3.2 flat-cache: 3.0.4 picocolors: 1.0.0 - vite: 4.4.9(@types/node@20.5.0) + vite: 4.4.9(@types/node@20.5.1) transitivePeerDependencies: - debug dev: true @@ -4040,13 +4040,13 @@ packages: debug: 4.3.4 globrex: 0.1.2 tsconfck: 2.1.1(typescript@5.1.6) - vite: 4.4.9(@types/node@20.5.0) + vite: 4.4.9(@types/node@20.5.1) transitivePeerDependencies: - supports-color - typescript dev: false - /vite@4.4.9(@types/node@20.5.0): + /vite@4.4.9(@types/node@20.5.1): resolution: {integrity: sha512-2mbUn2LlUmNASWwSCNSJ/EG2HuSRTnVNaydp6vMCm5VIqJsjMfbIWtbH2kDuwUVW5mMUKKZvGPX/rqeqVvv1XA==} engines: {node: ^14.18.0 || >=16.0.0} hasBin: true @@ -4074,7 +4074,7 @@ packages: terser: optional: true dependencies: - '@types/node': 20.5.0 + '@types/node': 20.5.1 esbuild: 0.18.19 postcss: 8.4.27 rollup: 3.28.0 diff --git a/src/GZCTF/Controllers/ProxyController.cs b/src/GZCTF/Controllers/ProxyController.cs index 0e4494d2e..e5d9e652c 100644 --- a/src/GZCTF/Controllers/ProxyController.cs +++ b/src/GZCTF/Controllers/ProxyController.cs @@ -28,7 +28,8 @@ public class ProxyController : ControllerBase private const uint CONNECTION_LIMIT = 64; private readonly JsonSerializerOptions _JsonOptions = new() { - Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping, + WriteIndented = true, }; public ProxyController(ILogger logger, IDistributedCache cache, From 7343aad2f38211eb4fc7717006bf58fb8fd3aab6 Mon Sep 17 00:00:00 2001 From: GZTime Date: Mon, 21 Aug 2023 22:24:28 +0800 Subject: [PATCH 59/67] wip: proxy --- .../ClientApp/src/components/ScrollSelect.tsx | 120 ++++++++++++++++++ .../ClientApp/src/components/TrafficItems.tsx | 46 +++++++ .../ClientApp/src/pages/admin/games/Index.tsx | 6 +- .../src/pages/games/[id]/monitor/Traffic.tsx | 119 +++-------------- src/GZCTF/Controllers/EditController.cs | 3 + src/GZCTF/Controllers/ProxyController.cs | 18 +-- src/GZCTF/Models/Data/Challenge.cs | 12 +- src/GZCTF/Utils/LogHelper.cs | 2 +- 8 files changed, 205 insertions(+), 121 deletions(-) create mode 100644 src/GZCTF/ClientApp/src/components/ScrollSelect.tsx create mode 100644 src/GZCTF/ClientApp/src/components/TrafficItems.tsx diff --git a/src/GZCTF/ClientApp/src/components/ScrollSelect.tsx b/src/GZCTF/ClientApp/src/components/ScrollSelect.tsx new file mode 100644 index 000000000..75e8498b1 --- /dev/null +++ b/src/GZCTF/ClientApp/src/components/ScrollSelect.tsx @@ -0,0 +1,120 @@ +import { FC, forwardRef } from 'react' +import { + ScrollAreaProps, + ScrollArea, + Center, + Stack, + createStyles, + rem, + UnstyledButton, + UnstyledButtonProps, +} from '@mantine/core' + +export interface SelectableItemProps extends UnstyledButtonProps { + onClick: () => void + active: boolean + disabled?: boolean + item: T +} + +interface ScrollSelectProps extends ScrollAreaProps { + itemComponent: React.FC + emptyPlaceholder?: React.ReactNode + items?: any[] + customClick?: boolean + selectedId?: number | null + onSelectId: (item: any | null) => void +} + +const useItemStyle = createStyles((theme) => ({ + root: { + display: 'flex', + alignItems: 'center', + width: '100%', + padding: `${rem(8)} ${theme.spacing.sm}`, + userSelect: 'none', + + ...theme.fn.hover({ + backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[0], + }), + + '&[data-active]': { + backgroundColor: theme.colors.background, + color: theme.colors.color, + ...theme.fn.hover({ backgroundColor: theme.colors.hover }), + }, + + '&[data-disabled]': { + opacity: 0.4, + pointerEvents: 'none', + }, + }, + + description: { + display: 'block', + + '&[data-active]': { + color: 'inherit', + }, + }, +})) + +export const SelectableItem = forwardRef((props, ref) => { + const { onClick, active, children, disabled, className, ...others } = props + const { classes, cx } = useItemStyle() + + return ( + + {children} + + ) +}) + +const ScrollSelect: FC = (props) => { + const { + itemComponent: ItemComponent, + emptyPlaceholder, + items, + selectedId, + onSelectId, + customClick, + ...ScrollAreaProps + } = props + + return ( + + {!items || items.length === 0 ? ( +
{emptyPlaceholder}
+ ) : ( + + {customClick + ? items.map((item) => ( + onSelectId(item)} + active={false} + item={item} + /> + )) + : items.map((item) => ( + onSelectId(item.id)} + active={selectedId === item.id} + item={item} + /> + ))} + + )} +
+ ) +} + +export default ScrollSelect diff --git a/src/GZCTF/ClientApp/src/components/TrafficItems.tsx b/src/GZCTF/ClientApp/src/components/TrafficItems.tsx new file mode 100644 index 000000000..8295cdfbf --- /dev/null +++ b/src/GZCTF/ClientApp/src/components/TrafficItems.tsx @@ -0,0 +1,46 @@ +import dayjs from 'dayjs' +import { FC } from 'react' +import { Stack, Text } from '@mantine/core' +import { SelectableItemProps, SelectableItem } from '@Components/ScrollSelect' +import { HunamizeSize } from '@Utils/Shared' +import { ChallengeTrafficModel, TeamTrafficModel, FileRecord } from '@Api' + +export const ChallengeItem: FC> = (props) => { + const item = props.item + + return ( + + + {item.title} + {item.type} + + + ) +} + +export const TeamItem: FC> = (props) => { + const item = props.item + + return ( + + + {item.name} + {item.organization} + + + ) +} + +export const FileItem: FC> = (props) => { + const item = props.item + + return ( + + + {item.fileName} + {HunamizeSize(item.size ?? 0)} + {dayjs(item.updateTime).format('YYYY-MM-DD HH:mm:ss')} + + + ) +} diff --git a/src/GZCTF/ClientApp/src/pages/admin/games/Index.tsx b/src/GZCTF/ClientApp/src/pages/admin/games/Index.tsx index f72c8fdc5..9e573311a 100644 --- a/src/GZCTF/ClientApp/src/pages/admin/games/Index.tsx +++ b/src/GZCTF/ClientApp/src/pages/admin/games/Index.tsx @@ -112,7 +112,7 @@ const Games: FC = () => { - + @@ -130,7 +130,7 @@ const Games: FC = () => { @@ -164,7 +164,7 @@ const Games: FC = () => { diff --git a/src/GZCTF/ClientApp/src/pages/games/[id]/monitor/Traffic.tsx b/src/GZCTF/ClientApp/src/pages/games/[id]/monitor/Traffic.tsx index 32c2e3f82..b788aab44 100644 --- a/src/GZCTF/ClientApp/src/pages/games/[id]/monitor/Traffic.tsx +++ b/src/GZCTF/ClientApp/src/pages/games/[id]/monitor/Traffic.tsx @@ -1,109 +1,11 @@ import dayjs from 'dayjs' import { CSSProperties, FC, useState } from 'react' import { useParams } from 'react-router-dom' -import { Center, Grid, NavLink, Paper, ScrollArea, ScrollAreaProps, Stack } from '@mantine/core' +import { Grid, Paper } from '@mantine/core' +import ScrollSelect from '@Components/ScrollSelect' +import { ChallengeItem, TeamItem, FileItem } from '@Components/TrafficItems' import WithGameMonitorTab from '@Components/WithGameMonitor' -import { HunamizeSize } from '@Utils/Shared' -import api, { ChallengeTrafficModel, FileRecord, TeamTrafficModel } from '@Api' - -interface SelectableItemProps { - onClick: () => void - active: boolean -} - -interface ScrollSelectProps extends ScrollAreaProps { - itemComponent: React.FC - emptyPlaceholder?: React.ReactNode - items?: any[] - customClick?: boolean - selectedId?: number | null - onSelectId: (item: any | null) => void -} - -const ScrollSelect: FC = (props) => { - const { - itemComponent: ItemComponent, - emptyPlaceholder, - items, - selectedId, - onSelectId, - customClick, - ...ScrollAreaProps - } = props - - return ( - - {!items || items.length === 0 ? ( -
{emptyPlaceholder}
- ) : ( - - {customClick - ? items.map((item) => ( - onSelectId(item)} - active={false} - {...item} - /> - )) - : items.map((item) => ( - onSelectId(item.id)} - active={selectedId === item.id} - {...item} - /> - ))} - - )} -
- ) -} - -const ChallengeItem: FC = (itemProps) => { - const { onClick, active, ...props } = itemProps - - return ( - - ) -} - -const TeamItem: FC = (itemProps) => { - const { onClick, active, ...props } = itemProps - - return ( - - ) -} - -const FileItem: FC = (itemProps) => { - const { onClick, active, ...props } = itemProps - - return ( - - ) -} +import api, { FileRecord } from '@Api' const SWROptions = { refreshInterval: 0, @@ -118,7 +20,10 @@ const Traffic: FC = () => { const [challengeId, setChallengeId] = useState(null) const [participationId, setParticipationId] = useState(null) - const { data: challengeTraffic } = api.game.useGameGetChallengesWithTrafficCapturing(gameId, SWROptions) + const { data: challengeTraffic } = api.game.useGameGetChallengesWithTrafficCapturing( + gameId, + SWROptions + ) const { data: teamTraffic } = api.game.useGameGetChallengeTraffic( challengeId ?? 0, SWROptions, @@ -137,6 +42,12 @@ const Traffic: FC = () => { window.open(`/api/game/captures/${challengeId}/${participationId}/${item.fileName}`, '_blank') } + const orderedFileRecords = + fileRecords?.sort((a, b) => dayjs(b.updateTime).diff(dayjs(a.updateTime))) ?? [] + + // make list longer for testing by duplicating 3 times + const testFiles = orderedFileRecords.concat(orderedFileRecords).concat(orderedFileRecords) + const innerStyle: CSSProperties = { borderRight: '1px solid gray', } @@ -168,7 +79,7 @@ const Traffic: FC = () => { UpdateGameChallenge([FromRoute] int id, [FromRo if (model.IsEnabled == true && !res.Flags.Any() && res.Type != ChallengeType.DynamicContainer) return BadRequest(new RequestResponse("题目无 flag,不可启用")); + if (res.EnableTrafficCapture && res.Type.IsContainer()) + return BadRequest(new RequestResponse("只有容器题目可以进行流量捕获")); + if (model.FileName is not null && string.IsNullOrWhiteSpace(model.FileName)) return BadRequest(new RequestResponse("动态附件名不可为空")); diff --git a/src/GZCTF/Controllers/ProxyController.cs b/src/GZCTF/Controllers/ProxyController.cs index e5d9e652c..2860e7d23 100644 --- a/src/GZCTF/Controllers/ProxyController.cs +++ b/src/GZCTF/Controllers/ProxyController.cs @@ -104,12 +104,12 @@ public async Task ProxyForInstance(string id, CancellationToken t IPEndPoint target = new(ipAddress, container.Port); return await DoContainerProxy(id, client, target, metadata, new() - { - Source = client, - Dest = target, - EnableCapture = enable, - FilePath = container.TrafficPath(HttpContext.Connection.Id), - }, token); + { + Source = client, + Dest = target, + EnableCapture = enable, + FilePath = container.TrafficPath(HttpContext.Connection.Id), + }, token); } /// @@ -239,9 +239,9 @@ internal async Task DoContainerProxy(string id, IPEndPoint client var receiver = Task.Run(async () => { var buffer = new byte[BUFFER_SIZE]; - try - { - while (true) + try + { + while (true) { var count = await stream.ReadAsync(buffer, ct); if (count == 0) diff --git a/src/GZCTF/Models/Data/Challenge.cs b/src/GZCTF/Models/Data/Challenge.cs index 32591e62d..914f87589 100644 --- a/src/GZCTF/Models/Data/Challenge.cs +++ b/src/GZCTF/Models/Data/Challenge.cs @@ -242,20 +242,24 @@ internal Challenge Update(ChallengeUpdateModel model) Tag = model.Tag ?? Tag; Hints = model.Hints ?? Hints; IsEnabled = model.IsEnabled ?? IsEnabled; - // only set FlagTemplate to null when it pass an empty string (but not null) - FlagTemplate = model.FlagTemplate is null ? FlagTemplate : - string.IsNullOrWhiteSpace(model.FlagTemplate) ? null : model.FlagTemplate; CPUCount = model.CPUCount ?? CPUCount; MemoryLimit = model.MemoryLimit ?? MemoryLimit; StorageLimit = model.StorageLimit ?? StorageLimit; ContainerImage = model.ContainerImage?.Trim() ?? ContainerImage; - EnableTrafficCapture = model.EnableTrafficCapture ?? EnableTrafficCapture; ContainerExposePort = model.ContainerExposePort ?? ContainerExposePort; OriginalScore = model.OriginalScore ?? OriginalScore; MinScoreRate = model.MinScoreRate ?? MinScoreRate; Difficulty = model.Difficulty ?? Difficulty; FileName = model.FileName ?? FileName; + // only set FlagTemplate to null when it pass an empty string (but not null) + FlagTemplate = model.FlagTemplate is null ? FlagTemplate : + string.IsNullOrWhiteSpace(model.FlagTemplate) ? null : model.FlagTemplate; + + // DynamicContainer only + EnableTrafficCapture = Type == ChallengeType.DynamicContainer && + (model.EnableTrafficCapture ?? EnableTrafficCapture); + return this; } } diff --git a/src/GZCTF/Utils/LogHelper.cs b/src/GZCTF/Utils/LogHelper.cs index 2274faa38..860ad600e 100644 --- a/src/GZCTF/Utils/LogHelper.cs +++ b/src/GZCTF/Utils/LogHelper.cs @@ -89,7 +89,7 @@ public static void UseRequestLogging(this WebApplication app) context.Response.StatusCode == 204 ? LogEventLevel.Verbose : time > 10000 && context.Response.StatusCode != 101 ? LogEventLevel.Warning : (context.Response.StatusCode > 499 || ex is not null) ? LogEventLevel.Error : LogEventLevel.Debug; - + options.EnrichDiagnosticContext = (diagnosticContext, httpContext) => { diagnosticContext.Set("RemoteIP", httpContext.Connection.RemoteIpAddress); From dda3e6cf1f9f91064cd05708973c5169d51fb55c Mon Sep 17 00:00:00 2001 From: GZTime Date: Mon, 21 Aug 2023 23:43:23 +0800 Subject: [PATCH 60/67] wip: traffic capture page --- .../ClientApp/src/components/ScrollSelect.tsx | 7 +- .../ClientApp/src/components/TrafficItems.tsx | 100 ++++++++++++++---- .../components/admin/ChallengeEditCard.tsx | 2 +- .../src/pages/games/[id]/monitor/Traffic.tsx | 72 ++++++++++--- src/GZCTF/Models/Data/Container.cs | 2 +- 5 files changed, 143 insertions(+), 40 deletions(-) diff --git a/src/GZCTF/ClientApp/src/components/ScrollSelect.tsx b/src/GZCTF/ClientApp/src/components/ScrollSelect.tsx index 75e8498b1..82056ffdf 100644 --- a/src/GZCTF/ClientApp/src/components/ScrollSelect.tsx +++ b/src/GZCTF/ClientApp/src/components/ScrollSelect.tsx @@ -39,8 +39,7 @@ const useItemStyle = createStyles((theme) => ({ }), '&[data-active]': { - backgroundColor: theme.colors.background, - color: theme.colors.color, + backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.white[2], ...theme.fn.hover({ backgroundColor: theme.colors.hover }), }, @@ -89,11 +88,11 @@ const ScrollSelect: FC = (props) => { } = props return ( - + {!items || items.length === 0 ? (
{emptyPlaceholder}
) : ( - + {customClick ? items.map((item) => ( > = (props) => { const item = props.item + const data = ChallengeTagLabelMap.get(item.tag as ChallengeTag)! + const theme = useMantineTheme() + const type = item.type === ChallengeType.DynamicContainer ? 'dyn' : 'sta' return ( - - - {item.title} - {item.type} - + + + + + + + {item.title} + + + {type} + + + + + + {item.count} 队伍 + + ) } @@ -22,11 +47,39 @@ export const TeamItem: FC> = (props) => { const item = props.item return ( - - - {item.name} - {item.organization} - + + + + ({ + ...theme.fn.hover({ + cursor: 'pointer', + }), + })} + > + {item.name?.slice(0, 1) ?? 'T'} + + + + {item.name ?? 'Team'} + + {item.organization && ( + + {item.organization} + + )} + + + + + {item.count} 流量 + + ) } @@ -35,12 +88,21 @@ export const FileItem: FC> = (props) => { const item = props.item return ( - - - {item.fileName} - {HunamizeSize(item.size ?? 0)} - {dayjs(item.updateTime).format('YYYY-MM-DD HH:mm:ss')} - + + + + + {item.fileName} + + + {dayjs(item.updateTime).format('MM/DD HH:mm:ss')} + + + + + {HunamizeSize(item.size ?? 0)} + + ) } diff --git a/src/GZCTF/ClientApp/src/components/admin/ChallengeEditCard.tsx b/src/GZCTF/ClientApp/src/components/admin/ChallengeEditCard.tsx index 1425b978a..75ababaf0 100644 --- a/src/GZCTF/ClientApp/src/components/admin/ChallengeEditCard.tsx +++ b/src/GZCTF/ClientApp/src/components/admin/ChallengeEditCard.tsx @@ -55,7 +55,7 @@ const ChallengeEditCard: FC = ({ challenge, onToggle }) /> - + {challenge.title} diff --git a/src/GZCTF/ClientApp/src/pages/games/[id]/monitor/Traffic.tsx b/src/GZCTF/ClientApp/src/pages/games/[id]/monitor/Traffic.tsx index b788aab44..ccd627993 100644 --- a/src/GZCTF/ClientApp/src/pages/games/[id]/monitor/Traffic.tsx +++ b/src/GZCTF/ClientApp/src/pages/games/[id]/monitor/Traffic.tsx @@ -1,10 +1,14 @@ import dayjs from 'dayjs' import { CSSProperties, FC, useState } from 'react' import { useParams } from 'react-router-dom' -import { Grid, Paper } from '@mantine/core' +import { Group, Grid, Paper, Text, Divider, rem, ActionIcon, Tooltip } from '@mantine/core' +import { showNotification } from '@mantine/notifications' +import { mdiClose, mdiDownload } from '@mdi/js' +import Icon from '@mdi/react' import ScrollSelect from '@Components/ScrollSelect' import { ChallengeItem, TeamItem, FileItem } from '@Components/TrafficItems' import WithGameMonitorTab from '@Components/WithGameMonitor' +import { useTooltipStyles } from '@Utils/ThemeOverride' import api, { FileRecord } from '@Api' const SWROptions = { @@ -19,6 +23,7 @@ const Traffic: FC = () => { const [challengeId, setChallengeId] = useState(null) const [participationId, setParticipationId] = useState(null) + const { classes: tooltipClasses, theme } = useTooltipStyles() const { data: challengeTraffic } = api.game.useGameGetChallengesWithTrafficCapturing( gameId, @@ -42,47 +47,84 @@ const Traffic: FC = () => { window.open(`/api/game/captures/${challengeId}/${participationId}/${item.fileName}`, '_blank') } + const onDownloadAll = () => { + if (!challengeId || !participationId) { + showNotification({ + color: 'red', + title: '遇到了问题', + message: '请先选择题目和队伍', + icon: , + }) + return + } + + window.open(`/api/game/captures/${challengeId}/${participationId}/all`, '_blank') + } + const orderedFileRecords = fileRecords?.sort((a, b) => dayjs(b.updateTime).diff(dayjs(a.updateTime))) ?? [] - // make list longer for testing by duplicating 3 times - const testFiles = orderedFileRecords.concat(orderedFileRecords).concat(orderedFileRecords) - const innerStyle: CSSProperties = { - borderRight: '1px solid gray', + borderRight: `${rem(2)} solid ${ + theme.colorScheme === 'dark' ? theme.colors.dark[4] : theme.colors.gray[4] + }`, } + const srollHeight = 'calc(100vh - 174px)' + const headerHeight = rem(32) + return ( - - - + + + + + + 题目 + + + - + + + + 队伍 + + + + + + 流量文件 + + + + + + + + diff --git a/src/GZCTF/Models/Data/Container.cs b/src/GZCTF/Models/Data/Container.cs index 1a7bd1eb9..f986def17 100644 --- a/src/GZCTF/Models/Data/Container.cs +++ b/src/GZCTF/Models/Data/Container.cs @@ -78,7 +78,7 @@ public class Container ///
public string TrafficPath(string conn) => Instance is null ? string.Empty : Path.Combine(FilePath.Capture, - $"{Instance.ChallengeId}/{Instance.ParticipationId}/{DateTimeOffset.Now:yyyyMMdd-HH.mm.ssZ}-{conn}.pcap"); + $"{Instance.ChallengeId}/{Instance.ParticipationId}/{DateTimeOffset.Now:yyyyMMdd-HH.mm.ss}-{conn}.pcap"); #region Db Relationship From d90951ea9fcf34f015ee52da4bff6600c4c30344 Mon Sep 17 00:00:00 2001 From: GZTime Date: Tue, 22 Aug 2023 00:21:30 +0800 Subject: [PATCH 61/67] wip: use component `` --- .../src/components/ChallengeDetailModal.tsx | 54 ++++++---------- .../src/components/InstanceEntry.tsx | 21 ++----- .../admin/ChallengePreviewModal.tsx | 63 +++++++------------ 3 files changed, 48 insertions(+), 90 deletions(-) diff --git a/src/GZCTF/ClientApp/src/components/ChallengeDetailModal.tsx b/src/GZCTF/ClientApp/src/components/ChallengeDetailModal.tsx index a4029f4d7..d9b20e8db 100644 --- a/src/GZCTF/ClientApp/src/components/ChallengeDetailModal.tsx +++ b/src/GZCTF/ClientApp/src/components/ChallengeDetailModal.tsx @@ -9,19 +9,20 @@ import { LoadingOverlay, Modal, ModalProps, - Popover, Stack, Text, TextInput, Title, + Tooltip, } from '@mantine/core' -import { useDisclosure, useInputState } from '@mantine/hooks' +import { useInputState } from '@mantine/hooks' import { notifications, showNotification, updateNotification } from '@mantine/notifications' import { mdiCheck, mdiClose, mdiDownload, mdiLightbulbOnOutline, mdiLoading } from '@mdi/js' import { Icon } from '@mdi/react' import MarkdownRender, { InlineMarkdownRender } from '@Components/MarkdownRender' import { showErrorNotification } from '@Utils/ApiErrorHandler' import { ChallengeTagItemProps } from '@Utils/Shared' +import { useTooltipStyles } from '@Utils/ThemeOverride' import { OnceSWRConfig } from '@Utils/useConfig' import { useTypographyStyles } from '@Utils/useTypographyStyles' import api, { AnswerResult, ChallengeType } from '@Api' @@ -89,7 +90,7 @@ export const WrongFlagHints: string[] = [ const ChallengeDetailModal: FC = (props) => { const { gameId, gameEnded, challengeId, tagData, title, score, solved, ...modalProps } = props - const [downloadOpened, { close: downloadClose, open: downloadOpen }] = useDisclosure(false) + const { classes: tooltipClasses } = useTooltipStyles() const { data: challenge, mutate } = api.game.useGameGetChallenge( gameId, @@ -313,37 +314,22 @@ const ChallengeDetailModal: FC = (props) => { {challenge?.context?.url && ( - - - window.open(challenge.context?.url ?? '#', '_blank')} - top={0} - right={0} - pos="absolute" - > - - - - - - 下载附件 - - - + + + + + )} = (props) => { }) } - const onOpenInNew = () => { - window.open(`http://${instanceEntry}`, '_blank') - } - - const onOpenInApp = () => { - if (!isPlatformProxy) { - return - } + const getAppUrl = () => { const url = new URL('wsrx://open') url.searchParams.append('url', copyEntry) - window.location.href = url.href - showNotification({ - color: 'teal', - title: '已尝试拉起客户端', - message: '请确保客户端正确安装', - icon: , - }) + return url.href } + const openUrl = isPlatformProxy ? getAppUrl() : `http://${instanceEntry}` + if (!withContainer) { return test ? ( @@ -209,7 +198,7 @@ export const InstanceEntry: FC = (props) => { withArrow classNames={tooltipClasses} > - + diff --git a/src/GZCTF/ClientApp/src/components/admin/ChallengePreviewModal.tsx b/src/GZCTF/ClientApp/src/components/admin/ChallengePreviewModal.tsx index 253f6aa80..85d07a842 100644 --- a/src/GZCTF/ClientApp/src/components/admin/ChallengePreviewModal.tsx +++ b/src/GZCTF/ClientApp/src/components/admin/ChallengePreviewModal.tsx @@ -10,13 +10,13 @@ import { LoadingOverlay, Modal, ModalProps, - Popover, Stack, Text, TextInput, Title, + Tooltip, } from '@mantine/core' -import { useDisclosure, useInputState } from '@mantine/hooks' +import { useInputState } from '@mantine/hooks' import { showNotification } from '@mantine/notifications' import { mdiCheck, mdiDownload, mdiLightbulbOnOutline } from '@mdi/js' import { Icon } from '@mdi/react' @@ -24,6 +24,7 @@ import { FlagPlaceholders } from '@Components/ChallengeDetailModal' import InstanceEntry from '@Components/InstanceEntry' import MarkdownRender, { InlineMarkdownRender } from '@Components/MarkdownRender' import { ChallengeTagItemProps } from '@Utils/Shared' +import { useTooltipStyles } from '@Utils/ThemeOverride' import { useTypographyStyles } from '@Utils/useTypographyStyles' import { ChallengeType, ChallengeUpdateModel, FileType } from '@Api' @@ -41,7 +42,7 @@ interface FakeContext { const ChallengePreviewModal: FC = (props) => { const { challenge, type, attachmentType, tagData, ...modalProps } = props - const [downloadOpened, { close: downloadClose, open: downloadOpen }] = useDisclosure(false) + const { classes: tooltipClasses } = useTooltipStyles() const [placeholder, setPlaceholder] = useState('') const [flag, setFlag] = useInputState('') @@ -123,43 +124,25 @@ const ChallengePreviewModal: FC = (props) => { {attachmentType !== FileType.None && ( - - - - showNotification({ - color: 'teal', - message: '假装附件已经下载了!', - icon: , - }) - } - > - - - - - - 下载附件 - - - + + + showNotification({ + color: 'teal', + message: '假装附件已经下载了!', + icon: , + }) + } + > + + + )} Date: Tue, 22 Aug 2023 11:05:40 +0800 Subject: [PATCH 62/67] wip: types & styles --- .../ClientApp/src/components/ScrollSelect.tsx | 16 ++---- .../ClientApp/src/components/TrafficItems.tsx | 55 +++++++++---------- 2 files changed, 30 insertions(+), 41 deletions(-) diff --git a/src/GZCTF/ClientApp/src/components/ScrollSelect.tsx b/src/GZCTF/ClientApp/src/components/ScrollSelect.tsx index 82056ffdf..b9da646d3 100644 --- a/src/GZCTF/ClientApp/src/components/ScrollSelect.tsx +++ b/src/GZCTF/ClientApp/src/components/ScrollSelect.tsx @@ -10,13 +10,15 @@ import { UnstyledButtonProps, } from '@mantine/core' -export interface SelectableItemProps extends UnstyledButtonProps { +export interface SelectableItemProps extends UnstyledButtonProps { onClick: () => void - active: boolean + active?: boolean disabled?: boolean - item: T } +export type PropsWithItem = T & { item: I } +export type SelectableItemComponent = FC> + interface ScrollSelectProps extends ScrollAreaProps { itemComponent: React.FC emptyPlaceholder?: React.ReactNode @@ -48,14 +50,6 @@ const useItemStyle = createStyles((theme) => ({ pointerEvents: 'none', }, }, - - description: { - display: 'block', - - '&[data-active]': { - color: 'inherit', - }, - }, })) export const SelectableItem = forwardRef((props, ref) => { diff --git a/src/GZCTF/ClientApp/src/components/TrafficItems.tsx b/src/GZCTF/ClientApp/src/components/TrafficItems.tsx index 429bb366e..936b95cd8 100644 --- a/src/GZCTF/ClientApp/src/components/TrafficItems.tsx +++ b/src/GZCTF/ClientApp/src/components/TrafficItems.tsx @@ -1,8 +1,8 @@ import dayjs from 'dayjs' -import { FC } from 'react' import { Avatar, Badge, Group, Stack, Text, rem, useMantineTheme } from '@mantine/core' +import { mdiMenuRight } from '@mdi/js' import { Icon } from '@mdi/react' -import { SelectableItemProps, SelectableItem } from '@Components/ScrollSelect' +import { SelectableItem, SelectableItemComponent } from '@Components/ScrollSelect' import { ChallengeTagLabelMap, HunamizeSize } from '@Utils/Shared' import { ChallengeTrafficModel, @@ -14,19 +14,19 @@ import { const itemHeight = rem(60) -export const ChallengeItem: FC> = (props) => { - const item = props.item +export const ChallengeItem: SelectableItemComponent = (itemProps) => { + const { item, ...props } = itemProps const data = ChallengeTagLabelMap.get(item.tag as ChallengeTag)! const theme = useMantineTheme() const type = item.type === ChallengeType.DynamicContainer ? 'dyn' : 'sta' return ( - + - + {item.title} @@ -35,37 +35,29 @@ export const ChallengeItem: FC> = (pr - - {item.count} 队伍 - + + + {item.count} 队伍 + + + ) } -export const TeamItem: FC> = (props) => { - const item = props.item +export const TeamItem: SelectableItemComponent = (itemProps) => { + const { item, ...props } = itemProps return ( - + - ({ - ...theme.fn.hover({ - cursor: 'pointer', - }), - })} - > + {item.name?.slice(0, 1) ?? 'T'} - + {item.name ?? 'Team'} {item.organization && ( @@ -76,16 +68,19 @@ export const TeamItem: FC> = (props) => { - - {item.count} 流量 - + + + {item.count} 流量 + + + ) } -export const FileItem: FC> = (props) => { - const item = props.item +export const FileItem: SelectableItemComponent = (itemProps) => { + const { item, ...props } = itemProps return ( From 41efbb98672c51143e45c5a2e4a3c3cee9624a0f Mon Sep 17 00:00:00 2001 From: GZTime Date: Tue, 22 Aug 2023 11:11:47 +0800 Subject: [PATCH 63/67] fix: instance entry open target --- src/GZCTF/ClientApp/src/components/InstanceEntry.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/GZCTF/ClientApp/src/components/InstanceEntry.tsx b/src/GZCTF/ClientApp/src/components/InstanceEntry.tsx index 59717746a..f1d390fee 100644 --- a/src/GZCTF/ClientApp/src/components/InstanceEntry.tsx +++ b/src/GZCTF/ClientApp/src/components/InstanceEntry.tsx @@ -198,7 +198,12 @@ export const InstanceEntry: FC = (props) => { withArrow classNames={tooltipClasses} > - + From 13cf626d8ebfa1667fb07e995ab7192c9824fff9 Mon Sep 17 00:00:00 2001 From: GZTime Date: Tue, 22 Aug 2023 11:24:03 +0800 Subject: [PATCH 64/67] fix: hover style when active --- src/GZCTF/ClientApp/src/components/ScrollSelect.tsx | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/GZCTF/ClientApp/src/components/ScrollSelect.tsx b/src/GZCTF/ClientApp/src/components/ScrollSelect.tsx index b9da646d3..48a0e22ad 100644 --- a/src/GZCTF/ClientApp/src/components/ScrollSelect.tsx +++ b/src/GZCTF/ClientApp/src/components/ScrollSelect.tsx @@ -37,12 +37,15 @@ const useItemStyle = createStyles((theme) => ({ userSelect: 'none', ...theme.fn.hover({ - backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.gray[0], + backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.white[2], }), '&[data-active]': { backgroundColor: theme.colorScheme === 'dark' ? theme.colors.dark[6] : theme.colors.white[2], - ...theme.fn.hover({ backgroundColor: theme.colors.hover }), + ...theme.fn.hover({ + backgroundColor: + theme.colorScheme === 'dark' ? theme.colors.dark[5] : theme.colors.white[3], + }), }, '&[data-disabled]': { From 72d7c2147e9b2c2208bb5bba9ba4e73728fdb508 Mon Sep 17 00:00:00 2001 From: GZTime Date: Tue, 22 Aug 2023 11:35:21 +0800 Subject: [PATCH 65/67] wip: traffic placeholder --- .../src/pages/games/[id]/monitor/Traffic.tsx | 131 ++++++++++-------- 1 file changed, 76 insertions(+), 55 deletions(-) diff --git a/src/GZCTF/ClientApp/src/pages/games/[id]/monitor/Traffic.tsx b/src/GZCTF/ClientApp/src/pages/games/[id]/monitor/Traffic.tsx index ccd627993..443c7558d 100644 --- a/src/GZCTF/ClientApp/src/pages/games/[id]/monitor/Traffic.tsx +++ b/src/GZCTF/ClientApp/src/pages/games/[id]/monitor/Traffic.tsx @@ -1,7 +1,19 @@ import dayjs from 'dayjs' import { CSSProperties, FC, useState } from 'react' import { useParams } from 'react-router-dom' -import { Group, Grid, Paper, Text, Divider, rem, ActionIcon, Tooltip } from '@mantine/core' +import { + Group, + Grid, + Paper, + Text, + Divider, + rem, + ActionIcon, + Tooltip, + Center, + Stack, + Title, +} from '@mantine/core' import { showNotification } from '@mantine/notifications' import { mdiClose, mdiDownload } from '@mdi/js' import Icon from '@mdi/react' @@ -75,60 +87,69 @@ const Traffic: FC = () => { return ( - - - - - - 题目 - - - - - - - - - 队伍 - - - - - - - - - 流量文件 - - - - - - - - - - - - + {!challengeTraffic || challengeTraffic?.length === 0 ? ( +
+ + 暂时没有启用流量捕获的题目 + 需要平台配置和题目双重启用 + +
+ ) : ( + + + + + + 题目 + + + + + + + + + 队伍 + + + + + + + + + 流量文件 + + + + + + + + + + + + + )}
) } From 3405b7979b20cd6ce1ed5416789b4e59371e22b6 Mon Sep 17 00:00:00 2001 From: Aether Chen <15167799+chenjunyu19@users.noreply.github.com> Date: Tue, 22 Aug 2023 11:36:53 +0800 Subject: [PATCH 66/67] fix: set correct width for flag input --- src/GZCTF/ClientApp/src/components/ChallengeDetailModal.tsx | 1 + .../ClientApp/src/components/admin/ChallengePreviewModal.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/src/GZCTF/ClientApp/src/components/ChallengeDetailModal.tsx b/src/GZCTF/ClientApp/src/components/ChallengeDetailModal.tsx index d9b20e8db..39ba9d2ad 100644 --- a/src/GZCTF/ClientApp/src/components/ChallengeDetailModal.tsx +++ b/src/GZCTF/ClientApp/src/components/ChallengeDetailModal.tsx @@ -394,6 +394,7 @@ const ChallengeDetailModal: FC = (props) => { 提交 flag } + rightSectionWidth="6rem" /> )} diff --git a/src/GZCTF/ClientApp/src/components/admin/ChallengePreviewModal.tsx b/src/GZCTF/ClientApp/src/components/admin/ChallengePreviewModal.tsx index 85d07a842..18d96f2fa 100644 --- a/src/GZCTF/ClientApp/src/components/admin/ChallengePreviewModal.tsx +++ b/src/GZCTF/ClientApp/src/components/admin/ChallengePreviewModal.tsx @@ -197,6 +197,7 @@ const ChallengePreviewModal: FC = (props) => { }, }} rightSection={} + rightSectionWidth="6rem" /> From 9b4b5b525ce628872098e7974c8e0910758199bf Mon Sep 17 00:00:00 2001 From: GZTime Date: Tue, 22 Aug 2023 12:19:01 +0800 Subject: [PATCH 67/67] wip: add icon for download --- .../ClientApp/src/components/TrafficItems.tsx | 22 +++++++++++-------- .../src/pages/games/[id]/monitor/Traffic.tsx | 4 ++-- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/src/GZCTF/ClientApp/src/components/TrafficItems.tsx b/src/GZCTF/ClientApp/src/components/TrafficItems.tsx index 936b95cd8..56c139e6a 100644 --- a/src/GZCTF/ClientApp/src/components/TrafficItems.tsx +++ b/src/GZCTF/ClientApp/src/components/TrafficItems.tsx @@ -1,6 +1,6 @@ import dayjs from 'dayjs' import { Avatar, Badge, Group, Stack, Text, rem, useMantineTheme } from '@mantine/core' -import { mdiMenuRight } from '@mdi/js' +import { mdiFileDownloadOutline, mdiMenuRight } from '@mdi/js' import { Icon } from '@mdi/react' import { SelectableItem, SelectableItemComponent } from '@Components/ScrollSelect' import { ChallengeTagLabelMap, HunamizeSize } from '@Utils/Shared' @@ -85,14 +85,18 @@ export const FileItem: SelectableItemComponent = (itemProps) => { return ( - - - {item.fileName} - - - {dayjs(item.updateTime).format('MM/DD HH:mm:ss')} - - + + + + + + {item.fileName} + + + {dayjs(item.updateTime).format('MM/DD HH:mm:ss')} + + + {HunamizeSize(item.size ?? 0)} diff --git a/src/GZCTF/ClientApp/src/pages/games/[id]/monitor/Traffic.tsx b/src/GZCTF/ClientApp/src/pages/games/[id]/monitor/Traffic.tsx index 443c7558d..4cb64ea74 100644 --- a/src/GZCTF/ClientApp/src/pages/games/[id]/monitor/Traffic.tsx +++ b/src/GZCTF/ClientApp/src/pages/games/[id]/monitor/Traffic.tsx @@ -15,7 +15,7 @@ import { Title, } from '@mantine/core' import { showNotification } from '@mantine/notifications' -import { mdiClose, mdiDownload } from '@mdi/js' +import { mdiClose, mdiDownloadMultiple } from '@mdi/js' import Icon from '@mdi/react' import ScrollSelect from '@Components/ScrollSelect' import { ChallengeItem, TeamItem, FileItem } from '@Components/TrafficItems' @@ -134,7 +134,7 @@ const Traffic: FC = () => { - +
隐藏公开 比赛 比赛时间 简介 onToggleHidden(game)} /> - + {game.summary}