diff --git a/src/main/java/io/floci/az/core/AzureRoutingFilter.java b/src/main/java/io/floci/az/core/AzureRoutingFilter.java index d516e1d8..a2cd2ae2 100644 --- a/src/main/java/io/floci/az/core/AzureRoutingFilter.java +++ b/src/main/java/io/floci/az/core/AzureRoutingFilter.java @@ -18,6 +18,7 @@ import java.util.ArrayList; import java.util.Comparator; import java.util.HashMap; +import java.util.Locale; import java.util.List; import java.util.Map; import java.util.Optional; @@ -54,6 +55,7 @@ public AzureRoutingFilter(AuthPipeline authPipeline, AzureServiceRegistry servic this.vertx = vertx; this.stages = List.of( this::routeByHostSuffix, + this::routeByHostServiceMarker, this::routeImds, this::routeEntra, this::routeArmMetadataEndpoints, @@ -146,6 +148,14 @@ record ResolvedProvider(String marker, String serviceType, Predicate gua /** Host-suffix → serviceType. Suffixes are mutually exclusive; DFS maps to blob. */ private volatile List hostRoutes = List.of(); + /** + * Service marker → serviceType, derived from each host suffix's first label ({@code + * .blob.core.windows.net → blob}). Lets {@code {account}.{marker}.{any-host}} route host-style on + * emulator hostnames ({@code devstoreaccount1.blob.localhost}, {@code devstoreaccount1.blob.floci-az}) + * exactly as it would on the production suffix. + */ + private volatile Map hostServiceMarkers = Map.of(); + /** * Account-name suffix → serviceType (e.g. {@code {account}-queue → queue}), sorted longest-first. * Any suffix that is a string-suffix of another is always shorter, so length-descending gives the @@ -201,6 +211,7 @@ void buildRoutingTables() { this.hostRoutes = List.copyOf(hosts); this.accountSuffixRoutes = List.copyOf(accounts); this.providerRoutes = List.copyOf(providers); + this.hostServiceMarkers = deriveHostServiceMarkers(hosts); LOGGER.infof("Routing tables: %d host, %d account-suffix, %d ARM provider routes", hostRoutes.size(), accountSuffixRoutes.size(), providerRoutes.size()); @@ -240,6 +251,28 @@ static void rejectDuplicateProviders(List providers) { } } + /** + * Derives the service-marker table from the registered host suffixes: the first label of each + * suffix names the service ({@code .blob.core.windows.net → blob}, {@code .dfs.… → blob}). Two + * suffixes producing the same marker for different service types would make + * {@code {account}.{marker}.{host}} ambiguous — fail fast like the other duplicate checks. + */ + static Map deriveHostServiceMarkers(List hostRoutes) { + Map markers = new HashMap<>(); + for (SuffixRoute route : hostRoutes) { + String suffix = route.suffix(); + int start = suffix.startsWith(".") ? 1 : 0; + int end = suffix.indexOf('.', start); + String marker = end < 0 ? suffix.substring(start) : suffix.substring(start, end); + String previous = markers.putIfAbsent(marker, route.serviceType()); + if (previous != null && !previous.equals(route.serviceType())) { + throw new IllegalStateException("Two service types claim the host service marker '" + + marker + "': " + previous + " and " + route.serviceType()); + } + } + return Map.copyOf(markers); + } + /** Returns the route whose suffix {@code value} ends with, or {@code null} if none matches. */ private static SuffixRoute matchSuffix(List routes, String value) { for (SuffixRoute route : routes) { @@ -336,12 +369,17 @@ private static boolean isEmulatorAdminPath(String path) { || path.startsWith("_floci/") || path.startsWith("_admin"); } + /** + * Strips the port and lowercases: hostnames are case-insensitive (RFC 4343), so every host + * comparison — production suffixes and service markers alike — happens on the lowercase form. + */ private static String hostWithoutPort(String capturedHost) { if (capturedHost == null) { return null; } int colon = capturedHost.indexOf(':'); - return colon < 0 ? capturedHost : capturedHost.substring(0, colon); + String host = colon < 0 ? capturedHost : capturedHost.substring(0, colon); + return host.toLowerCase(Locale.ROOT); } // ── Routing stages, in chain order ────────────────────────────────────────── @@ -363,6 +401,37 @@ private Outcome routeByHostSuffix(RoutingContext ctx) { return dispatchOrServiceDisabled(ctx, stripSuffix(ctx.host(), route), route.serviceType(), ctx.path()); } + /** + * Host-style (production-style) addressing on arbitrary emulator hostnames: + * {@code {account}.{service}.{rest…}} — e.g. {@code devstoreaccount1.blob.localhost} or + * {@code devstoreaccount1.blob.floci-az} — routes exactly like the production suffix + * {@code {account}.{service}.core.windows.net}. Real Azure and Azurite both address storage this + * way; without this stage a host-style request has its container consumed as the account and + * Create Container answers 501, which blocks the Functions host's AzureWebJobsStorage bootstrap + * (#267). Path-style callers are unaffected: their hosts carry no service marker as the second + * label, so they fall through to the account-suffix terminal unchanged. + */ + private Outcome routeByHostServiceMarker(RoutingContext ctx) { + String host = ctx.host(); + if (host == null) { + return Fallthrough.TO_NEXT_STAGE; + } + int firstDot = host.indexOf('.'); + if (firstDot <= 0) { + return Fallthrough.TO_NEXT_STAGE; + } + int secondDot = host.indexOf('.', firstDot + 1); + if (secondDot < 0 || secondDot == host.length() - 1) { + return Fallthrough.TO_NEXT_STAGE; // need {account}.{marker}.{at least one more label} + } + String serviceType = hostServiceMarkers.get(host.substring(firstDot + 1, secondDot)); + if (serviceType == null) { + return Fallthrough.TO_NEXT_STAGE; + } + String account = host.substring(0, firstDot); + return dispatchOrServiceDisabled(ctx, account, serviceType, ctx.path()); + } + /** * IMDS (Instance Metadata Service) — managed identity token endpoint: * {@code metadata/identity/oauth2/token?resource=...} (header {@code Metadata: true}). diff --git a/src/main/java/io/floci/az/services/table/TableServiceHandler.java b/src/main/java/io/floci/az/services/table/TableServiceHandler.java index 9588ecaf..eb9cb7a6 100644 --- a/src/main/java/io/floci/az/services/table/TableServiceHandler.java +++ b/src/main/java/io/floci/az/services/table/TableServiceHandler.java @@ -66,6 +66,7 @@ public boolean enabled(String serviceType) { public ServiceRoutes routes() { return ServiceRoutes.builder() + .host(".table.core.windows.net") .account("-table", "table") .build(); diff --git a/src/test/java/io/floci/az/core/AzureRoutingFilterTest.java b/src/test/java/io/floci/az/core/AzureRoutingFilterTest.java index 86aa5127..7ec0da4a 100644 --- a/src/test/java/io/floci/az/core/AzureRoutingFilterTest.java +++ b/src/test/java/io/floci/az/core/AzureRoutingFilterTest.java @@ -226,4 +226,88 @@ void managedIdentityNestedProviderFallsThroughToArm() { + "11111111-1111-1111-1111-111111111111?api-version=2022-04-01") .then().statusCode(404); } + + // ── Host-style addressing on emulator hostnames (#267) ────────────────────── + // + // Real Azure and Azurite address storage host-style: the account is the first host label and the + // container is the first path segment ({account}.{service}.{host}). Without these routes, a + // host-style request has its container consumed as the account and Create Container answers 501 — + // which blocks the Azure Functions host's AzureWebJobsStorage bootstrap. + + @Test + void hostStyleBlobCreateContainerOnEmulatorHost() { + // The exact #267 reproduction: PUT /awjh?restype=container, Host: devstoreaccount1.blob.localhost. + given().header("Host", "devstoreaccount1.blob.localhost:4577") + .queryParam("restype", "container") + .when().put("/hoststyle-awjh") + .then().statusCode(201); + } + + @Test + void hostStyleAndPathStyleShareTheAccountNamespace() { + // A blob written host-style (docker-alias-shaped hostname) must be readable path-style: + // both addressings resolve to the same account. + given().header("Host", "devstoreaccount1.blob.floci-az") + .queryParam("restype", "container") + .when().put("/hoststyle-shared") + .then().statusCode(201); + given().header("Host", "devstoreaccount1.blob.floci-az") + .header("x-ms-blob-type", "BlockBlob").body("host-style payload") + .when().put("/hoststyle-shared/blob1") + .then().statusCode(201); + + given().when().get("/devstoreaccount1/hoststyle-shared/blob1") + .then().statusCode(200) + .body(containsString("host-style payload")); + } + + @Test + void hostStyleQueueListOnEmulatorHost() { + given().header("Host", "devstoreaccount1.queue.localhost:4577") + .when().get("/?comp=list") + .then().statusCode(200) + .body(containsString("EnumerationResults")); + } + + @Test + void hostStyleTableCreateOnEmulatorHost() { + given().header("Host", "devstoreaccount1.table.localhost:4577") + .contentType("application/json") + .body("{\"TableName\":\"hostStyleTbl\"}") + .when().post("/Tables") + .then().statusCode(201); + } + + @Test + void hostTableCoreWindowsNetRoutesToTable() { + given().header("Host", "acct.table.core.windows.net") + .contentType("application/json") + .body("{\"TableName\":\"hostSuffixTbl\"}") + .when().post("/Tables") + .then().statusCode(201); + } + + @Test + void hostStyleAddressingIsCaseInsensitive() { + // Hostnames are case-insensitive (RFC 4343): a mixed-case Host must still route host-style, + // and the account label must land in the lowercase account namespace. + given().header("Host", "DevStoreAccount1.BLOB.Localhost:4577") + .queryParam("restype", "container") + .when().put("/hoststyle-case") + .then().statusCode(201); + + given().when().get("/devstoreaccount1/?comp=list") + .then().statusCode(200) + .body(containsString("hoststyle-case")); + } + + @Test + void dottedHostWithoutServiceMarkerStaysPathStyle() { + // A multi-label Host that carries no service marker (an emulator served at an FQDN) must keep + // path-style resolution: the first path segment is the account, not a container. + given().header("Host", "floci-az.mycorp.local:4577") + .when().get("/devstoreaccount1/?comp=list") + .then().statusCode(200) + .body(containsString("EnumerationResults")); + } } diff --git a/src/test/java/io/floci/az/core/RoutingTableAssemblyTest.java b/src/test/java/io/floci/az/core/RoutingTableAssemblyTest.java index 2322df0a..cdb8fa9f 100644 --- a/src/test/java/io/floci/az/core/RoutingTableAssemblyTest.java +++ b/src/test/java/io/floci/az/core/RoutingTableAssemblyTest.java @@ -32,13 +32,14 @@ class RoutingTableAssemblyTest { @Inject AzureRoutingFilter filter; - /** A4's HOST_ROUTES, verbatim. */ + /** A4's HOST_ROUTES, plus {@code .table.core.windows.net} (host-style table addressing, #267). */ private static final Set> GOLDEN_HOST_ROUTES = Set.of( Map.entry(".vault.azure.net", "keyvault"), Map.entry(".communication.azure.com", "email"), Map.entry(".blob.core.windows.net", "blob"), Map.entry(".dfs.core.windows.net", "blob"), Map.entry(".queue.core.windows.net", "queue"), + Map.entry(".table.core.windows.net", "table"), Map.entry(".servicebus.windows.net", "servicebus") ); @@ -95,7 +96,7 @@ private static Set> asEntries(List routes @Test void hostRoutesMatchA4() { assertEquals(GOLDEN_HOST_ROUTES, asEntries(filter.hostRoutes())); - assertEquals(6, filter.hostRoutes().size(), "no duplicate host suffixes"); + assertEquals(7, filter.hostRoutes().size(), "no duplicate host suffixes"); } @Test