diff --git a/README.md b/README.md index f3098ae..369b0a9 100644 --- a/README.md +++ b/README.md @@ -23,12 +23,26 @@ dotnet run --project src/AppHost First run opens a browser for `devtunnel` sign-in. Then: 1. Open the Aspire dashboard URL printed in the terminal. -2. Click the `tunnel` resource — copy its `https://*.devtunnels.ms` URL. -3. Edit `src/Proxy/appsettings.json` → set `Clusters.default.Destinations.primary.Address` to your upstream. YARP hot-reloads — no restart needed. -4. Edit `src/AppHost/appsettings.json` → change `DevTunnel:Id` to a unique slug (a–z, 0–9, `-`) so collaborators get distinct stable URLs. +2. Click the `tunnel-example` resource — copy its `https://*.devtunnels.ms` URL. +3. Edit `src/AppHost/proxies/example.json` → set `ReverseProxy.Clusters.default.Destinations.primary.Address` to your upstream. YARP hot-reloads — no restart needed. +4. To publish under a different stable URL, rename the file (e.g. `mv example.json my-slug.json`) and restart. The filename is the slug. That's it. Anything hitting the tunnel URL is forwarded to your configured destination. +### Add another proxy + +Drop a second JSON file in `src/AppHost/proxies/`: + +```bash +cp src/AppHost/proxies/example.json src/AppHost/proxies/api.json +# edit api.json — point Address at a different upstream +dotnet run --project src/AppHost +``` + +The dashboard now shows two pairs: `proxy-example` + `tunnel-example` and `proxy-api` + `tunnel-api`, each with its own public URL. Pairs are static — Ctrl+C and re-run to add or remove one. + +Filename rules: lowercase letters, digits, and hyphens only; 1–32 characters; must start and end alphanumeric. Bad filenames fail AppHost startup with a clear message. + ### Requirements | | Version | @@ -46,11 +60,22 @@ That's it. Anything hitting the tunnel URL is forwarded to your configured desti Stripe, GitHub, Slack, etc. need a public HTTPS endpoint to send events. Point the proxy at your local handler, paste the tunnel URL into the provider's webhook config. ```jsonc -// src/Proxy/appsettings.json -"Clusters": { - "default": { - "Destinations": { - "primary": { "Address": "http://localhost:5050/" } +// src/AppHost/proxies/webhooks.json +{ + "DevTunnel": { "AnonymousAccess": true }, + "ReverseProxy": { + "Routes": { + "default": { + "ClusterId": "default", + "Match": { "Path": "{**catch-all}" } + } + }, + "Clusters": { + "default": { + "Destinations": { + "primary": { "Address": "http://localhost:5050/" } + } + } } } } @@ -69,29 +94,40 @@ dotnet run --project src/AppHost # share the printed devtunnels.ms URL ``` -Stable across restarts as long as `DevTunnel:Id` doesn't change. +Stable across restarts as long as the proxy filename doesn't change. ### Add CORS to an upstream that doesn't support it Wrap a bare API with browser-friendly CORS without modifying the upstream: ```jsonc -"Cors": { - "Policies": { - "default": { - "AllowedOrigins": ["https://my-spa.example.com"], - "AllowedMethods": ["GET", "POST"], - "AllowedHeaders": ["*"], - "AllowCredentials": false +// src/AppHost/proxies/cors-bridge.json +{ + "DevTunnel": { "AnonymousAccess": true }, + "Cors": { + "Policies": { + "default": { + "AllowedOrigins": ["https://my-spa.example.com"], + "AllowedMethods": ["GET", "POST"], + "AllowedHeaders": ["*"], + "AllowCredentials": false + } } - } -}, -"ReverseProxy": { - "Routes": { - "default": { - "ClusterId": "default", - "CorsPolicy": "default", - "Match": { "Path": "{**catch-all}" } + }, + "ReverseProxy": { + "Routes": { + "default": { + "ClusterId": "default", + "CorsPolicy": "default", + "Match": { "Path": "{**catch-all}" } + } + }, + "Clusters": { + "default": { + "Destinations": { + "primary": { "Address": "http://localhost:5050/" } + } + } } } } @@ -104,14 +140,27 @@ Wrap a bare API with browser-friendly CORS without modifying the upstream: Public-facing tunnel, secret-bearing upstream. Use route transforms: ```jsonc -"Routes": { - "default": { - "ClusterId": "default", - "Match": { "Path": "{**catch-all}" }, - "Transforms": [ - { "RequestHeader": "X-Api-Key", "Set": "your-secret-here" }, - { "RequestHeader": "X-Trace", "Append": "proxy" } - ] +// src/AppHost/proxies/auth-injection.json +{ + "DevTunnel": { "AnonymousAccess": true }, + "ReverseProxy": { + "Routes": { + "default": { + "ClusterId": "default", + "Match": { "Path": "{**catch-all}" }, + "Transforms": [ + { "RequestHeader": "X-Api-Key", "Set": "your-secret-here" }, + { "RequestHeader": "X-Trace", "Append": "proxy" } + ] + } + }, + "Clusters": { + "default": { + "Destinations": { + "primary": { "Address": "http://localhost:5050/" } + } + } + } } } ``` @@ -123,14 +172,27 @@ Public-facing tunnel, secret-bearing upstream. Use route transforms: For upstreams that authenticate via body fields, not headers: ```jsonc -"Routes": { - "default": { - "ClusterId": "default", - "Match": { "Path": "{**catch-all}" }, - "Metadata": { - "InjectJsonField:AuthToken": "your-server-side-secret", - "InjectJsonField:Count": "42", - "InjectJsonField:Nested": "{\"k\":\"v\"}" +// src/AppHost/proxies/body-auth.json +{ + "DevTunnel": { "AnonymousAccess": true }, + "ReverseProxy": { + "Routes": { + "default": { + "ClusterId": "default", + "Match": { "Path": "{**catch-all}" }, + "Metadata": { + "InjectJsonField.AuthToken": "your-server-side-secret", + "InjectJsonField.Count": "42", + "InjectJsonField.Nested": "{\"k\":\"v\"}" + } + } + }, + "Clusters": { + "default": { + "Destinations": { + "primary": { "Address": "http://localhost:5050/" } + } + } } } } @@ -142,8 +204,9 @@ Each value parses as JSON first — `"42"` becomes a number, `"true"` a boolean, | File | Purpose | |---|---| -| `src/AppHost/appsettings.json` | Tunnel ID, public/private toggle | -| `src/Proxy/appsettings.json` | Routes, clusters, CORS policies, transforms | +| `src/AppHost/proxies/.json` | One file per proxy: tunnel access mode, YARP routes/clusters, CORS policies | +| `src/AppHost/appsettings.json` | Logging only. Optional `Proxies:Directory` to relocate the proxies folder | +| `src/Proxy/appsettings.json` | Logging only — runtime YARP/CORS config comes from the per-pair file via `Proxy__ConfigFile` | YARP routes/clusters fully follow the upstream schema — see [YARP config files](https://learn.microsoft.com/aspnet/core/fundamentals/servers/yarp/config-files). @@ -153,7 +216,7 @@ YARP routes/clusters fully follow the upstream schema — see [YARP config files `DevTunnel:AnonymousAccess: true` (the default) makes the tunnel URL **publicly reachable by anyone who knows it**. Don't proxy anything with secrets, dev databases, or unauthenticated admin surfaces over an anonymous tunnel. -Set `DevTunnel:AnonymousAccess: false` in `src/AppHost/appsettings.json` for a private tunnel. Recipients then need a Microsoft/GitHub login the owner has authorised, or an `X-Tunnel-Authorization` token from `devtunnel token`. Note: private tunnels block cross-origin browser callers — `fetch()` from a deployed SPA on another origin can't complete the interactive sign-in. +Set `DevTunnel:AnonymousAccess: false` in the per-pair file in `src/AppHost/proxies/` for a private tunnel. Recipients then need a Microsoft/GitHub login the owner has authorised, or an `X-Tunnel-Authorization` token from `devtunnel token`. Note: private tunnels block cross-origin browser callers — `fetch()` from a deployed SPA on another origin can't complete the interactive sign-in. To report a vulnerability privately, please open a [GitHub security advisory](https://github.com/LorcanChinnock/devtunnel-proxy/security/advisories/new) rather than a public issue. @@ -179,8 +242,10 @@ After clearing, re-run — the CLI repopulates the cache cleanly. Your user logi ``` src/ -├── AppHost/ .NET Aspire app host — wires the proxy to a Dev Tunnel -└── Proxy/ ASP.NET Core + YARP — routes/clusters live in appsettings.json +├── AppHost/ +│ ├── proxies/ one .json file per public URL — drop in to add a pair +│ └── ... .NET Aspire app host wiring proxies to dev tunnels +└── Proxy/ ASP.NET Core + YARP — reads its slice via Proxy:ConfigFile tests/ └── Proxy.Tests/ xUnit v3 integration + unit tests ``` diff --git a/docs/superpowers/plans/2026-05-07-multiple-proxies.md b/docs/superpowers/plans/2026-05-07-multiple-proxies.md new file mode 100644 index 0000000..d790a27 --- /dev/null +++ b/docs/superpowers/plans/2026-05-07-multiple-proxies.md @@ -0,0 +1,824 @@ +# Multiple proxies and devtunnels — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Replace the AppHost's single hard-coded `DevTunnel:Id` with a folder scan that creates one `Projects.Proxy` instance + one devtunnel per JSON file in `src/AppHost/proxies/`. + +**Architecture:** AppHost discovers `*.json` files at startup; filename = Aspire resource name = devtunnel slug. Each Proxy instance reads its assigned file via the `Proxy__ConfigFile` env var injected by Aspire. YARP's per-route hot-reload still works (per pair). Static at AppHost startup — adding/removing a pair requires restart. + +**Tech Stack:** .NET 10, Aspire 13.2 (Aspire.Hosting.DevTunnels), YARP 2.3, xUnit v3, Microsoft.AspNetCore.Mvc.Testing. + +**Spec:** `docs/superpowers/specs/2026-05-07-multiple-proxies-design.md` + +**Branch:** `feat/multi-proxy-support` + +--- + +## File map + +**Create:** +- `src/AppHost/ProxySlug.cs` — `public static partial class` with regex-based `IsValid(string?)` helper. +- `src/AppHost/ProxyConfigFile.cs` — `public static class` with `LoadAnonymousAccess(string)` helper. +- `src/AppHost/proxies/example.json` — first config-driven pair, replicates today's quickstart. +- `tests/Proxy.Tests/Hosting/ProxySlugTests.cs` — xUnit tests for slug validation. +- `tests/Proxy.Tests/Hosting/ProxyConfigFileTests.cs` — xUnit tests for the config helper. + +**Modify:** +- `src/AppHost/AppHost.cs` — replace single-tunnel block with folder scan + loop; refactor pre-flight check. +- `src/AppHost/appsettings.json` — drop `DevTunnel` block. +- `src/Proxy/Program.cs` — add three lines to load `Proxy:ConfigFile`. +- `src/Proxy/appsettings.json` — drop `Cors` and `ReverseProxy` sections. +- `tests/Proxy.Tests/Integration/ProxyAppFactory.cs` — add baseline CORS + ReverseProxy in-memory overrides. +- `tests/Proxy.Tests/Proxy.Tests.csproj` — add `ProjectReference` to `AppHost.csproj`. +- `README.md` — quickstart and use-case sections repointed at per-pair files; add "Add another proxy" subsection. + +--- + +## Task 1: ProxySlug helper (TDD) + +**Files:** +- Create: `src/AppHost/ProxySlug.cs` +- Create: `tests/Proxy.Tests/Hosting/ProxySlugTests.cs` +- Modify: `tests/Proxy.Tests/Proxy.Tests.csproj` + +- [ ] **Step 1: Add ProjectReference to AppHost in the test csproj** + +Replace the existing single-line `` containing `Proxy.csproj` reference (around line 22 of `tests/Proxy.Tests/Proxy.Tests.csproj`) with: + +```xml + + + + +``` + +- [ ] **Step 2: Verify the test project still builds** + +```bash +timeout 60 dotnet build tests/Proxy.Tests/Proxy.Tests.csproj +``` + +Expected: Build succeeds. (If it fails because Aspire SDK objects to being referenced from a non-Aspire project, fall back to extracting helpers into a new `src/Hosting/Hosting.csproj` class library and reference that from both AppHost and Proxy.Tests — adjust subsequent tasks accordingly.) + +- [ ] **Step 3: Write the failing test** + +Create `tests/Proxy.Tests/Hosting/ProxySlugTests.cs`: + +```csharp +using AppHost; + +namespace Proxy.Tests.Hosting; + +public class ProxySlugTests +{ + [Theory] + [InlineData("api")] + [InlineData("a")] + [InlineData("a1")] + [InlineData("api-v2")] + [InlineData("1abc")] + [InlineData("ab")] + public void IsValid_returns_true_for_valid_slug(string name) => + Assert.True(ProxySlug.IsValid(name)); + + [Fact] + public void IsValid_returns_true_for_max_length_slug() => + Assert.True(ProxySlug.IsValid("a" + new string('b', 30) + "c")); + + [Theory] + [InlineData("")] + [InlineData("Api")] + [InlineData("-api")] + [InlineData("api-")] + [InlineData("api_v2")] + [InlineData(" api ")] + public void IsValid_returns_false_for_invalid_slug(string name) => + Assert.False(ProxySlug.IsValid(name)); + + [Fact] + public void IsValid_returns_false_for_too_long_slug() => + Assert.False(ProxySlug.IsValid(new string('a', 33))); + + [Fact] + public void IsValid_returns_false_for_null() => + Assert.False(ProxySlug.IsValid(null)); +} +``` + +- [ ] **Step 4: Run test to verify it fails** + +```bash +timeout 120 dotnet test tests/Proxy.Tests/Proxy.Tests.csproj --filter "FullyQualifiedName~ProxySlugTests" +``` + +Expected: Build fails with "The type or namespace name 'ProxySlug' could not be found" (or "AppHost" namespace not found). + +- [ ] **Step 5: Write the implementation** + +Create `src/AppHost/ProxySlug.cs`: + +```csharp +using System.Text.RegularExpressions; + +namespace AppHost; + +public static partial class ProxySlug +{ + [GeneratedRegex("^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$")] + private static partial Regex Pattern(); + + public static bool IsValid(string? name) => name is not null && Pattern().IsMatch(name); +} +``` + +- [ ] **Step 6: Run test to verify it passes** + +```bash +timeout 120 dotnet test tests/Proxy.Tests/Proxy.Tests.csproj --filter "FullyQualifiedName~ProxySlugTests" +``` + +Expected: All `ProxySlugTests` pass (15 cases across the theories + facts). + +- [ ] **Step 7: Commit** + +```bash +git add src/AppHost/ProxySlug.cs tests/Proxy.Tests/Hosting/ProxySlugTests.cs tests/Proxy.Tests/Proxy.Tests.csproj +git commit -m "feat(apphost): add ProxySlug.IsValid helper for filename validation" +``` + +--- + +## Task 2: ProxyConfigFile.LoadAnonymousAccess helper (TDD) + +**Files:** +- Create: `src/AppHost/ProxyConfigFile.cs` +- Create: `tests/Proxy.Tests/Hosting/ProxyConfigFileTests.cs` + +- [ ] **Step 1: Write the failing test** + +Create `tests/Proxy.Tests/Hosting/ProxyConfigFileTests.cs`: + +```csharp +using AppHost; + +namespace Proxy.Tests.Hosting; + +public class ProxyConfigFileTests : IDisposable +{ + private readonly string _tempFile = Path.GetTempFileName(); + + public void Dispose() => File.Delete(_tempFile); + + [Fact] + public void LoadAnonymousAccess_returns_true_when_key_omitted() + { + File.WriteAllText(_tempFile, "{ \"DevTunnel\": {} }"); + Assert.True(ProxyConfigFile.LoadAnonymousAccess(_tempFile)); + } + + [Fact] + public void LoadAnonymousAccess_returns_true_when_explicit_true() + { + File.WriteAllText(_tempFile, "{ \"DevTunnel\": { \"AnonymousAccess\": true } }"); + Assert.True(ProxyConfigFile.LoadAnonymousAccess(_tempFile)); + } + + [Fact] + public void LoadAnonymousAccess_returns_false_when_explicit_false() + { + File.WriteAllText(_tempFile, "{ \"DevTunnel\": { \"AnonymousAccess\": false } }"); + Assert.False(ProxyConfigFile.LoadAnonymousAccess(_tempFile)); + } + + [Fact] + public void LoadAnonymousAccess_returns_true_when_devtunnel_section_absent() + { + File.WriteAllText(_tempFile, "{ }"); + Assert.True(ProxyConfigFile.LoadAnonymousAccess(_tempFile)); + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +timeout 120 dotnet test tests/Proxy.Tests/Proxy.Tests.csproj --filter "FullyQualifiedName~ProxyConfigFileTests" +``` + +Expected: Build fails with "The type or namespace name 'ProxyConfigFile' could not be found". + +- [ ] **Step 3: Write the implementation** + +Create `src/AppHost/ProxyConfigFile.cs`: + +```csharp +using Microsoft.Extensions.Configuration; + +namespace AppHost; + +public static class ProxyConfigFile +{ + public static bool LoadAnonymousAccess(string filePath) + { + var config = new ConfigurationBuilder() + .AddJsonFile(filePath, optional: false) + .Build(); + return config.GetValue("DevTunnel:AnonymousAccess", true); + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +timeout 120 dotnet test tests/Proxy.Tests/Proxy.Tests.csproj --filter "FullyQualifiedName~ProxyConfigFileTests" +``` + +Expected: All four `ProxyConfigFileTests` pass. + +- [ ] **Step 5: Commit** + +```bash +git add src/AppHost/ProxyConfigFile.cs tests/Proxy.Tests/Hosting/ProxyConfigFileTests.cs +git commit -m "feat(apphost): add ProxyConfigFile.LoadAnonymousAccess helper" +``` + +--- + +## Task 3: Move Proxy test baseline into ProxyAppFactory + +The Proxy's `appsettings.json` will lose its `Cors` and `ReverseProxy` sections in the next task. Tests still need that baseline to spin up a working proxy without `Proxy__ConfigFile`. Add it to `ProxyAppFactory`'s in-memory overrides first so the next change doesn't break tests. + +**Files:** +- Modify: `tests/Proxy.Tests/Integration/ProxyAppFactory.cs` + +- [ ] **Step 1: Add the baseline to the in-memory overrides** + +Replace the contents of `tests/Proxy.Tests/Integration/ProxyAppFactory.cs` with: + +```csharp +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.Extensions.Configuration; + +namespace Proxy.Tests.Integration; + +internal sealed class ProxyAppFactory : WebApplicationFactory +{ + private readonly string _upstreamUrl; + private readonly IDictionary _extraConfig; + + public ProxyAppFactory(string upstreamUrl, IDictionary? extraConfig = null) + { + _upstreamUrl = upstreamUrl; + _extraConfig = extraConfig ?? new Dictionary(); + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Test"); + builder.ConfigureAppConfiguration((_, config) => + { + var overrides = new Dictionary + { + ["Cors:Policies:AllowAll:AllowedOrigins:0"] = "*", + ["Cors:Policies:AllowAll:AllowedMethods:0"] = "*", + ["Cors:Policies:AllowAll:AllowedHeaders:0"] = "*", + ["Cors:Policies:AllowAll:AllowCredentials"] = "false", + ["ReverseProxy:Routes:default:ClusterId"] = "default", + ["ReverseProxy:Routes:default:CorsPolicy"] = "AllowAll", + ["ReverseProxy:Routes:default:Match:Path"] = "{**catch-all}", + ["ReverseProxy:Clusters:default:Destinations:primary:Address"] = _upstreamUrl, + }; + foreach (var kvp in _extraConfig) + { + overrides[kvp.Key] = kvp.Value; + } + config.AddInMemoryCollection(overrides); + }); + } +} +``` + +- [ ] **Step 2: Run all existing tests to confirm no behaviour change** + +```bash +timeout 180 dotnet test tests/Proxy.Tests/Proxy.Tests.csproj +``` + +Expected: All tests pass. The in-memory keys override-but-match what `appsettings.json` provides, so test behaviour is unchanged. + +- [ ] **Step 3: Commit** + +```bash +git add tests/Proxy.Tests/Integration/ProxyAppFactory.cs +git commit -m "refactor(tests): move proxy baseline into ProxyAppFactory" +``` + +--- + +## Task 4: Strip Proxy/appsettings.json down to Logging + AllowedHosts + +**Files:** +- Modify: `src/Proxy/appsettings.json` + +- [ ] **Step 1: Replace the file contents** + +Replace the entire contents of `src/Proxy/appsettings.json` with: + +```json +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} +``` + +- [ ] **Step 2: Run all tests** + +```bash +timeout 180 dotnet test tests/Proxy.Tests/Proxy.Tests.csproj +``` + +Expected: All tests pass. Tests now rely entirely on the in-memory baseline added in Task 3. + +- [ ] **Step 3: Commit** + +```bash +git add src/Proxy/appsettings.json +git commit -m "refactor(proxy): drop Cors and ReverseProxy from appsettings.json" +``` + +--- + +## Task 5: Add Proxy:ConfigFile loader to Program.cs + +**Files:** +- Modify: `src/Proxy/Program.cs` + +- [ ] **Step 1: Edit Program.cs** + +Replace the entire contents of `src/Proxy/Program.cs` with: + +```csharp +using Proxy.Cors; +using Proxy.Transforms; + +var builder = WebApplication.CreateBuilder(args); + +var configFile = builder.Configuration["Proxy:ConfigFile"]; +if (!string.IsNullOrEmpty(configFile)) +{ + builder.Configuration.AddJsonFile(configFile, optional: false, reloadOnChange: true); +} + +builder.Services.AddReverseProxy() + .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")) + .AddTransforms(); + +builder.Services.AddCors(options => + CorsPolicyConfigurator.Configure(options, builder.Configuration.GetSection("Cors:Policies"))); + +var app = builder.Build(); + +app.UseRouting(); +app.UseCors(); +app.MapReverseProxy(); +app.Run(); + +public partial class Program; +``` + +- [ ] **Step 2: Run all tests** + +```bash +timeout 180 dotnet test tests/Proxy.Tests/Proxy.Tests.csproj +``` + +Expected: All tests pass. Tests don't set `Proxy__ConfigFile`, so the new branch is skipped. + +- [ ] **Step 3: Commit** + +```bash +git add src/Proxy/Program.cs +git commit -m "feat(proxy): load YARP/CORS from Proxy:ConfigFile when set" +``` + +--- + +## Task 6: Create example.json + +**Files:** +- Create: `src/AppHost/proxies/example.json` + +- [ ] **Step 1: Create the proxies folder and example file** + +Create `src/AppHost/proxies/example.json` with: + +```json +{ + "DevTunnel": { + "AnonymousAccess": true + }, + "Cors": { + "Policies": { + "AllowAll": { + "AllowedOrigins": ["*"], + "AllowedMethods": ["*"], + "AllowedHeaders": ["*"], + "ExposedHeaders": [], + "AllowCredentials": false + } + } + }, + "ReverseProxy": { + "Routes": { + "default": { + "ClusterId": "default", + "CorsPolicy": "AllowAll", + "Match": { "Path": "{**catch-all}" } + } + }, + "Clusters": { + "default": { + "Destinations": { + "primary": { "Address": "https://example.com/" } + } + } + } + } +} +``` + +- [ ] **Step 2: Verify the AppHost project still builds** + +```bash +timeout 60 dotnet build src/AppHost/AppHost.csproj +``` + +Expected: Build succeeds. (No code changes yet, just a new JSON file under the project.) + +- [ ] **Step 3: Commit** + +```bash +git add src/AppHost/proxies/example.json +git commit -m "feat(apphost): add example proxy config (slug \"example\")" +``` + +--- + +## Task 7: Rewrite AppHost.cs and drop DevTunnel from AppHost/appsettings.json + +This is the biggest single change. After this task, `dotnet run --project src/AppHost` discovers `proxies/*.json` and spins up one Proxy + tunnel per file. + +**Files:** +- Modify: `src/AppHost/AppHost.cs` +- Modify: `src/AppHost/appsettings.json` + +- [ ] **Step 1: Replace AppHost.cs contents** + +Replace the entire contents of `src/AppHost/AppHost.cs` with: + +```csharp +using System.Diagnostics; +using AppHost; +using Microsoft.Extensions.Configuration; + +var builder = DistributedApplication.CreateBuilder(args); + +var directory = builder.Configuration["Proxies:Directory"] + ?? Path.Combine(builder.AppHostDirectory, "proxies"); + +var files = Directory.Exists(directory) + ? Directory.GetFiles(directory, "*.json").OrderBy(f => f, StringComparer.Ordinal).ToArray() + : []; + +if (files.Length == 0) +{ + throw new InvalidOperationException( + $"No proxy configurations found in '{directory}'. Add at least one .json file."); +} + +VerifyDevtunnelTokenCache(); + +foreach (var file in files) +{ + var name = Path.GetFileNameWithoutExtension(file); + if (!ProxySlug.IsValid(name)) + { + throw new InvalidOperationException( + $"Proxy config filename '{Path.GetFileName(file)}' is not a valid devtunnel slug " + + "(lowercase letters, digits, hyphens; 1-32 chars; must start and end alphanumeric)."); + } + + var anonymous = ProxyConfigFile.LoadAnonymousAccess(file); + + var proxy = builder.AddProject($"proxy-{name}") + .WithEnvironment("Proxy__ConfigFile", file); + + var tunnel = builder.AddDevTunnel($"tunnel-{name}", tunnelId: name) + .WithReference(proxy); + + if (anonymous) + { + tunnel.WithAnonymousAccess(); + } +} + +builder.Build().Run(); + +static void VerifyDevtunnelTokenCache() +{ + string output; + try + { + var psi = new ProcessStartInfo("devtunnel", "list") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + using var p = Process.Start(psi); + if (p is null) return; + output = p.StandardOutput.ReadToEnd() + p.StandardError.ReadToEnd(); + p.WaitForExit(5_000); + } + catch + { + return; + } + + if (!output.Contains("An item with the same key has already been added", StringComparison.Ordinal)) + { + return; + } + + throw new InvalidOperationException( + "The devtunnel CLI's tunnel-access-token cache is corrupt (duplicate 'host' key). " + + "Aspire's tunnel creation will fail. " + + "Fix on macOS: " + + "security delete-generic-password -s tunnels -a \"https://global.rel.tunnels.api.visualstudio.com/auth/tunnels\" " + + "(see README Troubleshooting for Linux/Windows)."); +} +``` + +- [ ] **Step 2: Replace AppHost/appsettings.json contents** + +Replace the entire contents of `src/AppHost/appsettings.json` with: + +```json +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Aspire.Hosting.Dcp": "Warning" + } + } +} +``` + +- [ ] **Step 3: Build the AppHost** + +```bash +timeout 60 dotnet build src/AppHost/AppHost.csproj +``` + +Expected: Build succeeds with no warnings about unused usings. + +- [ ] **Step 4: Run the full test suite** + +```bash +timeout 180 dotnet test tests/Proxy.Tests/Proxy.Tests.csproj +``` + +Expected: All tests pass. AppHost changes don't affect Proxy unit/integration tests. + +- [ ] **Step 5: Commit** + +```bash +git add src/AppHost/AppHost.cs src/AppHost/appsettings.json +git commit -m "feat(apphost): scan proxies/ folder to spin up one proxy+tunnel per file" +``` + +--- + +## Task 8: Manual smoke verification + +Aspire orchestration and devtunnel creation aren't covered by automated tests; run this checklist before merging. Each item is verified by observation, not by a passing assertion. + +> **Important:** If the run fails with a `devtunnel` cache error, follow the README's Troubleshooting steps to clear the OS credential cache, then retry — this is a pre-existing CLI bug unrelated to this change. + +- [ ] **Step 1: Fresh run with the shipped example pair** + +```bash +timeout 60 dotnet run --project src/AppHost +``` + +Expected: Aspire dashboard URL is printed; dashboard shows `proxy-example` (running, healthy) and `tunnel-example` (running, with a `https://*.devtunnels.ms` URL). Hitting that URL forwards to `https://example.com/`. Stop with Ctrl+C. + +- [ ] **Step 2: Add a second pair and verify both run** + +Create `src/AppHost/proxies/echo.json` with a different upstream: + +```json +{ + "DevTunnel": { "AnonymousAccess": true }, + "Cors": { + "Policies": { + "AllowAll": { + "AllowedOrigins": ["*"], + "AllowedMethods": ["*"], + "AllowedHeaders": ["*"], + "AllowCredentials": false + } + } + }, + "ReverseProxy": { + "Routes": { + "default": { + "ClusterId": "default", + "CorsPolicy": "AllowAll", + "Match": { "Path": "{**catch-all}" } + } + }, + "Clusters": { + "default": { + "Destinations": { + "primary": { "Address": "https://httpbin.org/" } + } + } + } + } +} +``` + +Run the AppHost again. Expected: dashboard shows both `proxy-example`/`tunnel-example` and `proxy-echo`/`tunnel-echo`. Each tunnel URL forwards to its own upstream. Stop, then delete `echo.json` before continuing. + +- [ ] **Step 3: Toggle private access on one pair** + +Edit `src/AppHost/proxies/example.json` and set `"DevTunnel": { "AnonymousAccess": false }`. Restart the AppHost. Expected: hitting the `tunnel-example` URL anonymously returns a sign-in page or 401 (per devtunnel's private mode). Revert the file before continuing. + +- [ ] **Step 4: YARP hot-reload still works inside one pair** + +With the AppHost running, edit `src/AppHost/proxies/example.json` and change the cluster destination to `https://duckduckgo.com/`. Save. Expected: within ~1s, requests to the tunnel URL forward to duckduckgo.com instead of example.com — no AppHost restart. Revert the file before continuing. + +- [ ] **Step 5: Bad filename rejected at startup** + +Create `src/AppHost/proxies/Bad_Slug.json` (uppercase + underscore). Run the AppHost. Expected: startup fails with `Proxy config filename 'Bad_Slug.json' is not a valid devtunnel slug ...`. Delete the file before continuing. + +- [ ] **Step 6: Empty proxies folder rejected at startup** + +Move `example.json` aside, leaving the folder empty. Run the AppHost. Expected: startup fails with `No proxy configurations found in ''. Add at least one .json file.`. Restore `example.json` before continuing. + +- [ ] **Step 7: No commit needed** + +This task is verification only. No code changes. + +--- + +## Task 9: Update README + +**Files:** +- Modify: `README.md` + +- [ ] **Step 1: Update the Quickstart section** + +Replace the `## Quickstart` section (lines 15-30 in current README) with: + +```markdown +## Quickstart + +```bash +git clone https://github.com/LorcanChinnock/devtunnel-proxy.git +cd devtunnel-proxy +dotnet run --project src/AppHost +``` + +First run opens a browser for `devtunnel` sign-in. Then: + +1. Open the Aspire dashboard URL printed in the terminal. +2. Click the `tunnel-example` resource — copy its `https://*.devtunnels.ms` URL. +3. Edit `src/AppHost/proxies/example.json` → set `ReverseProxy.Clusters.default.Destinations.primary.Address` to your upstream. YARP hot-reloads — no restart needed. +4. To publish under a different stable URL, rename the file (e.g. `mv example.json my-slug.json`) and restart. The filename is the slug. + +That's it. Anything hitting the tunnel URL is forwarded to your configured destination. +``` + +- [ ] **Step 2: Add an "Add another proxy" subsection** + +Insert this immediately after the Quickstart section: + +```markdown +### Add another proxy + +Drop a second JSON file in `src/AppHost/proxies/`: + +```bash +cp src/AppHost/proxies/example.json src/AppHost/proxies/api.json +# edit api.json — point Address at a different upstream +dotnet run --project src/AppHost +``` + +The dashboard now shows two pairs: `proxy-example` + `tunnel-example` and `proxy-api` + `tunnel-api`, each with its own public URL. Pairs are static — Ctrl+C and re-run to add or remove one. + +Filename rules: lowercase letters, digits, and hyphens only; 1–32 characters; must start and end alphanumeric. Bad filenames fail AppHost startup with a clear message. +``` + +- [ ] **Step 3: Update the Configuration reference table** + +Replace the table at `## Configuration reference` with: + +```markdown +| File | Purpose | +|---|---| +| `src/AppHost/proxies/.json` | One file per proxy: tunnel access mode, YARP routes/clusters, CORS policies | +| `src/AppHost/appsettings.json` | Logging only. Optional `Proxies:Directory` to relocate the proxies folder | +| `src/Proxy/appsettings.json` | Logging only — runtime YARP/CORS config comes from the per-pair file via `Proxy__ConfigFile` | +``` + +- [ ] **Step 4: Update the use-case JSON examples** + +Each `## Use cases` subsection currently shows a snippet labelled `// src/Proxy/appsettings.json` and edits relative to that file. Update each one to reference `// src/AppHost/proxies/.json` and show the snippet nested inside the per-pair shape (i.e. inside the `Cors` / `ReverseProxy` blocks, alongside `DevTunnel`). The snippets themselves stay identical — only the path comment and the surrounding context change. + +For example, the "Add CORS to an upstream" section snippet: + +```jsonc +// src/AppHost/proxies/api.json +{ + "DevTunnel": { "AnonymousAccess": true }, + "Cors": { + "Policies": { + "default": { + "AllowedOrigins": ["https://my-spa.example.com"], + "AllowedMethods": ["GET", "POST"], + "AllowedHeaders": ["*"], + "AllowCredentials": false + } + } + }, + "ReverseProxy": { + "Routes": { + "default": { + "ClusterId": "default", + "CorsPolicy": "default", + "Match": { "Path": "{**catch-all}" } + } + }, + "Clusters": { + "default": { + "Destinations": { + "primary": { "Address": "http://localhost:5050/" } + } + } + } + } +} +``` + +Apply the same pattern (wrap snippets in the full per-pair shape, change the path comment) to: "Receive webhooks from third-party services", "Inject auth headers before forwarding", and "Inject auth fields into JSON request bodies". + +- [ ] **Step 5: Update the Security section reference** + +Find the line `Set DevTunnel:AnonymousAccess: false in src/AppHost/appsettings.json` and replace `src/AppHost/appsettings.json` with `the per-pair file in src/AppHost/proxies/`. The rest of that section is unchanged. + +- [ ] **Step 6: Update the Project layout block** + +Replace the existing tree with: + +```markdown +src/ +├── AppHost/ +│ ├── proxies/ one .json file per public URL — drop in to add a pair +│ └── ... .NET Aspire app host wiring proxies to dev tunnels +└── Proxy/ ASP.NET Core + YARP — reads its slice via Proxy:ConfigFile +tests/ +└── Proxy.Tests/ xUnit v3 integration + unit tests +``` + +- [ ] **Step 7: Verify markdown renders correctly** + +```bash +timeout 30 grep -n "src/Proxy/appsettings.json" README.md || echo "no remaining references" +``` + +Expected: `no remaining references` (every old path has been updated). + +- [ ] **Step 8: Commit** + +```bash +git add README.md +git commit -m "docs: update README for config-driven multi-proxy layout" +``` + +--- + +## Done + +After Task 9 the branch is ready for PR. The full test suite covers helper logic and the integration boundary inside Proxy. The Aspire orchestration is verified by the manual checklist in Task 8. + +Suggested PR title (Conventional Commits): `feat: support multiple proxies and devtunnels via config files`. diff --git a/docs/superpowers/specs/2026-05-07-multiple-proxies-design.md b/docs/superpowers/specs/2026-05-07-multiple-proxies-design.md new file mode 100644 index 0000000..dbe15ec --- /dev/null +++ b/docs/superpowers/specs/2026-05-07-multiple-proxies-design.md @@ -0,0 +1,224 @@ +# Multiple proxies and devtunnels — design + +Date: 2026-05-07 +Branch: `feat/multi-proxy-support` +Status: Approved for planning + +## Goal + +Run any number of YARP proxy + devtunnel pairs from a single AppHost. Each pair has its own public URL, its own routes/clusters, its own CORS, its own access mode. Adding a pair is "drop a JSON file and restart". The current single-proxy behaviour is replaced, not layered on top. + +## Topology + +One Aspire AppHost orchestrates N pairs. Each pair is: + +- one `Projects.Proxy` instance on its own port (Aspire-assigned) +- one devtunnel forwarding to that proxy with a stable slug +- a private `IConfiguration` slice loaded from a single JSON file + +Pairs share nothing at runtime. Two pairs can pick the same internal cluster id without conflict — they live in separate processes. + +## Config layout + +``` +src/AppHost/proxies/ + example.json → Aspire: proxy-example + tunnel-example (slug "example") + api.json → Aspire: proxy-api + tunnel-api (slug "api") + webhooks.json → Aspire: proxy-webhooks + tunnel-webhooks (slug "webhooks") +``` + +The folder lives inside the AppHost project so it's resolved relative to `builder.AppHostDirectory` with no working-directory ambiguity. A user who wants the folder elsewhere sets `Proxies:Directory` in `src/AppHost/appsettings.json`. + +### Filename rules + +The filename (without extension) is the single source of truth: it becomes the Aspire resource id (`proxy-{name}`, `tunnel-{name}`) AND the public devtunnel slug. It must match: + +``` +^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$ +``` + +Lowercase letters, digits, hyphens; 1–32 characters; must start and end alphanumeric. This matches devtunnel's slug constraints. A bad filename fails AppHost startup with a message naming the offending file. + +Slug uniqueness is automatic — filenames in one folder can't collide. + +### Per-pair file schema + +```jsonc +// src/AppHost/proxies/api.json → slug "api" +{ + "DevTunnel": { + "AnonymousAccess": true // optional, default true + }, + "Cors": { + "Policies": { + "AllowAll": { + "AllowedOrigins": ["*"], + "AllowedMethods": ["*"], + "AllowedHeaders": ["*"], + "AllowCredentials": false + } + } + }, + "ReverseProxy": { + "Routes": { + "default": { + "ClusterId": "default", + "CorsPolicy": "AllowAll", + "Match": { "Path": "{**catch-all}" } + } + }, + "Clusters": { + "default": { + "Destinations": { + "primary": { "Address": "http://localhost:5050/" } + } + } + } + } +} +``` + +- `DevTunnel:AnonymousAccess` is the only key the AppHost reads. Default `true` matches today's behaviour. +- Everything else is the YARP and CORS shape the existing Proxy code already understands. No new schema invented; no .NET config classes needed. +- A JSON parse error surfaces at AppHost startup with the offending filename. + +## AppHost behaviour + +`src/AppHost/AppHost.cs` replaces the single-tunnel block with a folder scan: + +```csharp +var builder = DistributedApplication.CreateBuilder(args); + +var directory = builder.Configuration["Proxies:Directory"] + ?? Path.Combine(builder.AppHostDirectory, "proxies"); + +var files = Directory.Exists(directory) + ? Directory.GetFiles(directory, "*.json").OrderBy(f => f).ToArray() + : []; + +if (files.Length == 0) +{ + throw new InvalidOperationException( + $"No proxy configurations found in '{directory}'. " + + "Add at least one .json file."); +} + +VerifyDevtunnelTokenCache(); // refactored: no slug arg + +foreach (var file in files) +{ + var name = Path.GetFileNameWithoutExtension(file); + if (!ProxySlug.IsValid(name)) + { + throw new InvalidOperationException( + $"Proxy config filename '{Path.GetFileName(file)}' is not a valid devtunnel slug " + + "(lowercase letters, digits, hyphens; 1–32 chars; must start and end alphanumeric)."); + } + + var pairConfig = new ConfigurationBuilder().AddJsonFile(file, optional: false).Build(); + var anonymous = pairConfig.GetValue("DevTunnel:AnonymousAccess", true); + + var proxy = builder.AddProject($"proxy-{name}") + .WithEnvironment("Proxy__ConfigFile", file); + + var tunnel = builder.AddDevTunnel($"tunnel-{name}", tunnelId: name) + .WithReference(proxy); + + if (anonymous) tunnel.WithAnonymousAccess(); +} + +builder.Build().Run(); +``` + +Notes: + +- Files iterated in alphabetical order so the resource graph is deterministic across runs. +- `Proxy__ConfigFile` carries the absolute path to that pair's JSON. Aspire injects it as an env var, surfacing in `IConfiguration` as `Proxy:ConfigFile`. +- Empty folder → throw. Silent no-op would hide misconfiguration. +- `ProxySlug.IsValid` and the file-loading helper move to a small static class (`ProxySlug`, `ProxyConfigFile`) so they're unit-testable without spinning up Aspire. + +### Pre-flight cache check + +The current check runs `devtunnel show ` and looks for the corrupt-cache error string. With N pairs there's no single id, but the cache bug is **global per OS** — running the check once is enough. Refactor to call `devtunnel list` (or any tunnel-listing command), which deserialises the same cache and produces the same error when corrupt. Run once at AppHost startup, before the resource loop. Error message and remediation steps unchanged. + +## Proxy changes + +`src/Proxy/Program.cs` adds three lines: + +```csharp +var builder = WebApplication.CreateBuilder(args); + +var configFile = builder.Configuration["Proxy:ConfigFile"]; +if (!string.IsNullOrEmpty(configFile)) +{ + builder.Configuration.AddJsonFile(configFile, optional: false, reloadOnChange: true); +} + +builder.Services.AddReverseProxy() + .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")) + .AddTransforms(); + +builder.Services.AddCors(options => + CorsPolicyConfigurator.Configure(options, builder.Configuration.GetSection("Cors:Policies"))); +// ...rest unchanged +``` + +Why this shape: + +- One env var, absolute path: no CLI args, no working-directory ambiguity. +- `reloadOnChange: true` keeps YARP's per-route hot-reload promise from the README — edits to a pair's file apply live without restart. (Slug and access mode still need a restart; that's an AppHost-level concern.) +- The `if (!string.IsNullOrEmpty(...))` guard exists only so tests can run `Program` via `WebApplicationFactory` without an AppHost. Production always has the env var set. +- No new C# config class — `Proxy:ConfigFile` is a single string read once at startup. + +## Migration + +Concrete edits: + +| File | Change | +|---|---| +| `src/AppHost/AppHost.cs` | Rewritten to scan folder + loop, as above. | +| `src/AppHost/appsettings.json` | Drop `DevTunnel` block. Keep `Logging`. Optional `Proxies:Directory` documented but not present by default. | +| `src/AppHost/proxies/example.json` | **New.** Today's quickstart values: anonymous, AllowAll CORS, single catch-all route to `https://example.com/`. Acts as the smoke-test pair on a fresh clone. | +| `src/Proxy/appsettings.json` | Drop `Cors` and `ReverseProxy`. Keep `Logging` and `AllowedHosts`. | +| `src/Proxy/Program.cs` | Three lines added (the `Proxy:ConfigFile` block above). | +| `tests/Proxy.Tests/Integration/ProxyAppFactory.cs` | Add baseline `Cors:Policies:AllowAll` and `ReverseProxy:Routes:default`/`Clusters:default` to the in-memory overrides so existing tests still get a working proxy without `Proxy__ConfigFile`. | +| `README.md` | Quickstart and use-case sections repointed at `src/AppHost/proxies/.json`. New "Add another proxy" subsection: drop a file, restart, get a second URL. | + +No backwards-compatibility shim — the user chose a clean cut. Existing single-proxy users follow the migration in the README on upgrade. + +## Testing + +### Unit tests (new) + +Folder: `tests/Proxy.Tests/AppHost/`. These exist because `IsValidSlug` and `LoadAnonymousAccess` are pulled out of `Program.cs` into `ProxySlug` / `ProxyConfigFile` static classes — pure functions, no I/O at the test seam. + +- `ProxySlug.IsValid` accepts `api`, `api-v2`, `a`, `a1b2`, 32-char strings. +- `ProxySlug.IsValid` rejects empty, `Api`, `-api`, `api-`, `api_v2`, 33-char strings. +- `ProxyConfigFile.LoadAnonymousAccess` returns `true` when the key is omitted, `true` when explicitly `true`, `false` when explicitly `false`. + +To make this work the AppHost project exposes `InternalsVisibleTo("Proxy.Tests")`, mirroring the Proxy project's setup. + +### Integration tests (existing) + +Behaviour unchanged. Only `ProxyAppFactory.ConfigureWebHost` changes — its in-memory override dictionary gains the CORS + ReverseProxy baseline that used to come from `src/Proxy/appsettings.json`. Per-test `extraConfig` continues to layer on top. + +### Manual verification (run before merge) + +1. Fresh clone, `dotnet run --project src/AppHost` → dashboard shows `proxy-example` + `tunnel-example`. Hitting the tunnel URL forwards to `example.com`. +2. Drop `src/AppHost/proxies/api.json` (different slug, different upstream) → restart → both pairs in dashboard, both URLs reachable, each forwarding to its own upstream. +3. Set `"AnonymousAccess": false` on one pair, restart → that tunnel requires sign-in, the other stays public. +4. Edit a route inside one pair's file while running → YARP picks it up live (no restart), the other pair untouched. +5. Bad filename (`Api.json`, `api_v2.json`) → startup fails with a clear message naming the file. +6. Empty `proxies/` folder → startup fails with "No proxy configurations found in ''". + +### Out of scope + +- Aspire `DistributedApplicationTesting` harness — heavy, low return for this change. Flag as a follow-up if AppHost churn grows. +- Hot add/remove of pairs at runtime — explicitly rejected during brainstorming. Static at AppHost startup; Ctrl+C and re-run to add/remove a pair. + +## Risks and follow-ups + +- **devtunnel rate limits**: each pair holds its own tunnel. Users with many pairs may hit account-level tunnel limits. Document in README; out of scope to mitigate. +- **Port pressure**: each Proxy instance binds two ports (HTTP + HTTPS via Aspire). Should be fine for double-digit pair counts on a dev machine. +- **Pre-flight check refactor** uses `devtunnel list`. If a future CLI version stops triggering the cache deserialise on `list`, the check silently passes when it should fail. Low likelihood; if it happens we revisit. +- **Transformations stay per-pair**. The `InjectJsonField` transform is configured via per-route metadata inside the pair's file, no sharing across pairs. Matches today's behaviour. diff --git a/src/AppHost/AppHost.cs b/src/AppHost/AppHost.cs index 48d6377..5e1b731 100644 --- a/src/AppHost/AppHost.cs +++ b/src/AppHost/AppHost.cs @@ -1,57 +1,87 @@ using System.Diagnostics; using Microsoft.Extensions.Configuration; -var builder = DistributedApplication.CreateBuilder(args); +namespace AppHost; -var tunnelId = builder.Configuration["DevTunnel:Id"] - ?? throw new InvalidOperationException("DevTunnel:Id is required in appsettings.json."); +public static class Program +{ + public static void Main(string[] args) + { + var builder = DistributedApplication.CreateBuilder(args); -var anonymousAccess = builder.Configuration.GetValue("DevTunnel:AnonymousAccess", true); + var directory = builder.Configuration["Proxies:Directory"] + ?? Path.Combine(builder.AppHostDirectory, "proxies"); -VerifyDevtunnelTokenCache(tunnelId); + var files = Directory.Exists(directory) + ? Directory.GetFiles(directory, "*.json").OrderBy(f => f, StringComparer.Ordinal).ToArray() + : []; -var proxy = builder.AddProject("proxy"); + if (files.Length == 0) + { + throw new InvalidOperationException( + $"No proxy configurations found in '{directory}'. Add at least one .json file."); + } -var tunnel = builder.AddDevTunnel("tunnel", tunnelId: tunnelId) - .WithReference(proxy); + VerifyDevtunnelTokenCache(); -if (anonymousAccess) -{ - tunnel.WithAnonymousAccess(); -} + foreach (var file in files) + { + var name = Path.GetFileNameWithoutExtension(file); + if (!ProxySlug.IsValid(name)) + { + throw new InvalidOperationException( + $"Proxy config filename '{Path.GetFileName(file)}' is not a valid devtunnel slug " + + "(lowercase letters, digits, hyphens; 1-32 chars; must start and end alphanumeric)."); + } -builder.Build().Run(); + var anonymous = ProxyConfigFile.LoadAnonymousAccess(file); -static void VerifyDevtunnelTokenCache(string tunnelId) -{ - string output; - try - { - var psi = new ProcessStartInfo("devtunnel", $"show {tunnelId}") - { - RedirectStandardOutput = true, - RedirectStandardError = true, - UseShellExecute = false, - }; - using var p = Process.Start(psi); - if (p is null) return; - output = p.StandardOutput.ReadToEnd() + p.StandardError.ReadToEnd(); - p.WaitForExit(5_000); - } - catch - { - return; + var proxy = builder.AddProject($"proxy-{name}") + .WithEnvironment("Proxy__ConfigFile", file); + + var tunnel = builder.AddDevTunnel($"tunnel-{name}", tunnelId: name) + .WithReference(proxy); + + if (anonymous) + { + tunnel.WithAnonymousAccess(); + } + } + + builder.Build().Run(); } - if (!output.Contains("An item with the same key has already been added", StringComparison.Ordinal)) + private static void VerifyDevtunnelTokenCache() { - return; - } + string output; + try + { + var psi = new ProcessStartInfo("devtunnel", "list") + { + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + using var p = Process.Start(psi); + if (p is null) return; + output = p.StandardOutput.ReadToEnd() + p.StandardError.ReadToEnd(); + p.WaitForExit(5_000); + } + catch + { + return; + } - throw new InvalidOperationException( - "The devtunnel CLI's tunnel-access-token cache is corrupt (duplicate 'host' key). " + - "Aspire's tunnel creation will fail. " + - "Fix on macOS: " + - "security delete-generic-password -s tunnels -a \"https://global.rel.tunnels.api.visualstudio.com/auth/tunnels\" " + - "(see README Troubleshooting for Linux/Windows)."); + if (!output.Contains("An item with the same key has already been added", StringComparison.Ordinal)) + { + return; + } + + throw new InvalidOperationException( + "The devtunnel CLI's tunnel-access-token cache is corrupt (duplicate 'host' key). " + + "Aspire's tunnel creation will fail. " + + "Fix on macOS: " + + "security delete-generic-password -s tunnels -a \"https://global.rel.tunnels.api.visualstudio.com/auth/tunnels\" " + + "(see README Troubleshooting for Linux/Windows)."); + } } diff --git a/src/AppHost/AppHost.csproj b/src/AppHost/AppHost.csproj index e761b64..893c607 100644 --- a/src/AppHost/AppHost.csproj +++ b/src/AppHost/AppHost.csproj @@ -9,7 +9,7 @@ - + diff --git a/src/AppHost/Properties/launchSettings.json b/src/AppHost/Properties/launchSettings.json index 1ed9d11..42d60e0 100644 --- a/src/AppHost/Properties/launchSettings.json +++ b/src/AppHost/Properties/launchSettings.json @@ -4,7 +4,7 @@ "https": { "commandName": "Project", "dotnetRunMessages": true, - "applicationUrl": "https://localhost:17002;http://localhost:15028", + "applicationUrl": "https://localhost:17002", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development", "DOTNET_ENVIRONMENT": "Development", @@ -12,18 +12,6 @@ "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "https://localhost:23289", "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "https://localhost:22021" } - }, - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "applicationUrl": "http://localhost:15028", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development", - "DOTNET_ENVIRONMENT": "Development", - "ASPIRE_DASHBOARD_OTLP_ENDPOINT_URL": "http://localhost:19086", - "ASPIRE_DASHBOARD_MCP_ENDPOINT_URL": "http://localhost:18133", - "ASPIRE_RESOURCE_SERVICE_ENDPOINT_URL": "http://localhost:20015" - } } } } diff --git a/src/AppHost/ProxyConfigFile.cs b/src/AppHost/ProxyConfigFile.cs new file mode 100644 index 0000000..c773f07 --- /dev/null +++ b/src/AppHost/ProxyConfigFile.cs @@ -0,0 +1,14 @@ +using Microsoft.Extensions.Configuration; + +namespace AppHost; + +public static class ProxyConfigFile +{ + public static bool LoadAnonymousAccess(string filePath) + { + var config = new ConfigurationBuilder() + .AddJsonFile(filePath, optional: false) + .Build(); + return config.GetValue("DevTunnel:AnonymousAccess", true); + } +} diff --git a/src/AppHost/ProxySlug.cs b/src/AppHost/ProxySlug.cs new file mode 100644 index 0000000..5dc66af --- /dev/null +++ b/src/AppHost/ProxySlug.cs @@ -0,0 +1,11 @@ +using System.Text.RegularExpressions; + +namespace AppHost; + +public static partial class ProxySlug +{ + [GeneratedRegex("^[a-z0-9](?:[a-z0-9-]{0,30}[a-z0-9])?$")] + private static partial Regex Pattern(); + + public static bool IsValid(string? name) => name is not null && Pattern().IsMatch(name); +} diff --git a/src/AppHost/appsettings.json b/src/AppHost/appsettings.json index d689c0b..31c092a 100644 --- a/src/AppHost/appsettings.json +++ b/src/AppHost/appsettings.json @@ -5,9 +5,5 @@ "Microsoft.AspNetCore": "Warning", "Aspire.Hosting.Dcp": "Warning" } - }, - "DevTunnel": { - "Id": "devtunnel-proxy", - "AnonymousAccess": true } } diff --git a/src/AppHost/proxies/example-1.json b/src/AppHost/proxies/example-1.json new file mode 100644 index 0000000..32c854e --- /dev/null +++ b/src/AppHost/proxies/example-1.json @@ -0,0 +1,32 @@ +{ + "DevTunnel": { + "AnonymousAccess": true + }, + "Cors": { + "Policies": { + "AllowAll": { + "AllowedOrigins": ["*"], + "AllowedMethods": ["*"], + "AllowedHeaders": ["*"], + "ExposedHeaders": [], + "AllowCredentials": false + } + } + }, + "ReverseProxy": { + "Routes": { + "default": { + "ClusterId": "default", + "CorsPolicy": "AllowAll", + "Match": { "Path": "{**catch-all}" } + } + }, + "Clusters": { + "default": { + "Destinations": { + "primary": { "Address": "https://google.com/" } + } + } + } + } +} diff --git a/src/AppHost/proxies/example-2.json b/src/AppHost/proxies/example-2.json new file mode 100644 index 0000000..1023c71 --- /dev/null +++ b/src/AppHost/proxies/example-2.json @@ -0,0 +1,32 @@ +{ + "DevTunnel": { + "AnonymousAccess": true + }, + "Cors": { + "Policies": { + "AllowAll": { + "AllowedOrigins": ["*"], + "AllowedMethods": ["*"], + "AllowedHeaders": ["*"], + "ExposedHeaders": [], + "AllowCredentials": false + } + } + }, + "ReverseProxy": { + "Routes": { + "default": { + "ClusterId": "default", + "CorsPolicy": "AllowAll", + "Match": { "Path": "{**catch-all}" } + } + }, + "Clusters": { + "default": { + "Destinations": { + "primary": { "Address": "https://bing.com/" } + } + } + } + } +} diff --git a/src/Proxy/Program.cs b/src/Proxy/Program.cs index 8b4b594..bd699b7 100644 --- a/src/Proxy/Program.cs +++ b/src/Proxy/Program.cs @@ -3,6 +3,12 @@ var builder = WebApplication.CreateBuilder(args); +var configFile = builder.Configuration["Proxy:ConfigFile"]; +if (!string.IsNullOrEmpty(configFile)) +{ + builder.Configuration.AddJsonFile(configFile, optional: false, reloadOnChange: true); +} + builder.Services.AddReverseProxy() .LoadFromConfig(builder.Configuration.GetSection("ReverseProxy")) .AddTransforms(); diff --git a/src/Proxy/Properties/launchSettings.json b/src/Proxy/Properties/launchSettings.json index 6798214..0ea8d4d 100644 --- a/src/Proxy/Properties/launchSettings.json +++ b/src/Proxy/Properties/launchSettings.json @@ -1,18 +1,10 @@ { "$schema": "https://json.schemastore.org/launchsettings.json", "profiles": { - "http": { - "commandName": "Project", - "dotnetRunMessages": true, - "applicationUrl": "http://localhost:5098", - "environmentVariables": { - "ASPNETCORE_ENVIRONMENT": "Development" - } - }, "https": { "commandName": "Project", "dotnetRunMessages": true, - "applicationUrl": "https://localhost:7136;http://localhost:5098", + "applicationUrl": "https://localhost:7136", "environmentVariables": { "ASPNETCORE_ENVIRONMENT": "Development" } diff --git a/src/Proxy/appsettings.json b/src/Proxy/appsettings.json index c3e277a..10f68b8 100644 --- a/src/Proxy/appsettings.json +++ b/src/Proxy/appsettings.json @@ -5,32 +5,5 @@ "Microsoft.AspNetCore": "Warning" } }, - "AllowedHosts": "*", - "Cors": { - "Policies": { - "AllowAll": { - "AllowedOrigins": ["*"], - "AllowedMethods": ["*"], - "AllowedHeaders": ["*"], - "ExposedHeaders": [], - "AllowCredentials": false - } - } - }, - "ReverseProxy": { - "Routes": { - "default": { - "ClusterId": "default", - "CorsPolicy": "AllowAll", - "Match": { "Path": "{**catch-all}" } - } - }, - "Clusters": { - "default": { - "Destinations": { - "primary": { "Address": "https://example.com/" } - } - } - } - } + "AllowedHosts": "*" } diff --git a/tests/Proxy.Tests/Hosting/ProxyConfigFileTests.cs b/tests/Proxy.Tests/Hosting/ProxyConfigFileTests.cs new file mode 100644 index 0000000..58212c8 --- /dev/null +++ b/tests/Proxy.Tests/Hosting/ProxyConfigFileTests.cs @@ -0,0 +1,38 @@ +using AppHost; + +namespace Proxy.Tests.Hosting; + +public class ProxyConfigFileTests : IDisposable +{ + private readonly string _tempFile = Path.GetTempFileName(); + + public void Dispose() => File.Delete(_tempFile); + + [Fact] + public void LoadAnonymousAccess_returns_true_when_key_omitted() + { + File.WriteAllText(_tempFile, "{ \"DevTunnel\": {} }"); + Assert.True(ProxyConfigFile.LoadAnonymousAccess(_tempFile)); + } + + [Fact] + public void LoadAnonymousAccess_returns_true_when_explicit_true() + { + File.WriteAllText(_tempFile, "{ \"DevTunnel\": { \"AnonymousAccess\": true } }"); + Assert.True(ProxyConfigFile.LoadAnonymousAccess(_tempFile)); + } + + [Fact] + public void LoadAnonymousAccess_returns_false_when_explicit_false() + { + File.WriteAllText(_tempFile, "{ \"DevTunnel\": { \"AnonymousAccess\": false } }"); + Assert.False(ProxyConfigFile.LoadAnonymousAccess(_tempFile)); + } + + [Fact] + public void LoadAnonymousAccess_returns_true_when_devtunnel_section_absent() + { + File.WriteAllText(_tempFile, "{ }"); + Assert.True(ProxyConfigFile.LoadAnonymousAccess(_tempFile)); + } +} diff --git a/tests/Proxy.Tests/Hosting/ProxySlugTests.cs b/tests/Proxy.Tests/Hosting/ProxySlugTests.cs new file mode 100644 index 0000000..092f024 --- /dev/null +++ b/tests/Proxy.Tests/Hosting/ProxySlugTests.cs @@ -0,0 +1,38 @@ +using AppHost; + +namespace Proxy.Tests.Hosting; + +public class ProxySlugTests +{ + [Theory] + [InlineData("api")] + [InlineData("a")] + [InlineData("a1")] + [InlineData("api-v2")] + [InlineData("1abc")] + [InlineData("ab")] + public void IsValid_returns_true_for_valid_slug(string name) => + Assert.True(ProxySlug.IsValid(name)); + + [Fact] + public void IsValid_returns_true_for_max_length_slug() => + Assert.True(ProxySlug.IsValid("a" + new string('b', 30) + "c")); + + [Theory] + [InlineData("")] + [InlineData("Api")] + [InlineData("-api")] + [InlineData("api-")] + [InlineData("api_v2")] + [InlineData(" api ")] + public void IsValid_returns_false_for_invalid_slug(string name) => + Assert.False(ProxySlug.IsValid(name)); + + [Fact] + public void IsValid_returns_false_for_too_long_slug() => + Assert.False(ProxySlug.IsValid(new string('a', 33))); + + [Fact] + public void IsValid_returns_false_for_null() => + Assert.False(ProxySlug.IsValid(null)); +} diff --git a/tests/Proxy.Tests/Integration/ProxyAppFactory.cs b/tests/Proxy.Tests/Integration/ProxyAppFactory.cs index 2f1b19e..5287142 100644 --- a/tests/Proxy.Tests/Integration/ProxyAppFactory.cs +++ b/tests/Proxy.Tests/Integration/ProxyAppFactory.cs @@ -22,6 +22,13 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) { var overrides = new Dictionary { + ["Cors:Policies:AllowAll:AllowedOrigins:0"] = "*", + ["Cors:Policies:AllowAll:AllowedMethods:0"] = "*", + ["Cors:Policies:AllowAll:AllowedHeaders:0"] = "*", + ["Cors:Policies:AllowAll:AllowCredentials"] = "false", + ["ReverseProxy:Routes:default:ClusterId"] = "default", + ["ReverseProxy:Routes:default:CorsPolicy"] = "AllowAll", + ["ReverseProxy:Routes:default:Match:Path"] = "{**catch-all}", ["ReverseProxy:Clusters:default:Destinations:primary:Address"] = _upstreamUrl, }; foreach (var kvp in _extraConfig) diff --git a/tests/Proxy.Tests/Proxy.Tests.csproj b/tests/Proxy.Tests/Proxy.Tests.csproj index 080f0fe..d744e49 100644 --- a/tests/Proxy.Tests/Proxy.Tests.csproj +++ b/tests/Proxy.Tests/Proxy.Tests.csproj @@ -20,6 +20,7 @@ +