dotnet / aspire

Tools, templates, and packages to accelerate building observable, production-ready apps
https://learn.microsoft.com/dotnet/aspire
MIT License
3.93k stars 480 forks source link

Allow specifying an address hostname for project/service binding #4319

Open tdhatcher opened 6 months ago

tdhatcher commented 6 months ago

@davidfowl @mitchdenny This issue I'm trying to solve (or feature enhancement) is related to not being able to provide a specific hostname/address alias to resource.

While I'm not trying to externally access anything orchestrated locally I think there maybe some fundamental overlap with discussions here: #2887 and #3146 and pull request: #3492

I have a couple use cases:

  1. I have several UI projects that rely on OIDC and specific domains are whitelisted in Okta for redirects. Of course I can lobby to statically add a whitelist of localhost:port but that requires another team that manages that infrastructure to get involved.
  2. We have multi-tenancy logic that utilizes the request's host vanity domain to serve as a parameter to alter context and behavior at runtime. So with the same hosted instance a request with .vanity-A.com would do something slightly different than .vanity-B.com

So it would be awesome to be able supply additional address bindings specifically for local hosting that ALSO display on the dashboard. (This was something I recall doing with Tye) image

An existing tye.yaml

- name: ui
  project: ../src/App.UI/App.UI.csproj
  replicas: 1
  bindings:
  - name: http
    host: localhost
    protocol: http
  - name: https
    host: localhost
    protocol: https
  - name: local
    host: my.vanity-a.com
    protocol: https
    port: 1337
  env:
  - ASPNETCORE_ENVIRONMENT=Development

A "kinda" workaround

I've been adapting an existing distributed application into the Aspire model. My goal has been to do this incrementally without being invasive or making modifications to the existing projects to prevent any refactor disruptions or coordination. I have been successful enough so far, so kudos on baking in that flexibility from the start!

The workaround I have found like below so far appears to allow a workable path but not ideal of course from the dashboard or verbosity.

var ui = builder.AddProject<Projects.MyAccount_UI>("ui", null)
    .WithReference(api)
    .WithHttpsEndpoint(port: 7098, isProxied: false)
    .WithEnvironment("ASPNETCORE_ENVIRONMENT", "Development")
    // Need to specify address. Environment customization allows us to provide a hostname other
    // than localhost for Okta whitelist and general compatibility with current codebase state.
    .WithEnvironment(async context =>
    {
        if (context.ExecutionContext.IsRunMode)
        {
            var aspnetUrls = (await (context.EnvironmentVariables["ASPNETCORE_URLS"] as ReferenceExpression)!.GetValueAsync(context.CancellationToken));
            context.EnvironmentVariables["ASPNETCORE_URLS"] = aspnetUrls!.Replace("localhost", "my.vanity-a.com");
        }
    })
    // This does cause another addition endpoint to render under the resource in the dashboard but only localhost, no hostname address parameter
    .WithAnnotation(new EndpointAnnotation(protocol: ProtocolType.Tcp, "https", name: "my-vanity-b", port: 18001, isExternal: true, isProxied: false));
mitchdenny commented 6 months ago

What are you doing for certificates in this scenario? Are you manually installing a self-signed cert for my.vanity-a.com? Or are you just ignoring the cert warnings? Are you manually modifying your hosts file to make this work as well?

tdhatcher commented 6 months ago

@mitchdenny I'd have to say all of the above. Which isn't too much overhead compared to needing clone repos, get secrets, etc. That initial ceremony but could mostly be scripted.

Manually doing the following.

mitchdenny commented 5 months ago

At this point we probably aren't going to try to tackle this with Aspire but I'll leave this on the backlog to collect more feedback. I can see the utility of this but there are some inherent security risks of doing this.

tdhatcher commented 5 months ago

@mitchdenny I'm not completely understanding if the security risk part is really relevant.

I think at this point the main thing I'm missing is just having the flexibility to insert an additional "aliased" endpoint link to render on the dashboard for the resource (and the verbosity to achieve that) to steer others who run the Aspire AppHost as configured. Otherwise I already have the behavior and flow working as I need it to. It's just that I have to communicate this in another way to others -- it's not intuitive to other devs off the shelf that it can be accessed in another way.

