Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 70 additions & 1 deletion src/main/java/io/floci/az/core/AzureRoutingFilter.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -146,6 +148,14 @@ record ResolvedProvider(String marker, String serviceType, Predicate<String> gua
/** Host-suffix → serviceType. Suffixes are mutually exclusive; DFS maps to blob. */
private volatile List<SuffixRoute> 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<String, String> 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
Expand Down Expand Up @@ -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());
Expand Down Expand Up @@ -240,6 +251,28 @@ static void rejectDuplicateProviders(List<ResolvedProvider> 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<String, String> deriveHostServiceMarkers(List<SuffixRoute> hostRoutes) {
Map<String, String> 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<SuffixRoute> routes, String value) {
for (SuffixRoute route : routes) {
Expand Down Expand Up @@ -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 ──────────────────────────────────────────
Expand All @@ -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));
Comment thread
greptile-apps[bot] marked this conversation as resolved.
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}).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ public boolean enabled(String serviceType) {

public ServiceRoutes routes() {
return ServiceRoutes.builder()
.host(".table.core.windows.net")
.account("-table", "table")
.build();

Expand Down
84 changes: 84 additions & 0 deletions src/test/java/io/floci/az/core/AzureRoutingFilterTest.java
Original file line number Diff line number Diff line change
Expand Up @@ -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"));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Map.Entry<String, String>> 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")
);

Expand Down Expand Up @@ -95,7 +96,7 @@ private static Set<Map.Entry<String, String>> asEntries(List<SuffixRoute> 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
Expand Down