diff --git a/DysonNetwork.Gateway/Controllers/WellKnownController.cs b/DysonNetwork.Gateway/Controllers/WellKnownController.cs new file mode 100644 index 0000000..7e94544 --- /dev/null +++ b/DysonNetwork.Gateway/Controllers/WellKnownController.cs @@ -0,0 +1,16 @@ +using Microsoft.AspNetCore.Mvc; + +namespace DysonNetwork.Gateway.Controllers; + +[ApiController] +[Route("/.well-known")] +public class WellKnownController(IConfiguration configuration) : ControllerBase +{ + [HttpGet("domains")] + public IActionResult GetDomainMappings() + { + var domainMappings = configuration.GetSection("DomainMappings").GetChildren() + .ToDictionary(x => x.Key, x => x.Value); + return Ok(domainMappings); + } +} \ No newline at end of file diff --git a/DysonNetwork.Gateway/Dockerfile b/DysonNetwork.Gateway/Dockerfile new file mode 100644 index 0000000..b9273da --- /dev/null +++ b/DysonNetwork.Gateway/Dockerfile @@ -0,0 +1,23 @@ +FROM mcr.microsoft.com/dotnet/aspnet:9.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 +EXPOSE 8081 + +FROM mcr.microsoft.com/dotnet/sdk:9.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src +COPY ["DysonNetwork.Gateway/DysonNetwork.Gateway.csproj", "DysonNetwork.Gateway/"] +RUN dotnet restore "DysonNetwork.Gateway/DysonNetwork.Gateway.csproj" +COPY . . +WORKDIR "/src/DysonNetwork.Gateway" +RUN dotnet build "./DysonNetwork.Gateway.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "./DysonNetwork.Gateway.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "DysonNetwork.Gateway.dll"] \ No newline at end of file diff --git a/DysonNetwork.Gateway/DysonNetwork.Gateway.csproj b/DysonNetwork.Gateway/DysonNetwork.Gateway.csproj new file mode 100644 index 0000000..1e7d39a --- /dev/null +++ b/DysonNetwork.Gateway/DysonNetwork.Gateway.csproj @@ -0,0 +1,19 @@ + + + + net9.0 + enable + enable + + + + + + + + + + + + + diff --git a/DysonNetwork.Gateway/EtcdProxyConfigProvider.cs b/DysonNetwork.Gateway/EtcdProxyConfigProvider.cs new file mode 100644 index 0000000..5984aea --- /dev/null +++ b/DysonNetwork.Gateway/EtcdProxyConfigProvider.cs @@ -0,0 +1,108 @@ +using System.Text; +using dotnet_etcd.interfaces; +using Yarp.ReverseProxy.Configuration; + +namespace DysonNetwork.Gateway; + +public class EtcdProxyConfigProvider : IProxyConfigProvider, IDisposable +{ + private readonly IEtcdClient _etcdClient; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + private readonly CancellationTokenSource _watchCts = new(); + private CancellationTokenSource _cts = new(); + + public EtcdProxyConfigProvider(IEtcdClient etcdClient, IConfiguration configuration, ILogger logger) + { + _etcdClient = etcdClient; + _configuration = configuration; + _logger = logger; + + // Watch for changes in etcd + _etcdClient.WatchRange("/services/", _ => + { + _logger.LogInformation("Etcd configuration changed. Reloading proxy config."); + _cts.Cancel(); + _cts = new CancellationTokenSource(); + }, cancellationToken: _watchCts.Token); + } + + public IProxyConfig GetConfig() + { + // This will be called by YARP when it needs a new config + _logger.LogInformation("Generating new proxy config."); + var response = _etcdClient.GetRange("/services/"); + var kvs = response.Kvs; + + var clusters = new List(); + var routes = new List(); + + var domainMappings = _configuration.GetSection("DomainMappings").GetChildren() + .ToDictionary(x => x.Key, x => x.Value); + + _logger.LogInformation("Indexing {ServiceCount} services from Etcd.", kvs.Count); + + foreach (var kv in kvs) + { + var serviceName = Encoding.UTF8.GetString(kv.Key.ToByteArray()).Replace("/services/", ""); + var serviceUrl = Encoding.UTF8.GetString(kv.Value.ToByteArray()); + + _logger.LogInformation(" Service: {ServiceName}, URL: {ServiceUrl}", serviceName, serviceUrl); + + var cluster = new ClusterConfig + { + ClusterId = serviceName, + Destinations = new Dictionary + { + { "destination1", new DestinationConfig { Address = serviceUrl } } + } + }; + clusters.Add(cluster); + + // Host-based routing + if (domainMappings.TryGetValue(serviceName, out var domain)) + { + var hostRoute = new RouteConfig + { + RouteId = $"{serviceName}-host", + ClusterId = serviceName, + Match = new RouteMatch + { + Hosts = new[] { domain }, + Path = "/{**catch-all}" + } + }; + routes.Add(hostRoute); + _logger.LogInformation(" Added Host-based Route: {Host}", domain); + } + + // Path-based routing + var pathRoute = new RouteConfig + { + RouteId = $"{serviceName}-path", + ClusterId = serviceName, + Match = new RouteMatch { Path = $"/{serviceName}/{{**catch-all}}" } + }; + routes.Add(pathRoute); + _logger.LogInformation(" Added Path-based Route: {Path}", pathRoute.Match.Path); + } + + return new CustomProxyConfig(routes, clusters); + } + + private class CustomProxyConfig(IReadOnlyList routes, IReadOnlyList clusters) + : IProxyConfig + { + public IReadOnlyList Routes { get; } = routes; + public IReadOnlyList Clusters { get; } = clusters; + public Microsoft.Extensions.Primitives.IChangeToken ChangeToken { get; } = new Microsoft.Extensions.Primitives.CancellationChangeToken(CancellationToken.None); + } + + public void Dispose() + { + _cts.Cancel(); + _cts.Dispose(); + _watchCts.Cancel(); + _watchCts.Dispose(); + } +} \ No newline at end of file diff --git a/DysonNetwork.Gateway/Program.cs b/DysonNetwork.Gateway/Program.cs new file mode 100644 index 0000000..df34149 --- /dev/null +++ b/DysonNetwork.Gateway/Program.cs @@ -0,0 +1,15 @@ +using DysonNetwork.Gateway.Startup; + +var builder = WebApplication.CreateBuilder(args); + +// Add services to the container. +builder.Services.AddGateway(builder.Configuration); +builder.Services.AddControllers(); + +var app = builder.Build(); + +// app.UseHttpsRedirection(); + +app.MapReverseProxy(); + +app.Run(); diff --git a/DysonNetwork.Gateway/Properties/launchSettings.json b/DysonNetwork.Gateway/Properties/launchSettings.json new file mode 100644 index 0000000..bb5bb2e --- /dev/null +++ b/DysonNetwork.Gateway/Properties/launchSettings.json @@ -0,0 +1,23 @@ +{ + "$schema": "https://json.schemastore.org/launchsettings.json", + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "http://localhost:5094", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "applicationUrl": "https://localhost:7034;http://localhost:5094", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/DysonNetwork.Gateway/Startup/ServiceCollectionExtensions.cs b/DysonNetwork.Gateway/Startup/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..ba1f598 --- /dev/null +++ b/DysonNetwork.Gateway/Startup/ServiceCollectionExtensions.cs @@ -0,0 +1,16 @@ +using DysonNetwork.Shared.Registry; +using Yarp.ReverseProxy.Configuration; + +namespace DysonNetwork.Gateway.Startup; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddGateway(this IServiceCollection services, IConfiguration configuration) + { + services.AddReverseProxy(); + services.AddRegistryService(configuration); + services.AddSingleton(); + + return services; + } +} diff --git a/DysonNetwork.Gateway/appsettings.json b/DysonNetwork.Gateway/appsettings.json new file mode 100644 index 0000000..0e7c15b --- /dev/null +++ b/DysonNetwork.Gateway/appsettings.json @@ -0,0 +1,25 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*", + "ConnectionStrings": { + "Etcd": "etcd.orb.local:2379" + }, + "Etcd": { + "Insecure": true + }, + "Service": { + "Name": "DysonNetwork.Gateway", + "Url": "https://localhost:7034" + }, + "DomainMappings": { + "DysonNetwork.Pass": "id.solsynth.dev", + "DysonNetwork.Drive": "drive.solsynth.dev", + "DysonNetwork.Pusher": "push.solsynth.dev", + "DysonNetwork.Sphere": "sphere.solsynth.dev" + } +} diff --git a/DysonNetwork.Shared/Auth/Startup.cs b/DysonNetwork.Shared/Auth/Startup.cs index 103aac2..3cd02ed 100644 --- a/DysonNetwork.Shared/Auth/Startup.cs +++ b/DysonNetwork.Shared/Auth/Startup.cs @@ -15,8 +15,8 @@ public static class DysonAuthStartup { var etcdClient = sp.GetRequiredService(); var config = sp.GetRequiredService(); - var clientCertPath = config["Service:ClientCert"]; - var clientKeyPath = config["Service:ClientKey"]; + var clientCertPath = config["Service:ClientCert"]!; + var clientKeyPath = config["Service:ClientKey"]!; var clientCertPassword = config["Service:CertPassword"]; return GrpcClientHelper @@ -24,6 +24,20 @@ public static class DysonAuthStartup .GetAwaiter() .GetResult(); }); + + services.AddSingleton(sp => + { + var etcdClient = sp.GetRequiredService(); + var config = sp.GetRequiredService(); + var clientCertPath = config["Service:ClientCert"]!; + var clientKeyPath = config["Service:ClientKey"]!; + var clientCertPassword = config["Service:CertPassword"]; + + return GrpcClientHelper + .CreatePermissionServiceClient(etcdClient, clientCertPath, clientKeyPath, clientCertPassword) + .GetAwaiter() + .GetResult(); + }); services.AddAuthentication(options => { diff --git a/DysonNetwork.Shared/Proto/GrpcClientHelper.cs b/DysonNetwork.Shared/Proto/GrpcClientHelper.cs index f254575..05b3213 100644 --- a/DysonNetwork.Shared/Proto/GrpcClientHelper.cs +++ b/DysonNetwork.Shared/Proto/GrpcClientHelper.cs @@ -61,6 +61,18 @@ public static class GrpcClientHelper return new AuthService.AuthServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath, clientCertPassword)); } + + public static async Task CreatePermissionServiceClient( + IEtcdClient etcdClient, + string clientCertPath, + string clientKeyPath, + string? clientCertPassword = null + ) + { + var url = await GetServiceUrlFromEtcd(etcdClient, "DysonNetwork.Pass"); + return new PermissionService.PermissionServiceClient(CreateCallInvoker(url, clientCertPath, clientKeyPath, + clientCertPassword)); + } public static async Task CreatePusherServiceClient( IEtcdClient etcdClient, diff --git a/DysonNetwork.sln b/DysonNetwork.sln index 1ef9fd7..0ce0170 100644 --- a/DysonNetwork.sln +++ b/DysonNetwork.sln @@ -1,5 +1,6 @@  Microsoft Visual Studio Solution File, Format Version 12.00 +# Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.Sphere", "DysonNetwork.Sphere\DysonNetwork.Sphere.csproj", "{CFF62EFA-F4C2-4FC7-8D97-25570B4DB452}" EndProject Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Solution Items", "Solution Items", "{A444D180-5B51-49C3-A35D-AA55832BBC66}" @@ -15,6 +16,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.Pusher", "Dyso EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.Drive", "DysonNetwork.Drive\DysonNetwork.Drive.csproj", "{8DE0B783-8852-494D-B90A-201ABBB71202}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DysonNetwork.Gateway", "DysonNetwork.Gateway\DysonNetwork.Gateway.csproj", "{19EB0086-4049-4B78-91C4-EAC37130A006}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -41,5 +44,9 @@ Global {8DE0B783-8852-494D-B90A-201ABBB71202}.Debug|Any CPU.Build.0 = Debug|Any CPU {8DE0B783-8852-494D-B90A-201ABBB71202}.Release|Any CPU.ActiveCfg = Release|Any CPU {8DE0B783-8852-494D-B90A-201ABBB71202}.Release|Any CPU.Build.0 = Release|Any CPU + {19EB0086-4049-4B78-91C4-EAC37130A006}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {19EB0086-4049-4B78-91C4-EAC37130A006}.Debug|Any CPU.Build.0 = Debug|Any CPU + {19EB0086-4049-4B78-91C4-EAC37130A006}.Release|Any CPU.ActiveCfg = Release|Any CPU + {19EB0086-4049-4B78-91C4-EAC37130A006}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/compose.yaml b/compose.yaml index 00ec211..fbfb60d 100644 --- a/compose.yaml +++ b/compose.yaml @@ -1,7 +1,96 @@ -services: - sphere: - image: xsheep2010/dyson-sphere:latest +services: + etcd: + image: bitnami/etcd:latest + ports: + - "2379:2379" + - "2380:2380" + environment: + - ETCD_ADVERTISE_CLIENT_URLS=http://etcd:2379 + - ETCD_LISTEN_CLIENT_URLS=http://0.0.0.0:2379 + - ETCD_LISTEN_PEER_URLS=http://0.0.0.0:2380 + - ETCD_INITIAL_ADVERTISE_PEER_URLS=http://etcd:2380 + - ETCD_INITIAL_CLUSTER_TOKEN=etcd-cluster + - ETCD_INITIAL_CLUSTER_STATE=new + - ETCD_INITIAL_CLUSTER=etcd=http://etcd:2380 + healthcheck: + test: ["CMD", "etcdctl", "get", "/health"] + interval: 5s + timeout: 5s + retries: 5 + + gateway: + build: + context: . + dockerfile: DysonNetwork.Gateway/Dockerfile + ports: + - "8000:8080" + environment: + - ConnectionStrings__Etcd=http://etcd:2379 + - Etcd__Insecure=true + - Service__Name=DysonNetwork.Gateway + - Service__Url=http://gateway:8080 + depends_on: + etcd: + condition: service_healthy + + drive: + build: + context: . + dockerfile: DysonNetwork.Drive/Dockerfile ports: - "8001:8080" + environment: + - ConnectionStrings__Etcd=http://etcd:2379 + - Etcd__Insecure=true + - Service__Name=DysonNetwork.Drive + - Service__Url=http://drive:8080 + depends_on: + etcd: + condition: service_healthy + + pass: + build: + context: . + dockerfile: DysonNetwork.Pass/Dockerfile + ports: + - "8002:8080" + environment: + - ConnectionStrings__Etcd=http://etcd:2379 + - Etcd__Insecure=true + - Service__Name=DysonNetwork.Pass + - Service__Url=http://pass:8080 + depends_on: + etcd: + condition: service_healthy + + pusher: + build: + context: . + dockerfile: DysonNetwork.Pusher/Dockerfile + ports: + - "8003:8080" + environment: + - ConnectionStrings__Etcd=http://etcd:2379 + - Etcd__Insecure=true + - Service__Name=DysonNetwork.Pusher + - Service__Url=http://pusher:8080 + depends_on: + etcd: + condition: service_healthy + + sphere: + build: + context: . + dockerfile: DysonNetwork.Sphere/Dockerfile + ports: + - "8004:8080" + environment: + - ConnectionStrings__Etcd=http://etcd:2379 + - Etcd__Insecure=true + - Service__Name=DysonNetwork.Sphere + - Service__Url=http://sphere:8080 volumes: - "./keys:/app/keys" + depends_on: + etcd: + condition: service_healthy \ No newline at end of file