I did have the hostname and port I'm binding to whitelisted in the external Okta tool (or whatever configurable blackbox that is outside of the purview of Aspire)

DamianEdwards commented 5 months ago

Related to #1116

tdhatcher commented 5 months ago

@DamianEdwards Thanks. Sorry I tried to find if there were any existing issues that were similar before posting. I'd say that #1116 is definitely on the same wavelength more than any other previous issues.

In my case 1) I would want the same port number (and specified) to serve for all hostnames. And 2) for it to be recognized on the Dashboard so a person would easily see "here are all the possible tenants/brands served for this resource". My application won't even function if I try to access it on localhost or 127.0.0.1 because it essentially will not map to a valid configuration state.

From some of what I have gathered service discovery seems to be something a bit more complex to solve for in general. For what it is worth, since I'm retrofitting existing applications into Aspire I'm basically crafting the environment variable for the service url configuration myself so I'm not really depedent on that at the moment. However my hope is to gradually migrate to adopt the Aspire conventions at some later time -- to remove most of the environment variable overrides.

I could live with this syntax of accessing some building blocks like this as it is more concise over what I have working now. I'll continue to experiement to see if I can come up with a cleaner workaround in the meantime.

AppHost.cs

var myUI = builder.AddProject<Projects.MyUI>("myUI")
    // 7085 port is required
    .WithEndpoint(port: 7085, scheme: "https", name: "vanitya", hostName: "my.vanity-a.com")
    .WithEndpoint(port: 7085, scheme: "https", name: "vanityb", hostName: "my.vanity-b.com")
    .WithEndpoint(port: 7085, scheme: "https", name: "vanityc", hostName: "my.vanity-c.com")

    // Maybe another overload
    .WithEndpoint(port: 7085, scheme: "https", hosts: [
        new() {Name = "vanitya", hostName: "my.vanity-a.com"},
        new() {Name = "vanityb", hostName: "my.vanity-b.com"},
        new() {Name = "vanityc", hostName: "my.vanity-c.com"},
    ]);
;

var myServiceApi = builder.AddProject<Projects.MyServiceApi>("myServiceApi")
    // Non-standard service url injection not following Aspire conventions
    .WithEnvironment("Brands__A__Url", myUI.GetEndpoint("vanitya").Url)
    .WithEnvironment("Brands__A__Url", "https://my.vanitya.com" + myUI.GetEndpoint("https").Port) // an less consise alternative
    .WithEnvironment("Brands__B__Url", myUI.GetEndpoint("vanitya").Url)
    .WithEnvironment("Brands__C__Url", myUI.GetEndpoint("vanitya").Url);

HttpClient Service Discovery

services__myui__vanitya__0
services__myui__vanityb__0
services__myui__vanityc__0

https+http://myui_vanitya/user
https+http://myui_vanityb/user
https+http://myui_vanityc/user

https+http://myui:vanitya/user
https+http://myui:vanityb/user
https+http://myui:vanityc/user

// This would look a bit weird (or astonishing).
vanitya://myui/user
vanityb://myui/user
vanityc://myui/user

MyUI.csproj launchSettings.json

{
    "profiles": {
        "http": {
            "commandName": "Project",
            "dotnetRunMessages": true,
            "launchBrowser": true,
            "applicationUrl": "http://*:{port doesn't matter to me}",
            "environmentVariables": {
                "ASPNETCORE_ENVIRONMENT": "Development"
            }
        },
    }
}
matt1munich commented 5 months ago

+1 & subscribed. I am also in the same boat.

mitchdenny commented 5 months ago

I think that this is a super specific scenario so we'd be wanting to collect a lot more evidence around the need before we race off to implementation.

Supporting multi-tenant scenarios is challenging because there are so many different approaches and layers in which tenancy comes into play.

mitchdenny commented 5 months ago

One idle thought about this. My inclination would be to define a reverse proxy as a resource within the app model which is configured to respond on all the domains you want and then pass the host header through to the ASP.NET project. This is more inline with how this kind of tenanting mechanism works in production anyway.

Then you could just have a single wildcard host entry for that endpoint. You'd probably need to handle the cert side of things yourself since we can only issue the standard ASP.NET Core dev cert at this point in time.

