diff --git a/README.md b/README.md index b531c1b..57199bd 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Tilework is a fully integrated reverse proxying and load balancing platform, usi ## Features -- Deployment of HTTP/TCP/UDP load balancers with multiple backends +- Deployment of HTTP/TCP load balancers with multiple backends - HTTP rules based routing, including hostname, URL path, query string - Certificate issuing via popular services, lifecycle management, auto-renewal - Realtime and historical service statistics diff --git a/tilework.core/Enums/LoadBalancing/LoadBalancerActionRules.cs b/tilework.core/Enums/LoadBalancing/LoadBalancerActionRules.cs new file mode 100644 index 0000000..f5313f1 --- /dev/null +++ b/tilework.core/Enums/LoadBalancing/LoadBalancerActionRules.cs @@ -0,0 +1,30 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Tilework.LoadBalancing.Enums; + +public static class LoadBalancerActionRules +{ + private static readonly RuleActionType[] ApplicationActions = + { + RuleActionType.Forward, + RuleActionType.Redirect, + RuleActionType.FixedResponse + }; + + private static readonly RuleActionType[] NetworkActions = + { + RuleActionType.Forward, + RuleActionType.Reject + }; + + public static IReadOnlyList GetAllowedActions(LoadBalancerType type) + { + return type == LoadBalancerType.NETWORK ? NetworkActions : ApplicationActions; + } + + public static bool IsAllowed(LoadBalancerType type, RuleActionType action) + { + return GetAllowedActions(type).Contains(action); + } +} diff --git a/tilework.core/Enums/LoadBalancing/LoadBalancerProtocolRules.cs b/tilework.core/Enums/LoadBalancing/LoadBalancerProtocolRules.cs index 1bb6775..7c1fb73 100644 --- a/tilework.core/Enums/LoadBalancing/LoadBalancerProtocolRules.cs +++ b/tilework.core/Enums/LoadBalancing/LoadBalancerProtocolRules.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.Linq; namespace Tilework.LoadBalancing.Enums; @@ -13,8 +14,8 @@ public static class LoadBalancerProtocolRules private static readonly LoadBalancerProtocol[] NetworkProtocols = { LoadBalancerProtocol.TCP, - LoadBalancerProtocol.UDP, - LoadBalancerProtocol.TCP_UDP, + // LoadBalancerProtocol.UDP, + // LoadBalancerProtocol.TCP_UDP, LoadBalancerProtocol.TLS }; diff --git a/tilework.core/Enums/LoadBalancing/RuleActionType.cs b/tilework.core/Enums/LoadBalancing/RuleActionType.cs new file mode 100644 index 0000000..ed85b23 --- /dev/null +++ b/tilework.core/Enums/LoadBalancing/RuleActionType.cs @@ -0,0 +1,15 @@ +using System.ComponentModel; + +namespace Tilework.LoadBalancing.Enums; + +public enum RuleActionType +{ + [Description("Forward to target group")] + Forward, + [Description("HTTP redirect")] + Redirect, + [Description("HTTP fixed response")] + FixedResponse, + [Description("Reject connection")] + Reject +} diff --git a/tilework.core/Enums/LoadBalancing/TargetGroupProtocolRules.cs b/tilework.core/Enums/LoadBalancing/TargetGroupProtocolRules.cs new file mode 100644 index 0000000..4ba96f8 --- /dev/null +++ b/tilework.core/Enums/LoadBalancing/TargetGroupProtocolRules.cs @@ -0,0 +1,39 @@ +using System.Collections.Generic; +using System.Linq; + +namespace Tilework.LoadBalancing.Enums; + +public static class TargetGroupProtocolRules +{ + private static readonly TargetGroupProtocol[] ApplicationProtocols = + { + TargetGroupProtocol.HTTP, + TargetGroupProtocol.HTTPS + }; + + private static readonly TargetGroupProtocol[] NetworkProtocols = + { + TargetGroupProtocol.TCP, + // TargetGroupProtocol.UDP, + // TargetGroupProtocol.TCP_UDP, + TargetGroupProtocol.TLS + }; + + public static IReadOnlyList GetAllowedProtocols(LoadBalancerType type) + { + return type == LoadBalancerType.NETWORK ? NetworkProtocols : ApplicationProtocols; + } + + public static IReadOnlyList GetAllowedProtocols() + { + return ApplicationProtocols + .Concat(NetworkProtocols) + .Distinct() + .ToList(); + } + + public static bool IsAllowed(LoadBalancerType type, TargetGroupProtocol protocol) + { + return GetAllowedProtocols(type).Contains(protocol); + } +} diff --git a/tilework.core/Mappers/LoadBalancingMappingProfile.cs b/tilework.core/Mappers/LoadBalancingMappingProfile.cs index 910eeba..b00c9c6 100644 --- a/tilework.core/Mappers/LoadBalancingMappingProfile.cs +++ b/tilework.core/Mappers/LoadBalancingMappingProfile.cs @@ -27,10 +27,12 @@ public LoadBalancingMappingProfile() CreateMap() .ForMember(dest => dest.LoadBalancer, opt => opt.MapFrom(src => src.LoadBalancerId)) - .ForMember(dest => dest.TargetGroup, opt => opt.MapFrom(src => src.TargetGroupId)); + .ForMember(dest => dest.TargetGroup, opt => opt.MapFrom(src => src.TargetGroupId)) + .ForMember(dest => dest.Action, opt => opt.MapFrom(src => src.Action ?? new RuleAction())); CreateMap() .ForMember(dest => dest.LoadBalancerId, opt => opt.MapFrom(src => src.LoadBalancer)) .ForMember(dest => dest.TargetGroupId, opt => opt.MapFrom(src => src.TargetGroup)) + .ForMember(dest => dest.Action, opt => opt.MapFrom(src => src.Action ?? new RuleAction())) .ForMember(dest => dest.LoadBalancer, opt => opt.Ignore()) .ForMember(dest => dest.TargetGroup, opt => opt.Ignore()); } diff --git a/tilework.core/Migrations/20260208180423_RuleActions.Designer.cs b/tilework.core/Migrations/20260208180423_RuleActions.Designer.cs new file mode 100644 index 0000000..6ef8026 --- /dev/null +++ b/tilework.core/Migrations/20260208180423_RuleActions.Designer.cs @@ -0,0 +1,630 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Tilework.Core.Persistence; + +#nullable disable + +namespace tilework.core.Migrations +{ + [DbContext(typeof(TileworkContext))] + [Migration("20260208180423_RuleActions")] + partial class RuleActions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Proxies:ChangeTracking", false) + .HasAnnotation("Proxies:CheckEquality", false) + .HasAnnotation("Proxies:LazyLoading", true); + + modelBuilder.Entity("LoadBalancerCertificates", b => + { + b.Property("BalancerId") + .HasColumnType("TEXT"); + + b.Property("CertificateId") + .HasColumnType("TEXT"); + + b.HasKey("BalancerId", "CertificateId"); + + b.HasIndex("CertificateId"); + + b.ToTable("LoadBalancerCertificates"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .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"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .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") + .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("Tilework.Persistence.CertificateManagement.Models.Certificate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AuthorityId") + .HasColumnType("TEXT"); + + b.Property("CertificateDataString") + .HasColumnType("TEXT"); + + b.Property("ExpiresAtUtc") + .HasColumnType("INTEGER"); + + b.Property("Fqdn") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PrivateKeyId") + .HasColumnType("TEXT"); + + b.Property("Status") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("AuthorityId"); + + b.HasIndex("Name") + .IsUnique(); + + b.HasIndex("PrivateKeyId"); + + b.ToTable("Certificates"); + }); + + modelBuilder.Entity("Tilework.Persistence.CertificateManagement.Models.CertificateAuthority", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ParametersString") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("Parameters"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("CertificateAuthorities"); + }); + + modelBuilder.Entity("Tilework.Persistence.CertificateManagement.Models.PrivateKey", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Algorithm") + .HasColumnType("INTEGER"); + + b.Property("KeyDataString") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PrivateKeys"); + }); + + modelBuilder.Entity("Tilework.Persistence.IdentityManagement.Models.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Tilework.Persistence.IdentityManagement.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("Active") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("CreatedAtUtc") + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("LastLoginAtUtc") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.LoadBalancer", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Enabled") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("Protocol") + .HasColumnType("INTEGER"); + + b.Property("Type") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("LoadBalancers"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Rule", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("LoadBalancerId") + .HasColumnType("TEXT"); + + b.Property("Priority") + .HasColumnType("INTEGER"); + + b.Property("TargetGroupId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("LoadBalancerId"); + + b.HasIndex("TargetGroupId"); + + b.HasIndex("Priority", "LoadBalancerId") + .IsUnique(); + + b.ToTable("Rules"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Target", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Host") + .IsRequired() + .HasMaxLength(253) + .HasColumnType("TEXT"); + + b.Property("Port") + .HasColumnType("INTEGER"); + + b.Property("TargetGroupId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("TargetGroupId", "Host", "Port") + .IsUnique(); + + b.ToTable("Targets"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.TargetGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Protocol") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("Name") + .IsUnique(); + + b.ToTable("TargetGroups"); + }); + + modelBuilder.Entity("Tilework.Persistence.TokenVault.Models.Token", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property("Key") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Value") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Key") + .IsUnique(); + + b.ToTable("Tokens"); + }); + + modelBuilder.Entity("LoadBalancerCertificates", b => + { + b.HasOne("Tilework.Persistence.LoadBalancing.Models.LoadBalancer", null) + .WithMany() + .HasForeignKey("BalancerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tilework.Persistence.CertificateManagement.Models.Certificate", null) + .WithMany() + .HasForeignKey("CertificateId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Tilework.Persistence.IdentityManagement.Models.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("Tilework.Persistence.IdentityManagement.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("Tilework.Persistence.IdentityManagement.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Tilework.Persistence.IdentityManagement.Models.Role", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tilework.Persistence.IdentityManagement.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("Tilework.Persistence.IdentityManagement.Models.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Tilework.Persistence.CertificateManagement.Models.Certificate", b => + { + b.HasOne("Tilework.Persistence.CertificateManagement.Models.CertificateAuthority", "Authority") + .WithMany() + .HasForeignKey("AuthorityId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Tilework.Persistence.CertificateManagement.Models.PrivateKey", "PrivateKey") + .WithMany() + .HasForeignKey("PrivateKeyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Authority"); + + b.Navigation("PrivateKey"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Rule", b => + { + b.HasOne("Tilework.Persistence.LoadBalancing.Models.LoadBalancer", "LoadBalancer") + .WithMany("Rules") + .HasForeignKey("LoadBalancerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Tilework.Persistence.LoadBalancing.Models.TargetGroup", "TargetGroup") + .WithMany() + .HasForeignKey("TargetGroupId"); + + b.OwnsMany("Tilework.LoadBalancing.Models.Condition", "Conditions", b1 => + { + b1.Property("RuleId"); + + b1.Property("__synthesizedOrdinal") + .ValueGeneratedOnAddOrUpdate(); + + b1.Property("Type"); + + b1.PrimitiveCollection("Values") + .IsRequired(); + + b1.HasKey("RuleId", "__synthesizedOrdinal"); + + b1.ToTable("Rules"); + + b1.ToJson("Conditions"); + + b1.WithOwner() + .HasForeignKey("RuleId"); + }); + + b.OwnsOne("Tilework.LoadBalancing.Models.RuleAction", "Action", b1 => + { + b1.Property("RuleId"); + + b1.Property("FixedResponseBody"); + + b1.Property("FixedResponseContentType"); + + b1.Property("FixedResponseStatusCode"); + + b1.Property("RedirectStatusCode"); + + b1.Property("RedirectUrl"); + + b1.Property("Type"); + + b1.HasKey("RuleId"); + + b1.ToTable("Rules"); + + b1.ToJson("Action"); + + b1.WithOwner() + .HasForeignKey("RuleId"); + }); + + b.Navigation("Action") + .IsRequired(); + + b.Navigation("Conditions"); + + b.Navigation("LoadBalancer"); + + b.Navigation("TargetGroup"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.Target", b => + { + b.HasOne("Tilework.Persistence.LoadBalancing.Models.TargetGroup", "TargetGroup") + .WithMany("Targets") + .HasForeignKey("TargetGroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("TargetGroup"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.LoadBalancer", b => + { + b.Navigation("Rules"); + }); + + modelBuilder.Entity("Tilework.Persistence.LoadBalancing.Models.TargetGroup", b => + { + b.Navigation("Targets"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/tilework.core/Migrations/20260208180423_RuleActions.cs b/tilework.core/Migrations/20260208180423_RuleActions.cs new file mode 100644 index 0000000..7ec0183 --- /dev/null +++ b/tilework.core/Migrations/20260208180423_RuleActions.cs @@ -0,0 +1,71 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace tilework.core.Migrations +{ + /// + public partial class RuleActions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Rules_TargetGroups_TargetGroupId", + table: "Rules"); + + migrationBuilder.AlterColumn( + name: "TargetGroupId", + table: "Rules", + type: "TEXT", + nullable: true, + oldClrType: typeof(Guid), + oldType: "TEXT"); + + migrationBuilder.AddColumn( + name: "Action", + table: "Rules", + type: "TEXT", + nullable: false, + defaultValue: "{}"); + + migrationBuilder.AddForeignKey( + name: "FK_Rules_TargetGroups_TargetGroupId", + table: "Rules", + column: "TargetGroupId", + principalTable: "TargetGroups", + principalColumn: "Id"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropForeignKey( + name: "FK_Rules_TargetGroups_TargetGroupId", + table: "Rules"); + + migrationBuilder.DropColumn( + name: "Action", + table: "Rules"); + + migrationBuilder.AlterColumn( + name: "TargetGroupId", + table: "Rules", + type: "TEXT", + nullable: false, + defaultValue: new Guid("00000000-0000-0000-0000-000000000000"), + oldClrType: typeof(Guid), + oldType: "TEXT", + oldNullable: true); + + migrationBuilder.AddForeignKey( + name: "FK_Rules_TargetGroups_TargetGroupId", + table: "Rules", + column: "TargetGroupId", + principalTable: "TargetGroups", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + } + } +} diff --git a/tilework.core/Migrations/TileworkContextModelSnapshot.cs b/tilework.core/Migrations/TileworkContextModelSnapshot.cs index 0270ec7..7487cf3 100644 --- a/tilework.core/Migrations/TileworkContextModelSnapshot.cs +++ b/tilework.core/Migrations/TileworkContextModelSnapshot.cs @@ -363,7 +363,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property("Priority") .HasColumnType("INTEGER"); - b.Property("TargetGroupId") + b.Property("TargetGroupId") .HasColumnType("TEXT"); b.HasKey("Id"); @@ -541,9 +541,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasOne("Tilework.Persistence.LoadBalancing.Models.TargetGroup", "TargetGroup") .WithMany() - .HasForeignKey("TargetGroupId") - .OnDelete(DeleteBehavior.Cascade) - .IsRequired(); + .HasForeignKey("TargetGroupId"); b.OwnsMany("Tilework.LoadBalancing.Models.Condition", "Conditions", b1 => { @@ -567,6 +565,35 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasForeignKey("RuleId"); }); + b.OwnsOne("Tilework.LoadBalancing.Models.RuleAction", "Action", b1 => + { + b1.Property("RuleId"); + + b1.Property("FixedResponseBody"); + + b1.Property("FixedResponseContentType"); + + b1.Property("FixedResponseStatusCode"); + + b1.Property("RedirectStatusCode"); + + b1.Property("RedirectUrl"); + + b1.Property("Type"); + + b1.HasKey("RuleId"); + + b1.ToTable("Rules"); + + b1.ToJson("Action"); + + b1.WithOwner() + .HasForeignKey("RuleId"); + }); + + b.Navigation("Action") + .IsRequired(); + b.Navigation("Conditions"); b.Navigation("LoadBalancer"); diff --git a/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingMonitorData.cs b/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingMonitorData.cs index 006a0aa..1f07200 100644 --- a/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingMonitorData.cs +++ b/tilework.core/Models/LoadBalancing/Monitoring/LoadBalancingMonitorData.cs @@ -14,4 +14,7 @@ public class LoadBalancingMonitorData : BaseMonitorData public int HttpResponses4xx { get; set; } public int HttpResponses5xx { get; set; } public int HttpResponsesOther { get; set; } + + public int BytesIn { get; set; } + public int BytesOut { get; set; } } diff --git a/tilework.core/Models/LoadBalancing/RuleAction.cs b/tilework.core/Models/LoadBalancing/RuleAction.cs new file mode 100644 index 0000000..8b43286 --- /dev/null +++ b/tilework.core/Models/LoadBalancing/RuleAction.cs @@ -0,0 +1,15 @@ +using Tilework.LoadBalancing.Enums; + +namespace Tilework.LoadBalancing.Models; + +public class RuleAction +{ + public RuleActionType Type { get; set; } = RuleActionType.Forward; + + public string? RedirectUrl { get; set; } + public int? RedirectStatusCode { get; set; } + + public int? FixedResponseStatusCode { get; set; } + public string? FixedResponseBody { get; set; } + public string? FixedResponseContentType { get; set; } +} diff --git a/tilework.core/Models/LoadBalancing/RuleDTO.cs b/tilework.core/Models/LoadBalancing/RuleDTO.cs index 418ae53..26f3b75 100644 --- a/tilework.core/Models/LoadBalancing/RuleDTO.cs +++ b/tilework.core/Models/LoadBalancing/RuleDTO.cs @@ -8,7 +8,8 @@ public class RuleDTO public Guid Id { get; set; } public int Priority { get; set; } public Guid LoadBalancer { get; set; } - public Guid TargetGroup { get; set; } + public Guid? TargetGroup { get; set; } + public RuleAction Action { get; set; } = new(); public List Conditions { get; set; } = new(); -} \ No newline at end of file +} diff --git a/tilework.core/Persistence/DbContext.cs b/tilework.core/Persistence/DbContext.cs index f19f204..d8da24c 100644 --- a/tilework.core/Persistence/DbContext.cs +++ b/tilework.core/Persistence/DbContext.cs @@ -47,6 +47,12 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) b.ToJson(); }); + modelBuilder.Entity() + .OwnsOne(r => r.Action, b => + { + b.ToJson(); + }); + modelBuilder.Entity() .Property(e => e.Host) .HasConversion( diff --git a/tilework.core/Persistence/Entities/LoadBalancing/LoadBalancer.cs b/tilework.core/Persistence/Entities/LoadBalancing/LoadBalancer.cs index fe93f43..17d75b1 100644 --- a/tilework.core/Persistence/Entities/LoadBalancing/LoadBalancer.cs +++ b/tilework.core/Persistence/Entities/LoadBalancing/LoadBalancer.cs @@ -24,4 +24,4 @@ public class LoadBalancer public virtual List Certificates { get; set; } = new(); public virtual List Rules { get; set; } = new(); -} \ No newline at end of file +} diff --git a/tilework.core/Persistence/Entities/LoadBalancing/Rule.cs b/tilework.core/Persistence/Entities/LoadBalancing/Rule.cs index 8e69bbf..7eba0d0 100644 --- a/tilework.core/Persistence/Entities/LoadBalancing/Rule.cs +++ b/tilework.core/Persistence/Entities/LoadBalancing/Rule.cs @@ -10,11 +10,12 @@ public class Rule public Guid Id { get; set; } public int Priority { get; set; } - public Guid TargetGroupId { get; set; } - public virtual TargetGroup TargetGroup { get; set; } + public Guid? TargetGroupId { get; set; } + public virtual TargetGroup? TargetGroup { get; set; } public Guid LoadBalancerId { get; set; } public virtual LoadBalancer LoadBalancer { get; set; } public List Conditions { get; set; } = new(); -} \ No newline at end of file + public RuleAction Action { get; set; } = new(); +} diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs index f3f9a84..c803fa0 100644 --- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/HAProxyConfigurator.cs @@ -8,6 +8,7 @@ using Tilework.LoadBalancing.Interfaces; using Tilework.LoadBalancing.Models; +using Tilework.LoadBalancing.Enums; using Tilework.Core.Interfaces; using Tilework.Core.Enums; @@ -67,7 +68,12 @@ private void UpdateConfigFile(string path, LoadBalancer balancer) haproxyConfig.Frontends.Add(fe); - var targetGroups = balancer.Rules != null ? balancer.Rules.Select(r => r.TargetGroup).ToList() : new List(); + var targetGroups = balancer.Rules? + .Where(r => r.Action!.Type == RuleActionType.Forward) + .Select(r => r.TargetGroup) + .Where(tg => tg != null) + .Select(tg => tg!) + .ToList() ?? new List(); haproxyConfig.Backends = targetGroups diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyConfigurationProfile.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyConfigurationProfile.cs index 9351219..f510c7c 100644 --- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyConfigurationProfile.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Mappers/HAProxyConfigurationProfile.cs @@ -56,12 +56,52 @@ public HAProxyConfigurationProfile() dest.Acls.AddRange(acls); - var usebe = new UseBackend() + if (rule.Action == null) + throw new InvalidOperationException($"Rule {rule.Id} is missing an action."); + + var actionType = rule.Action.Type; + switch (actionType) { - Acls = acls.Select(a => a.Name).ToList(), - Target = rule.TargetGroup.Id.ToString(), - }; - dest.UseBackends.Add(usebe); + case RuleActionType.Forward: + var targetGroup = rule.TargetGroup; + if (targetGroup != null) + { + var usebe = new UseBackend() + { + Acls = acls.Select(a => a.Name).ToList(), + Target = targetGroup.Id.ToString(), + }; + dest.UseBackends.Add(usebe); + } + break; + case RuleActionType.Redirect: + dest.HttpRequests.Add(new HttpRequest() + { + ActionType = RuleActionType.Redirect, + RedirectUrl = rule.Action?.RedirectUrl, + RedirectStatusCode = rule.Action?.RedirectStatusCode, + Acls = acls.Select(a => a.Name).ToList() + }); + break; + case RuleActionType.FixedResponse: + dest.HttpRequests.Add(new HttpRequest() + { + ActionType = RuleActionType.FixedResponse, + FixedResponseStatusCode = rule.Action?.FixedResponseStatusCode, + FixedResponseContentType = rule.Action?.FixedResponseContentType, + FixedResponseBody = rule.Action?.FixedResponseBody, + Acls = acls.Select(a => a.Name).ToList() + }); + break; + case RuleActionType.Reject: + dest.TcpRequests.Add(new TcpRequest() + { + Acls = acls.Select(a => a.Name).ToList() + }); + break; + default: + throw new NotSupportedException($"Unsupported rule action: {actionType}"); + } } } }); @@ -93,6 +133,7 @@ public HAProxyConfigurationProfile() Address = target.Host.Value, Port = target.Port, Check = true, + Tls = src.Protocol == TargetGroupProtocol.HTTPS || src.Protocol == TargetGroupProtocol.TLS }).ToList(); }); } diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Sections/FrontendSection.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Sections/FrontendSection.cs index e653e36..1eae359 100644 --- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Sections/FrontendSection.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Sections/FrontendSection.cs @@ -11,6 +11,12 @@ public class FrontendSection : ConfigSection [Statement("acl")] public List Acls { get; set; } = new List(); + [Statement("http-request")] + public List HttpRequests { get; set; } = new List(); + + [Statement("tcp-request")] + public List TcpRequests { get; set; } = new List(); + [Statement("use_backend")] public List UseBackends { get; set; } = new List(); @@ -23,4 +29,4 @@ public class FrontendSection : ConfigSection public FrontendSection() : base("frontend") { } -} \ No newline at end of file +} diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/HttpRequest.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/HttpRequest.cs new file mode 100644 index 0000000..3818839 --- /dev/null +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/HttpRequest.cs @@ -0,0 +1,143 @@ +using System.Linq; +using Tilework.LoadBalancing.Enums; + +namespace Tilework.LoadBalancing.Haproxy; + +public class HttpRequest +{ + public RuleActionType ActionType { get; set; } + public string? RedirectUrl { get; set; } + public int? RedirectStatusCode { get; set; } + public int? FixedResponseStatusCode { get; set; } + public string? FixedResponseContentType { get; set; } + public string? FixedResponseBody { get; set; } + public List Acls { get; set; } = new(); + + public HttpRequest() { } + + public HttpRequest(string[] parameters) + { + } + + public override string ToString() + { + return ActionType switch + { + RuleActionType.Redirect => BuildRedirect(), + RuleActionType.FixedResponse => BuildReturn(), + _ => throw new NotSupportedException($"Unsupported HTTP action type: {ActionType}") + }; + } + + private string BuildRedirect() + { + var parts = new List { "redirect", "location", RedirectUrl ?? string.Empty }; + + if (RedirectStatusCode.HasValue) + { + parts.Add("code"); + parts.Add(RedirectStatusCode.Value.ToString()); + } + + if (Acls != null && Acls.Count > 0) + { + parts.Add("if"); + parts.AddRange(Acls); + } + + return string.Join(" ", parts); + } + + private string BuildReturn() + { + var parts = new List + { + "return", + "status", + FixedResponseStatusCode!.ToString() + }; + + if (!string.IsNullOrWhiteSpace(FixedResponseContentType)) + { + parts.Add("content-type"); + parts.Add(FixedResponseContentType); + } + + if (!string.IsNullOrWhiteSpace(FixedResponseBody)) + { + parts.Add("lf-string"); + parts.Add(Quote(FixedResponseBody)); + } + + if (Acls != null && Acls.Count > 0) + { + parts.Add("if"); + parts.AddRange(Acls); + } + + return string.Join(" ", parts); + } + + private void ParseRedirect(string[] parameters) + { + RedirectUrl = GetValueAfter(parameters, "location"); + var code = GetValueAfter(parameters, "code"); + if (int.TryParse(code, out var parsed)) + { + RedirectStatusCode = parsed; + } + Acls = GetAcls(parameters); + } + + private void ParseReturn(string[] parameters) + { + var status = GetValueAfter(parameters, "status"); + if (int.TryParse(status, out var parsed)) + { + FixedResponseStatusCode = parsed; + } + FixedResponseContentType = GetValueAfter(parameters, "content-type"); + var body = GetValueAfter(parameters, "lf-string"); + if (!string.IsNullOrWhiteSpace(body)) + { + FixedResponseBody = body.Trim('"'); + } + Acls = GetAcls(parameters); + } + + private static string? GetValueAfter(string[] parameters, string token) + { + for (int i = 0; i < parameters.Length - 1; i++) + { + if (string.Equals(parameters[i], token, StringComparison.OrdinalIgnoreCase)) + { + return parameters[i + 1]; + } + } + + return null; + } + + private static List GetAcls(string[] parameters) + { + for (int i = 0; i < parameters.Length; i++) + { + if (string.Equals(parameters[i], "if", StringComparison.OrdinalIgnoreCase)) + { + return parameters.Skip(i + 1).ToList(); + } + } + + return new List(); + } + + private static string Quote(string value) + { + var escaped = value + .Replace("\\", "\\\\") + .Replace("\"", "\\\"") + .Replace("\r", string.Empty) + .Replace("\n", "\\n"); + return $"\"{escaped}\""; + } +} diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/Server.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/Server.cs index 7716ccc..ef29433 100644 --- a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/Server.cs +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/Server.cs @@ -6,6 +6,7 @@ public class Server public string Address { get; set; } public int Port { get; set; } public bool Check { get; set; } + public bool Tls { get; set; } public Server() {} @@ -22,6 +23,7 @@ public Server(string [] parameters) public override string ToString() { var checkStr = Check == true ? "check" : ""; - return $"{Name} {Address}:{Port} {checkStr}"; + var tlsStr = Tls == true ? "ssl verify none" : ""; + return $"{Name} {Address}:{Port} {tlsStr} {checkStr}"; } } \ No newline at end of file diff --git a/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/TcpRequest.cs b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/TcpRequest.cs new file mode 100644 index 0000000..ab1793e --- /dev/null +++ b/tilework.core/Providers/LoadBalancingProviders/HAProxy/Models/Config/Statements/TcpRequest.cs @@ -0,0 +1,24 @@ +using System.Linq; + +namespace Tilework.LoadBalancing.Haproxy; + +public class TcpRequest +{ + public List Acls { get; set; } = new(); + + public TcpRequest() { } + + public TcpRequest(string[] parameters) + { + } + + public override string ToString() + { + if (Acls == null || Acls.Count == 0) + { + return "connection reject"; + } + + return $"connection reject if {string.Join(" ", Acls)}"; + } +} diff --git a/tilework.core/Resources/haproxy.cfg b/tilework.core/Resources/haproxy.cfg index 70c1e95..428ff36 100644 --- a/tilework.core/Resources/haproxy.cfg +++ b/tilework.core/Resources/haproxy.cfg @@ -13,6 +13,7 @@ global ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384 ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256 ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets + ssl-default-server-options ssl-min-ver TLSv1.2 defaults log global diff --git a/tilework.core/Resources/telegraf.conf b/tilework.core/Resources/telegraf.conf index c5046fd..7d5d260 100644 --- a/tilework.core/Resources/telegraf.conf +++ b/tilework.core/Resources/telegraf.conf @@ -22,7 +22,7 @@ def apply(metric): fieldinclude = [ "stot", "req_tot", "http_response.1xx", "http_response.2xx", "http_response.3xx", "http_response.4xx", "http_response.5xx", - "http_response.other", "status" + "http_response.other", "status", "bin", "bout" ] source = ''' @@ -111,3 +111,11 @@ def apply(metric): [[processors.rename.replace]] field = "http_response.other" dest = "httpresponsesother" + + [[processors.rename.replace]] + field = "bin" + dest = "bytesin" + + [[processors.rename.replace]] + field = "bout" + dest = "bytesout" diff --git a/tilework.core/Services/CertificateManagement/AcmeVerificationService.cs b/tilework.core/Services/CertificateManagement/AcmeVerificationService.cs index 150c42e..bd02eba 100644 --- a/tilework.core/Services/CertificateManagement/AcmeVerificationService.cs +++ b/tilework.core/Services/CertificateManagement/AcmeVerificationService.cs @@ -120,8 +120,9 @@ private async Task AddLoadBalancerTarget(string id, LoadBalancerDTO balancer, st var rule = new RuleDTO() { - TargetGroup = tg.Id, Priority = 0, + TargetGroup = tg.Id, + Action = new RuleAction { Type = RuleActionType.Forward }, Conditions = new List() { new Condition() { @@ -157,7 +158,9 @@ private async Task CheckRemoveLoadBalancer(string certId) var rules = await _loadBalancerService.GetRules(balancer); foreach (var rule in rules) { - var tg = targetGroups.FirstOrDefault(tg => tg.Id == rule.TargetGroup); + var tg = rule.TargetGroup == null + ? null + : targetGroups.FirstOrDefault(tg => tg.Id == rule.TargetGroup.Value); if (tg != null && tg.Name == targetGroupName) { await _loadBalancerService.RemoveRule(balancer, rule); diff --git a/tilework.core/Services/LoadBalancing/LoadBalancerService.cs b/tilework.core/Services/LoadBalancing/LoadBalancerService.cs index 1a05d1e..1309097 100644 --- a/tilework.core/Services/LoadBalancing/LoadBalancerService.cs +++ b/tilework.core/Services/LoadBalancing/LoadBalancerService.cs @@ -88,6 +88,66 @@ private static void EnsureRuleConditionsAllowed(LoadBalancer balancer, RuleDTO r nameof(rule)); } + private static void EnsureRuleActionAllowed(LoadBalancer balancer, RuleDTO rule) + { + if (rule.Action == null) + { + rule.Action = new RuleAction(); + } + + if (!LoadBalancerActionRules.IsAllowed(balancer.Type, rule.Action.Type)) + { + throw new ArgumentException( + $"Rule action {rule.Action.Type} is not valid for load balancer type {balancer.Type}", + nameof(rule)); + } + + switch (rule.Action.Type) + { + case RuleActionType.Forward: + if (rule.TargetGroup == null || rule.TargetGroup == Guid.Empty) + { + throw new ArgumentException("Forward actions require a target group.", nameof(rule)); + } + break; + case RuleActionType.Redirect: + rule.TargetGroup = null; + if (string.IsNullOrWhiteSpace(rule.Action.RedirectUrl)) + { + throw new ArgumentException("Redirect actions require a destination URL.", nameof(rule)); + } + if (rule.Action.RedirectStatusCode == null) + { + rule.Action.RedirectStatusCode = 302; + } + else if (rule.Action.RedirectStatusCode < 300 || rule.Action.RedirectStatusCode > 399) + { + throw new ArgumentException("Redirect status code must be in the 300-399 range.", nameof(rule)); + } + break; + case RuleActionType.FixedResponse: + rule.TargetGroup = null; + if (rule.Action.FixedResponseStatusCode == null) + { + rule.Action.FixedResponseStatusCode = 200; + } + else if (rule.Action.FixedResponseStatusCode < 100 || rule.Action.FixedResponseStatusCode > 599) + { + throw new ArgumentException("Fixed response status code must be in the 100-599 range.", nameof(rule)); + } + if (string.IsNullOrWhiteSpace(rule.Action.FixedResponseContentType)) + { + rule.Action.FixedResponseContentType = "text/plain"; + } + break; + case RuleActionType.Reject: + rule.TargetGroup = null; + break; + default: + throw new ArgumentOutOfRangeException(nameof(rule.Action.Type), rule.Action.Type, "Unknown rule action type."); + } + } + private static bool RequiresCertificate(LoadBalancer balancer) { return balancer.Protocol == LoadBalancerProtocol.HTTPS || balancer.Protocol == LoadBalancerProtocol.TLS; @@ -208,6 +268,7 @@ public async Task AddRule(LoadBalancerDTO balancer, RuleDTO rule) ValidateRulePriority(entity.Rules, rule.Priority); EnsureRuleConditionsAllowed(entity, rule); + EnsureRuleActionAllowed(entity, rule); foreach (var existingRule in entity.Rules.Where(r => r.Priority >= rule.Priority)) { @@ -231,6 +292,7 @@ public async Task UpdateRule(LoadBalancerDTO balancer, RuleDTO rule) ValidateRulePriority(entity.Rules, rule.Priority); EnsureRuleConditionsAllowed(entity, rule); + EnsureRuleActionAllowed(entity, rule); await using var tx = await _dbContext.Database.BeginTransactionAsync(); diff --git a/tilework.ui/Components/App.razor b/tilework.ui/Components/App.razor index fefa5b6..369d6cf 100644 --- a/tilework.ui/Components/App.razor +++ b/tilework.ui/Components/App.razor @@ -16,6 +16,7 @@ + diff --git a/tilework.ui/Components/Dialogs/RuleDialog.razor b/tilework.ui/Components/Dialogs/RuleDialog.razor index 1889c39..a324eed 100644 --- a/tilework.ui/Components/Dialogs/RuleDialog.razor +++ b/tilework.ui/Components/Dialogs/RuleDialog.razor @@ -15,12 +15,6 @@ - @if (!AllowedConditionTypes.Any()) - { - - This protocol does not support rule conditions. - - } @foreach (var condition in Rule.Conditions) { - - Add condition - + + + Add condition + + @foreach (var type in AvailableConditionTypes) @@ -42,15 +38,35 @@ - - - @foreach (var group in TargetGroups) + + @foreach (var type in AllowedActionTypes) { - @group.Name + @type.GetDescription() } + + @if (Rule.Action?.Type == RuleActionType.Forward) + { + + @foreach (var group in TargetGroups) + { + @group.Name + } + + } + else if (Rule.Action?.Type == RuleActionType.Redirect) + { + + + } + else if (Rule.Action?.Type == RuleActionType.FixedResponse) + { + + + + } @@ -65,6 +81,7 @@ [Parameter] public RuleDTO Rule { get; set; } = new(); [Parameter] public List TargetGroups { get; set; } = new(); [Parameter] public LoadBalancerProtocol Protocol { get; set; } + [Parameter] public LoadBalancerType Type { get; set; } [Parameter] public string Title { get; set; } = "Add rule"; [Parameter] public string ButtonText { get; set; } = "Add"; [Parameter] public string Icon { get; set; } = Icons.Material.Filled.Add; @@ -75,11 +92,7 @@ protected override async Task OnInitializedAsync() { - if (Rule.TargetGroup != Guid.Empty) - { - SelectedTargetGroup = Rule.TargetGroup; - } - + SelectedTargetGroup = Rule.TargetGroup; await base.OnInitializedAsync(); } @@ -89,7 +102,14 @@ if (form.IsValid) { - Rule.TargetGroup = (Guid) SelectedTargetGroup!; + if (Rule.Action?.Type == RuleActionType.Forward) + { + Rule.TargetGroup = (Guid) SelectedTargetGroup!; + } + else + { + Rule.TargetGroup = null; + } MudDialog.Close(DialogResult.Ok(true)); } } @@ -97,6 +117,7 @@ void Cancel() => MudDialog.Cancel(); IEnumerable AllowedConditionTypes => LoadBalancerConditionRules.GetAllowedConditions(Protocol); + IEnumerable AllowedActionTypes => LoadBalancerActionRules.GetAllowedActions(Type); IEnumerable AvailableConditionTypes => AllowedConditionTypes.Except(Rule.Conditions.Select(c => c.Type)); diff --git a/tilework.ui/Components/Layout/MainLayout.razor b/tilework.ui/Components/Layout/MainLayout.razor index 7764c5a..ab39147 100644 --- a/tilework.ui/Components/Layout/MainLayout.razor +++ b/tilework.ui/Components/Layout/MainLayout.razor @@ -9,15 +9,20 @@ Tilework + - @context.User.Identity?.Name + + +
+ + Logout + +
+
+
- -
- - diff --git a/tilework.ui/Components/Layout/NavMenu.razor b/tilework.ui/Components/Layout/NavMenu.razor index 7c4f527..b1a6e47 100644 --- a/tilework.ui/Components/Layout/NavMenu.razor +++ b/tilework.ui/Components/Layout/NavMenu.razor @@ -2,15 +2,15 @@ - Load balancers - Target groups + Load balancers + Target groups - Certificate authorities - Certificates + Certificate authorities + Certificates @* Identity providers *@ - Users + Users diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor index e129394..37a8708 100644 --- a/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor +++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityDetail.razor @@ -56,7 +56,7 @@ private List _breadcrumbs = new List { new BreadcrumbItem("Home", href: "/", icon: Icons.Material.Filled.Home), - new BreadcrumbItem("Certificate authorities", href: "/cm/authorities", icon: Icons.Material.Filled.AltRoute) + new BreadcrumbItem("Certificate authorities", href: "/cm/authorities", icon: Icons.Material.Filled.AccountBalance) }; private List _actions = new List(); diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityList.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityList.razor index 52f4465..5bed706 100644 --- a/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityList.razor +++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityList.razor @@ -29,7 +29,7 @@ private List _breadcrumbs = new List { new BreadcrumbItem("Home", href: "/", icon: Icons.Material.Filled.Home), - new BreadcrumbItem("Certificate authorities", href: "/cm/authorities/", disabled: true, icon: Icons.Material.Filled.AltRoute), + new BreadcrumbItem("Certificate authorities", href: "/cm/authorities/", disabled: true, icon: Icons.Material.Filled.AccountBalance), }; private List _actions = new List diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityNew.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityNew.razor index 2c74bc4..8a34f9e 100644 --- a/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityNew.razor +++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateAuthorityNew.razor @@ -35,8 +35,8 @@ else if(form is NewPredefinedAcmeCertificateAuthorityForm pdAcmeForm) private List _breadcrumbs = new List { new BreadcrumbItem("Home", href: "/", icon: Icons.Material.Filled.Home), - new BreadcrumbItem("Certificate authorities", href: "/cm/authorities", icon: Icons.Material.Filled.AltRoute), - new BreadcrumbItem("Create new", href: null, disabled: true, icon: Icons.Material.Filled.AltRoute) + new BreadcrumbItem("Certificate authorities", href: "/cm/authorities", icon: Icons.Material.Filled.AccountBalance), + new BreadcrumbItem("Create new", href: null, disabled: true, icon: Icons.Material.Outlined.AddCircleOutline) }; private static Type GetFormType(CertificateAuthorityType type) diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor index a5d49c4..1297710 100644 --- a/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor +++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateDetail.razor @@ -1,7 +1,12 @@ +@using System.Security.Cryptography +@using System.Security.Cryptography.X509Certificates +@using System.Text @using Tilework.CertificateManagement.Models @using Tilework.CertificateManagement.Interfaces @using Tilework.CertificateManagement.Enums @using Tilework.Core.Enums +@using Tilework.Ui.Services +@using Tilework.Ui.Utilities @namespace Tilework.Ui.Components.Pages @@ -9,6 +14,8 @@ @inject NavigationManager _navigationManager @inject IDialogService _dialogService @inject ISnackbar _snackbar +@inject IBrowserTimeZoneProvider _browserTimezoneProvider +@inject DownloadService _downloadService @page "/cm/certificates/{Id:guid}" @@ -24,7 +31,7 @@ Not before - @_item.CertificateData[0].NotBefore + @DateTimeFormatting.FormatTimestamp(_browserTimezoneProvider.Localize(new DateTimeOffset(_item.CertificateData[0].NotBefore.ToUniversalTime()))) Key algorithm @@ -36,7 +43,7 @@ Expires at - @_item.ExpiresAtUtc + @DateTimeFormatting.FormatTimestamp(_browserTimezoneProvider.Localize(_item.ExpiresAtUtc)) Serial number @@ -82,7 +89,7 @@ private List _breadcrumbs = new List { new BreadcrumbItem("Home", href: "/", icon: Icons.Material.Filled.Home), - new BreadcrumbItem("Certificates", href: "/cm/certificates", icon: Icons.Material.Filled.AltRoute) + new BreadcrumbItem("Certificates", href: "/cm/certificates", icon: Icons.Material.Filled.VerifiedUser) }; private List _actions = new List(); @@ -123,6 +130,9 @@ _navigationManager.NavigateTo("/cm/certificates"); return; } + + await _browserTimezoneProvider.Initialize(); + StateHasChanged(); } private async Task ConfirmRevoke() @@ -200,10 +210,47 @@ } } + private async Task DownloadBundle() + { + if (_item == null || _item.CertificateData == null || _item.CertificateData.Count == 0) + { + _snackbar.Add("No certificate chain available to download.", Severity.Warning); + return; + } + + var bundle = new StringBuilder(); + foreach (var cert in _item.CertificateData) + { + var der = cert.Export(X509ContentType.Cert); + var pem = PemEncoding.Write("CERTIFICATE", der); + bundle.AppendLine(new string(pem)); + } + + var fileName = $"{SanitizeFileName(_item.Name)}.pem"; + var data = Encoding.UTF8.GetBytes(bundle.ToString()); + await _downloadService.DownloadAsync(fileName, "application/x-pem-file", data); + } + + private static string SanitizeFileName(string name) + { + if (string.IsNullOrWhiteSpace(name)) + return "certificate"; + + var invalidChars = Path.GetInvalidFileNameChars(); + var sanitized = new StringBuilder(name.Length); + foreach (var ch in name) + { + sanitized.Append(invalidChars.Contains(ch) ? '_' : ch); + } + + return sanitized.ToString(); + } + private void SetActions() { _actions = new List(); + _actions.Add(new ActionItem() { Name = "Download", OnClick = DownloadBundle }); _actions.Add(new ActionItem() { Name = "Renew", OnClick = ConfirmRenew }); if(_item.Status == CertificateStatus.ACTIVE) diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateList.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateList.razor index 5dc6c49..d79d275 100644 --- a/tilework.ui/Components/Pages/CertificateManagement/CertificateList.razor +++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateList.razor @@ -2,12 +2,15 @@ @using Tilework.CertificateManagement.Interfaces @using Tilework.CertificateManagement.Enums @using Tilework.Core.Enums +@using Tilework.Ui.Services +@using Tilework.Ui.Utilities @namespace Tilework.Ui.Components.Pages @inject ICertificateManagementService _certificateManagementService @inject NavigationManager _navigationManager +@inject IBrowserTimeZoneProvider _browserTimezoneProvider @page "/cm/certificates" @@ -52,7 +55,7 @@ @_certificateManagementService.GetPrivateKey(context.PrivateKey).Result?.Algorithm.GetDescription() - @context.ExpiresAtUtc + @DateTimeFormatting.FormatTimestamp(_browserTimezoneProvider.Localize(context.ExpiresAtUtc)) @(context.CertificateData.Count > 0 ? context.CertificateData.Count - 1 : 0) @_certificateManagementService.GeCertificateAuthority(context.Authority).Result?.Name @@ -64,7 +67,7 @@ private List _breadcrumbs = new List { new BreadcrumbItem("Home", href: "/", icon: Icons.Material.Filled.Home), - new BreadcrumbItem("Certificates", href: "/cm/certificates/", disabled: true, icon: Icons.Material.Filled.AltRoute), + new BreadcrumbItem("Certificates", href: "/cm/certificates/", disabled: true, icon: Icons.Material.Filled.VerifiedUser), }; private List _actions = new List @@ -76,4 +79,13 @@ { _items = await _certificateManagementService.GetCertificates(); } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + await _browserTimezoneProvider.Initialize(); + StateHasChanged(); + } + } } diff --git a/tilework.ui/Components/Pages/CertificateManagement/CertificateNew.razor b/tilework.ui/Components/Pages/CertificateManagement/CertificateNew.razor index db06130..e252185 100644 --- a/tilework.ui/Components/Pages/CertificateManagement/CertificateNew.razor +++ b/tilework.ui/Components/Pages/CertificateManagement/CertificateNew.razor @@ -25,8 +25,8 @@ private List _breadcrumbs = new List { new BreadcrumbItem("Home", href: "/", icon: Icons.Material.Filled.Home), - new BreadcrumbItem("Certificates", href: "/cm/certificates", icon: Icons.Material.Filled.AltRoute), - new BreadcrumbItem("Create new", href: null, disabled: true, icon: Icons.Material.Filled.AltRoute) + new BreadcrumbItem("Certificates", href: "/cm/certificates", icon: Icons.Material.Filled.VerifiedUser), + new BreadcrumbItem("Create new", href: null, disabled: true, icon: Icons.Material.Outlined.AddCircleOutline) }; protected override async Task OnInitializedAsync() diff --git a/tilework.ui/Components/Pages/IdentityManagement/UserDetail.razor b/tilework.ui/Components/Pages/IdentityManagement/UserDetail.razor index 73c37e5..711efde 100644 --- a/tilework.ui/Components/Pages/IdentityManagement/UserDetail.razor +++ b/tilework.ui/Components/Pages/IdentityManagement/UserDetail.razor @@ -66,7 +66,7 @@ private List _breadcrumbs = new List { new BreadcrumbItem("Home", href: "/", icon: Icons.Material.Filled.Home), - new BreadcrumbItem("Users", href: "/im/users", icon: Icons.Material.Filled.AltRoute) + new BreadcrumbItem("Users", href: "/im/users", icon: Icons.Material.Filled.Person) }; private List _actions = new List(); diff --git a/tilework.ui/Components/Pages/IdentityManagement/UserEdit.razor b/tilework.ui/Components/Pages/IdentityManagement/UserEdit.razor index 3ff473c..7d58d7b 100644 --- a/tilework.ui/Components/Pages/IdentityManagement/UserEdit.razor +++ b/tilework.ui/Components/Pages/IdentityManagement/UserEdit.razor @@ -32,7 +32,7 @@ private List _breadcrumbs = new List { new BreadcrumbItem("Home", href: "/", icon: Icons.Material.Filled.Home), - new BreadcrumbItem("Users", href: "/im/users", icon: Icons.Material.Filled.AltRoute) + new BreadcrumbItem("Users", href: "/im/users", icon: Icons.Material.Filled.Person) }; protected override async Task OnInitializedAsync() diff --git a/tilework.ui/Components/Pages/IdentityManagement/UserList.razor b/tilework.ui/Components/Pages/IdentityManagement/UserList.razor index 5c70b06..508f81d 100644 --- a/tilework.ui/Components/Pages/IdentityManagement/UserList.razor +++ b/tilework.ui/Components/Pages/IdentityManagement/UserList.razor @@ -1,6 +1,7 @@ @using Tilework.IdentityManagement.Models @using Tilework.IdentityManagement.Services @using Tilework.Ui.Services +@using Tilework.Ui.Utilities @namespace Tilework.Ui.Components.Pages @inject UserService _userService @@ -36,8 +37,8 @@ } - @_browserTimezoneProvider.Localize(context.LastLoginAtUtc) - @_browserTimezoneProvider.Localize(context.CreatedAtUtc) + @DateTimeFormatting.FormatTimestamp(_browserTimezoneProvider.Localize(context.LastLoginAtUtc)) + @DateTimeFormatting.FormatTimestamp(_browserTimezoneProvider.Localize(context.CreatedAtUtc)) @@ -47,7 +48,7 @@ private List _breadcrumbs = new List { new BreadcrumbItem("Home", href: "/", icon: Icons.Material.Filled.Home), - new BreadcrumbItem("Users", href: "/im/users/", disabled: true, icon: Icons.Material.Filled.AltRoute), + new BreadcrumbItem("Users", href: "/im/users/", disabled: true, icon: Icons.Material.Filled.Person), }; private List _actions = new List diff --git a/tilework.ui/Components/Pages/IdentityManagement/UserNew.razor b/tilework.ui/Components/Pages/IdentityManagement/UserNew.razor index 363b7b3..e71ce98 100644 --- a/tilework.ui/Components/Pages/IdentityManagement/UserNew.razor +++ b/tilework.ui/Components/Pages/IdentityManagement/UserNew.razor @@ -27,8 +27,8 @@ private List _breadcrumbs = new List { new BreadcrumbItem("Home", href: "/", icon: Icons.Material.Filled.Home), - new BreadcrumbItem("Users", href: "/im/users", icon: Icons.Material.Filled.AltRoute), - new BreadcrumbItem("Create new", href: null, disabled: true, icon: Icons.Material.Filled.AltRoute) + new BreadcrumbItem("Users", href: "/im/users", icon: Icons.Material.Filled.Person), + new BreadcrumbItem("Create new", href: null, disabled: true, icon: Icons.Material.Outlined.AddCircleOutline) }; protected override async Task OnInitializedAsync() diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor index 9a5f5c8..fb5a925 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerDetail.razor @@ -61,9 +61,7 @@ - - @context.Item.Priority - + @context.Item.Priority @@ -92,7 +90,30 @@ } - + + + @if(context.Item.Action?.Type == RuleActionType.Forward) + { + var targetGroup = _loadBalancerService.GetTargetGroup(context.Item.TargetGroup.Value).Result; + Forward to @targetGroup.Name + } + else if(context.Item.Action?.Type == RuleActionType.Redirect) + { + + Redirect @context.Item.Action?.RedirectStatusCode + to @context.Item.Action?.RedirectUrl + + } + else if(context.Item.Action?.Type == RuleActionType.FixedResponse) + { + Fixed response @context.Item.Action?.FixedResponseStatusCode + } + else if(context.Item.Action?.Type == RuleActionType.Reject) + { + Reject connection + } + + @@ -225,6 +246,7 @@ parameters.Add(x => x.Rule, rule); parameters.Add(x => x.TargetGroups, await GetRuleTargetGroups()); parameters.Add(x => x.Protocol, _item.Protocol); + parameters.Add(x => x.Type, _item.Type); var options = new DialogOptions() { CloseButton = true, MaxWidth = MaxWidth.Medium, FullWidth = true }; @@ -261,6 +283,15 @@ Priority = rule.Priority, LoadBalancer = rule.LoadBalancer, TargetGroup = rule.TargetGroup, + Action = new RuleAction + { + Type = rule.Action.Type, + RedirectUrl = rule.Action.RedirectUrl, + RedirectStatusCode = rule.Action.RedirectStatusCode, + FixedResponseStatusCode = rule.Action.FixedResponseStatusCode, + FixedResponseContentType = rule.Action.FixedResponseContentType, + FixedResponseBody = rule.Action.FixedResponseBody + }, Conditions = rule.Conditions.Select(c => new Condition { Type = c.Type, @@ -272,6 +303,7 @@ parameters.Add(x => x.Rule, ruleEdit); parameters.Add(x => x.TargetGroups, await GetRuleTargetGroups()); parameters.Add(x => x.Protocol, _item.Protocol); + parameters.Add(x => x.Type, _item.Type); parameters.Add(x => x.Title, "Edit rule"); parameters.Add(x => x.ButtonText, "Save"); parameters.Add(x => x.Icon, Icons.Material.Filled.Edit); @@ -366,19 +398,7 @@ private async Task> GetRuleTargetGroups() { - var protocols = _item.Type == LoadBalancerType.NETWORK - ? new List - { - TargetGroupProtocol.TCP, - TargetGroupProtocol.UDP, - TargetGroupProtocol.TCP_UDP, - TargetGroupProtocol.TLS - } - : new List - { - TargetGroupProtocol.HTTP, - TargetGroupProtocol.HTTPS - }; + var protocols = TargetGroupProtocolRules.GetAllowedProtocols(_item.Type); return (await _loadBalancerService.GetTargetGroups()) .Where(tg => protocols.Contains(tg.Protocol)) @@ -441,33 +461,46 @@ private async Task> LoadMonitoringCharts(DateTimeOffset from, DateTimeOffset to, TimeSpan interval) { var charts = new List(); + var includeHttpMetrics = _item?.Type == LoadBalancerType.APPLICATION; List? data = await _loadBalancerService.GetLoadBalancerMonitoringData(_item.Id, interval, from, to) ?? new List(); Dictionary> chartData = new() { ["Sessions"] = new Dictionary(), - ["Requests"] = new Dictionary(), - ["HTTP 1xx"] = new Dictionary(), - ["HTTP 2xx"] = new Dictionary(), - ["HTTP 3xx"] = new Dictionary(), - ["HTTP 4xx"] = new Dictionary(), - ["HTTP 5xx"] = new Dictionary(), - ["HTTP Other"] = new Dictionary(), + ["Bytes in"] = new Dictionary(), + ["Bytes out"] = new Dictionary(), }; + if (includeHttpMetrics) + { + chartData["Requests"] = new Dictionary(); + chartData["HTTP 1xx"] = new Dictionary(); + chartData["HTTP 2xx"] = new Dictionary(); + chartData["HTTP 3xx"] = new Dictionary(); + chartData["HTTP 4xx"] = new Dictionary(); + chartData["HTTP 5xx"] = new Dictionary(); + chartData["HTTP Other"] = new Dictionary(); + } + foreach (var entry in data.OrderBy(d => d.Timestamp)) { var timestamp = entry.Timestamp.ToUniversalTime(); chartData["Sessions"][timestamp] = entry.Sessions; - chartData["Requests"][timestamp] = entry.Requests; - chartData["HTTP 1xx"][timestamp] = entry.HttpResponses1xx; - chartData["HTTP 2xx"][timestamp] = entry.HttpResponses2xx; - chartData["HTTP 3xx"][timestamp] = entry.HttpResponses3xx; - chartData["HTTP 4xx"][timestamp] = entry.HttpResponses4xx; - chartData["HTTP 5xx"][timestamp] = entry.HttpResponses5xx; - chartData["HTTP Other"][timestamp] = entry.HttpResponsesOther; + chartData["Bytes in"][timestamp] = entry.BytesIn; + chartData["Bytes out"][timestamp] = entry.BytesOut; + + if (includeHttpMetrics) + { + chartData["Requests"][timestamp] = entry.Requests; + chartData["HTTP 1xx"][timestamp] = entry.HttpResponses1xx; + chartData["HTTP 2xx"][timestamp] = entry.HttpResponses2xx; + chartData["HTTP 3xx"][timestamp] = entry.HttpResponses3xx; + chartData["HTTP 4xx"][timestamp] = entry.HttpResponses4xx; + chartData["HTTP 5xx"][timestamp] = entry.HttpResponses5xx; + chartData["HTTP Other"][timestamp] = entry.HttpResponsesOther; + } } foreach(var datapoint in chartData) diff --git a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerNew.razor b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerNew.razor index 6e4bec2..57009cc 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerNew.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/LoadBalancerNew.razor @@ -30,7 +30,7 @@ { new BreadcrumbItem("Home", href: "/", icon: Icons.Material.Filled.Home), new BreadcrumbItem("Load balancers", href: "/lb/loadbalancers", icon: Icons.Material.Filled.AltRoute), - new BreadcrumbItem("Create new", href: null, disabled: true, icon: Icons.Material.Filled.AltRoute) + new BreadcrumbItem("Create new", href: null, disabled: true, icon: Icons.Material.Outlined.AddCircleOutline) }; diff --git a/tilework.ui/Components/Pages/LoadBalancing/TargetGroupDetail.razor b/tilework.ui/Components/Pages/LoadBalancing/TargetGroupDetail.razor index 11aa018..3fccd1e 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/TargetGroupDetail.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/TargetGroupDetail.razor @@ -17,6 +17,12 @@ { + + + Protocol + @_item.Protocol + + @@ -77,7 +83,7 @@ private List _breadcrumbs = new List { new BreadcrumbItem("Home", href: "/", icon: Icons.Material.Filled.Home), - new BreadcrumbItem("Target groups", href: "/lb/targetgroups", icon: Icons.Material.Filled.AltRoute) + new BreadcrumbItem("Target groups", href: "/lb/targetgroups", icon: Icons.Material.Filled.Storage) }; private List _actions = new List {}; diff --git a/tilework.ui/Components/Pages/LoadBalancing/TargetGroupEdit.razor b/tilework.ui/Components/Pages/LoadBalancing/TargetGroupEdit.razor index adb1776..40e7de5 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/TargetGroupEdit.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/TargetGroupEdit.razor @@ -30,7 +30,7 @@ private List _breadcrumbs = new List { new BreadcrumbItem("Home", href: "/", icon: Icons.Material.Filled.Home), - new BreadcrumbItem("Target groups", href: "/lb/targetgroups", icon: Icons.Material.Filled.AltRoute) + new BreadcrumbItem("Target groups", href: "/lb/targetgroups", icon: Icons.Material.Filled.Storage) }; protected override async Task OnInitializedAsync() diff --git a/tilework.ui/Components/Pages/LoadBalancing/TargetGroupList.razor b/tilework.ui/Components/Pages/LoadBalancing/TargetGroupList.razor index f32dc5b..3b93db8 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/TargetGroupList.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/TargetGroupList.razor @@ -29,7 +29,7 @@ private List _breadcrumbs = new List { new BreadcrumbItem("Home", href: "/", icon: Icons.Material.Filled.Home), - new BreadcrumbItem("Target groups", href: "/lb/targetgroups/", disabled: true, icon: Icons.Material.Filled.AltRoute), + new BreadcrumbItem("Target groups", href: "/lb/targetgroups/", disabled: true, icon: Icons.Material.Filled.Storage), }; private List _actions = new List diff --git a/tilework.ui/Components/Pages/LoadBalancing/TargetGroupNew.razor b/tilework.ui/Components/Pages/LoadBalancing/TargetGroupNew.razor index 143d03d..76eea2b 100644 --- a/tilework.ui/Components/Pages/LoadBalancing/TargetGroupNew.razor +++ b/tilework.ui/Components/Pages/LoadBalancing/TargetGroupNew.razor @@ -1,3 +1,5 @@ +@using System.Linq +@using Tilework.Core.Enums @using Tilework.LoadBalancing.Enums @using Tilework.LoadBalancing.Models @using Tilework.LoadBalancing.Interfaces @@ -27,10 +29,16 @@ private List _breadcrumbs = new List { new BreadcrumbItem("Home", href: "/", icon: Icons.Material.Filled.Home), - new BreadcrumbItem("Target groups", href: "/lb/targetgroups", icon: Icons.Material.Filled.AltRoute), - new BreadcrumbItem("Create new", href: null, disabled: true, icon: Icons.Material.Filled.AltRoute) + new BreadcrumbItem("Target groups", href: "/lb/targetgroups", icon: Icons.Material.Filled.Storage), + new BreadcrumbItem("Create new", href: null, disabled: true, icon: Icons.Material.Outlined.AddCircleOutline) }; + protected override Task OnInitializedAsync() + { + ConfigureProtocolOptions(); + return Task.CompletedTask; + } + private async Task Submit() { try @@ -45,4 +53,19 @@ _snackbar.Add($"Failed to add target group: {ex.Message}", Severity.Error); } } + + private void ConfigureProtocolOptions() + { + var allowed = TargetGroupProtocolRules.GetAllowedProtocols(); + var options = allowed.Select(protocol => new SelectOptionItem + { + Value = protocol, + Text = protocol.GetDescription() + }).ToList(); + + form.SetOptions(nameof(NewTargetGroupForm.Protocol), options); + + if (!allowed.Contains(form.Protocol)) + form.Protocol = allowed[0]; + } } diff --git a/tilework.ui/ServiceCollectionExtensions.cs b/tilework.ui/ServiceCollectionExtensions.cs index 777cb3b..c33eb9d 100644 --- a/tilework.ui/ServiceCollectionExtensions.cs +++ b/tilework.ui/ServiceCollectionExtensions.cs @@ -9,6 +9,7 @@ public static IServiceCollection AddUserInterface(this IServiceCollection servic { services.AddAutoMapper(typeof(FormMappingProfile)); services.AddScoped(); + services.AddScoped(); return services; } diff --git a/tilework.ui/Services/DownloadService.cs b/tilework.ui/Services/DownloadService.cs new file mode 100644 index 0000000..e0030a0 --- /dev/null +++ b/tilework.ui/Services/DownloadService.cs @@ -0,0 +1,43 @@ +using Microsoft.JSInterop; + +namespace Tilework.Ui.Services; + +public sealed class DownloadService +{ + private readonly IJSRuntime _jsRuntime; + + public DownloadService(IJSRuntime jsRuntime) + { + _jsRuntime = jsRuntime; + } + + public async Task DownloadAsync(string fileName, string contentType, Stream stream, bool leaveOpen = false) + { + if (string.IsNullOrWhiteSpace(fileName)) + throw new ArgumentException("Filename is required.", nameof(fileName)); + + if (string.IsNullOrWhiteSpace(contentType)) + throw new ArgumentException("Content type is required.", nameof(contentType)); + + if (stream == null) + throw new ArgumentNullException(nameof(stream)); + + if (stream.CanSeek) + stream.Position = 0; + + using var streamRef = new DotNetStreamReference(stream); + await _jsRuntime.InvokeVoidAsync("downloadFileFromStream", fileName, contentType, streamRef); + + if (!leaveOpen) + await stream.DisposeAsync(); + } + + public async Task DownloadAsync(string fileName, string contentType, byte[] data) + { + if (data == null) + throw new ArgumentNullException(nameof(data)); + + await using var stream = new MemoryStream(data, writable: false); + await DownloadAsync(fileName, contentType, stream); + } +} diff --git a/tilework.ui/Utilities/DateTimeFormatting.cs b/tilework.ui/Utilities/DateTimeFormatting.cs new file mode 100644 index 0000000..6dbbb7a --- /dev/null +++ b/tilework.ui/Utilities/DateTimeFormatting.cs @@ -0,0 +1,16 @@ +using System.Globalization; + +namespace Tilework.Ui.Utilities; + +public static class DateTimeFormatting +{ + public static string FormatTimestamp(DateTimeOffset value) + { + return value.ToString("d MMMM yyyy, HH:mm:ss (zzz)", CultureInfo.CurrentCulture); + } + + public static string FormatTimestamp(DateTimeOffset? value) + { + return value is null ? string.Empty : FormatTimestamp(value.Value); + } +} diff --git a/tilework.ui/tilework.ui.csproj b/tilework.ui/tilework.ui.csproj index 1b5fec8..23f1ea6 100644 --- a/tilework.ui/tilework.ui.csproj +++ b/tilework.ui/tilework.ui.csproj @@ -15,7 +15,6 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all - diff --git a/tilework.ui/wwwroot/js/download.js b/tilework.ui/wwwroot/js/download.js new file mode 100644 index 0000000..8d61f47 --- /dev/null +++ b/tilework.ui/wwwroot/js/download.js @@ -0,0 +1,12 @@ +window.downloadFileFromStream = async (fileName, contentType, streamReference) => { + const arrayBuffer = await streamReference.arrayBuffer(); + const blob = new Blob([arrayBuffer], { type: contentType }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + link.remove(); + URL.revokeObjectURL(url); +};