tdhatcher commented 5 months ago

@mitchdenny I believe I understand what you are conveying here. It does feel like a heavier handed approach which is additional complexity just for Aspire purposes but not required with the existing application when launched locally with launchProfile with normal Visual Studio or even IIS. The equivalent was very easy to achieve with Tye, any binding added would just render the scheme and hostname/port on the dashboard.

If I'm not mistaken the reverse proxy appeoach still wouldn't help to address the primary "user friendliness" issue of allowing an uninitiated developer to easily discover the all the available hosted vanity urls by strictly relying on what is presented by the dashboard alone. How would the developer know which urls the reverse proxy resource supports?

The current missing vanity url details of a resource would have to be part of some buried and lesser obvious means such as exploring internal documentation or app configuration of the reverse proxy or the multi-tenant app as part of an onboarding process which is what I'm wanting to avoid (but currently appears to be my only option). Ideally, I would like the experience for other new devs on the team to be turnkey without having to dig into docs.

My actual use cases are driven by OIDC requirements and other multi-tenant/branded app behaviors that are already deployed in a production environment without the need for a reverse proxy. On here I've been relying on communicating similat usage scenarios that are contrived for simplification purposes but hopefully still accurate enough to communicate the desire.

Onwards to a better workaround

Assuming no further work is done to make an easier or more convenient interface, at this point I'd say a workaround I'd really be interested in is how to influence what is rendered on the dashboard for all the endpoint links associated with a resource. That would do the trick. I'm just needing to inject a couple vanity urls to render for the resource.

I was able to sort of achieve a new entry using .WithAnnotation(new EndpointAnnotation(...)) however I am still in the same boat as changing localhost does not appear to be a parameter. Maybe I can extend that annotation and override it? I haven't dug deep enough there as to how the dashboard constructs the endpoint urls it renders.

But because I'm already using some of the hackiness around overriding context.EnvironmentVariables["ASPNETCORE_URLS"] that at least does most of the heavy lifting to get the app to listen on host/port other than localhost. So if a developer is aware of the valid hosted vanity urls, they can still interact with the app in that context through thr browser or postman.

I can live with this little hackiness and extra configuration verbosity in the app host if it makes other devs on the project's life much easier. At least I'd have recipe to apply that makes the resource bind correctly and also render all its appropriate links on the dashboard.

clarkezone commented 4 months ago

+1 to the issue. This is currently blocking my environment.

mitchdenny commented 4 months ago

@tdhatcher take a look at the CustomResource playground:

https://github.com/dotnet/aspire/blob/2249f7d4d114784ed4f117a60b1b2963d971bd92/playground/CustomResources/CustomResources.AppHost/TestResource.cs#L49

It uses ResourceNotificationService to add update properties (in this case) on a resource. But you can also do things like add URLs. This is how the Azure provisioning logic works to add links to the deployment URLs in the Azure portal ... example:

https://github.com/dotnet/aspire/blob/2249f7d4d114784ed4f117a60b1b2963d971bd92/src/Aspire.Hosting.Azure/Provisioning/Provisioners/BicepProvisioner.cs#L97

tdhatcher commented 4 months ago

It uses ResourceNotificationService to add update properties (in this case) on a resource. But you can also do things like add URLs.

Thanks @mitchdenny I will dig further into this to see what I can do with it. On vacation at the moment so not a laptop in sight for a couple weeks 😀.

tdhatcher commented 4 months ago

Didn't mean to close this yet. Mobile!

AndrewBabbitt97 commented 4 months ago

This would also be useful for unix sockets, which we currently have no way of specifying the file path to the socket for an endpoint as well.

clarkezone commented 4 months ago

@glennc as I was talking to him about this recently

clarkezone commented 4 months ago

Didn't mean to close this yet. Mobile!

Are you planning to reopen?

hades200082 commented 2 months ago

Another thing this could help with is running out of space in localhost cookies with Aspire.

image

I now can't access Redis Commander, PG Admin, etc. because they throw HTTP 431 errors. Clearing cookies doesn't help because Aspire just puts them all back again on next run.

Being able to use different hostnames for services and projects would solve this issue. This is similar to feature request #5508 I opened earlier.

mitchdenny commented 2 months ago

@hades200082 can you create a GitHub repo with a repro of the cookie issue you are seeing. This is the first I've seen of it. If you can reproduce it, then can you open a specific issue for that.

jbogard commented 2 months ago

Will this also let me NOT set any host? When you specify a port binding, by default Docker will not assign a host IP:

docker run -it --rm -p 8001:8080 --name aspnetcore_sample mcr.microsoft.com/dotnet/samples:aspnetapp
    "HostConfig": {
        "NetworkMode": "bridge",
        "PortBindings": {
            "8080/tcp": [
                {
                    "HostIp": "",
                    "HostPort": "8000"
                }
            ]
        },

It doesn't look like there are any options to NOT set the HostIp here except to skip the WithEndpoint method. I have to do this:

var sample = builder.AddContainer("aspnetcore-sample", "mcr.microsoft.com/dotnet/samples", "aspnetapp")
    //.WithEndpoint(8001, 8080, scheme: "http", isProxied: false, isExternal: true)
    .WithContainerRuntimeArgs("-p", "8001:8080")
    ;

If I use WithEndpoint then HostIp is set to 127.0.0.1.

TrieBr commented 1 month ago

+1 for this.

I'm working on an application that can be accessed from multiple subdomains, and requires a cookie to be shared across them. Shared cookies don't work on localhost so I configure special local domains using mkcert such as one.myapp.local and two.myapp.local, and update my hosts file accordingly. I set launchUrl in launchSettings.json to launch the dns name in my browser (one.myapp.local). This all works fine when I launch the project standalone from VSCode or Rider.

However I'm transitioning to Aspire, and I can't figure out a way to get the Aspire dashboard to show my actual launch url (one.myapp.local instead of localhost).

I don't really understand the security argument here. I just need the Aspire dashboard to show the launchUrl I put in launchSettings.json

asimmon commented 2 weeks ago

In this post I provide a workaround for displaying URLs with custom domains/hosts in the dashboard. You still have to manage your hosts file and create your certificates if needed.

https://anthonysimmon.com/dotnet-aspire-non-localhost-endpoints/

JeroenvanderMeerNutreco commented 1 week ago

Before we can use aspire we need to be able to use hostnames. We have an identity server running locally, it uses b2c on azure to do the actual authentication. b2c expects the hostname of the identityserver as valid redirect, adding localhost:4042 to b2c is not an option for us. also we have vite running with hot module replacement that also needs a hostname so it works with both iis or aspire. And it works much easier in the browser when you have a recognisable hostname versus random portnumber.

tdhatcher commented 3 days ago

I was able to achieve a working PoC of what I needed with some inspiration from @asimmon and @mitchdenny mentions of the ResourceNotificationService and IDistributedApplicationLifecycleHook. IDistributedApplicationLifecycleHook Image

The code is just a basic happy path implementation. Haven't given much thought around what to do if there was a use case if one resource has a reference to another resource where these endpoints are added and how you would discover them cleanly. But it does work.

AppHost

var permissionApi = builder.AddProject<Projects.Permissions_Api>("permissionsapi")
var productApi = builder.AddProject<Projects.Product_Api>("productapi")
    .WithReference(userApi);

var userApi = builder.AddProject<Projects.User_Api>("userapi")
    .WithReference(permissionsApi)
    .WithReference(productApi);

var identityApi = builder.AddProject<Projects.Identity_Api>("identityapi")
    .WithReference(permissionsApi)
    .WithReference(userApi);

var authUI = builder.AddProject<Projects.AuthUI_Api>("authui")
    .WithHostname(["auth.subsidiary-a.com", "auth.subsidiary-b.com", "auth.subsidiary-c.com"], excludeLocalhost: true)
    .WithReference(identityApi);

var dashboardApi = builder.AddProject<Projects.Dashboard_Api>("dashboardapi")
    .WithHostname(["dashboard.subsidiary-a.com", "dashboard.subsidiary-b.com", "dashboard.subsidiary-c.com"])
    .WithReference(userApi)
    .WithReference(productApi);

var dashboardClient = builder.AddNpmApp("dashboardclient", "../../Dashboard/src/AngularClient", "start")
    .WithHostname(["dashboard.subsidiary-a.com", "dashboard.subsidiary-b.com", "dashboard.subsidiary-c.com"], excludeLocalhost: true)
    .WithReference(dashboardApi)
    .WithHttpEndpoint(env: "PORT")
    .WithExternalHttpEndpoints();

var adminApi = builder.AddProject<Projects.Admin_Api>("adminapi")
    .WithHostname("admin.company-parent.com")
    .WithReference(userApi)
    .WithReference(productApi)
    .WithReference(permissionsApi);

var adminClient = builder.AddNpmApp("adminclient", "../../Admin/src/AngularClient", "start")
    .WithHostname("admin.company-parent.com", excludeLocalhost: true)
    .WithReference(adminApi)
    .WithHttpEndpoint(env: "PORT")
    .WithExternalHttpEndpoints();

builder.Build().Run();

Crude Implementation

public static class ResourceHostnameExtensions
{
    public static IResourceBuilder<T> WithHostname<T>(this IResourceBuilder<T> builder, string hostname, bool excludeLocalhost = false)
        where T : IResourceWithEndpoints
    {
        builder.ApplicationBuilder.Services.TryAddLifecycleHook<ResourceHostnameLifecycleHook>();
        return builder.WithAnnotation(new HostnameAnnotation([hostname], excludeLocalhost));
    }

    public static IResourceBuilder<T> WithHostname<T>(this IResourceBuilder<T> builder, List<string> hostnames, bool excludeLocalhost = false)
        where T : IResourceWithEndpoints
    {
        builder.ApplicationBuilder.Services.TryAddLifecycleHook<ResourceHostnameLifecycleHook>();
        return builder.WithAnnotation(new HostnameAnnotation(hostnames, excludeLocalhost));
    }

    private class HostnameAnnotation : IResourceAnnotation
    {
        public HostnameAnnotation([EndpointName] List<string> hostnames, bool excludeLocalhost)
        {
            ArgumentNullException.ThrowIfNull(hostnames);

            if (!excludeLocalhost)
            {
                hostnames.Add("localhost");
            }

            this.Hostnames = hostnames;
        }

        public List<string> Hostnames { get; }
    }

    private class ResourceHostnameLifecycleHook(ResourceNotificationService notificationService) : IDistributedApplicationLifecycleHook
    {
        public Task BeforeStartAsync(DistributedApplicationModel appModel, CancellationToken cancellationToken = default)
        {
            _ = EnsureUrlsAsync();
            return Task.CompletedTask;
        }

        private async Task EnsureUrlsAsync()
        {
            await foreach (var evt in notificationService.WatchAsync())
            {
                if (evt.Snapshot.State != KnownResourceStates.Running || evt.Resource is not IResourceWithEndpoints resource)
                {
                    // By default, .NET Aspire only displays endpoints for running resources.
                    continue;
                }

                var hostnames = evt.Resource.Annotations.OfType<HostnameAnnotation>().SelectMany(a => a.Hostnames).Distinct();
                var endpoints = evt.Resource.Annotations.OfType<EndpointAnnotation>();
                var urlsToAdd = ImmutableArray.CreateBuilder<UrlSnapshot>();

                foreach (var hostname in hostnames)
                {
                    foreach (var endpoint in endpoints)
                    {
                        if (endpoint.AllocatedEndpoint is null)
                        {
                            continue;
                        }

                        var url = $"{endpoint.UriScheme}://{hostname}:{endpoint.AllocatedEndpoint.Port}";
                        if (!Uri.TryCreate(url, UriKind.Absolute, out _))
                        {
                            throw new ArgumentException($"'{url}' is not an absolute URL.", nameof(url));
                        }

                        var urlAlreadyAdded = evt.Snapshot.Urls.Any(x => string.Equals(x.Name, hostname, StringComparison.OrdinalIgnoreCase));
                        if (!urlAlreadyAdded)
                        {
                            urlsToAdd.Add(new UrlSnapshot(hostname, url, IsInternal: false));
                        }
                    }
                }

                if (urlsToAdd.Count > 0)
                {
                    await notificationService.PublishUpdateAsync(evt.Resource, snapshot => snapshot with
                    {
                        Urls = urlsToAdd.ToImmutableArray()
                    });

                }
            }
        }
    }
}