diff --git a/src/Titanium.Gateway/Translation/TranslationPass.Matches.cs b/src/Titanium.Gateway/Translation/TranslationPass.Matches.cs
index ad3c0b5ef..b3b19ee18 100644
--- a/src/Titanium.Gateway/Translation/TranslationPass.Matches.cs
+++ b/src/Titanium.Gateway/Translation/TranslationPass.Matches.cs
@@ -75,10 +75,16 @@ private static void EmitMatch( // NOSONAR S3776 -- Match emission handles redire
return;
}
- route.Unsupported.Add(
- $"rule {ruleIndex} match {matchIndex}: RequestRedirect without hostname needs the route or listener to name concrete hostnames");
- redirect = null;
- direct500 = true;
+ // Wildcard or no hostnames: let the data plane use the host the client sent.
+ redirect = new RedirectSpec
+ {
+ Status = redirect.Status,
+ Scheme = redirect.Scheme,
+ Port = redirect.Port,
+ FullPath = redirect.FullPath,
+ PrefixReplacement = redirect.PrefixReplacement,
+ UseRequestHost = true,
+ };
}
drafts.Add(MakeDraft(route, ruleIndex, matchIndex, spec, hosts, plan, transforms, redirect, null, direct500));
@@ -146,6 +152,7 @@ private static void EmitMatch( // NOSONAR S3776 -- Match emission handles redire
Status = redirect.Status,
Scheme = redirect.Scheme,
Host = redirect.Hostname ?? redirectHost,
+ UseRequestHost = redirect.UseRequestHost,
Port = redirect.Port,
Path = redirect.FullPath,
ReplacePrefixFrom = PrefixReplacementFrom(redirect, spec),
diff --git a/src/Titanium.Gateway/Translation/TranslationPass.Rules.cs b/src/Titanium.Gateway/Translation/TranslationPass.Rules.cs
index d990c8323..03c447a40 100644
--- a/src/Titanium.Gateway/Translation/TranslationPass.Rules.cs
+++ b/src/Titanium.Gateway/Translation/TranslationPass.Rules.cs
@@ -127,6 +127,9 @@ internal sealed class RedirectSpec
/// replaced is the match's own, so it is resolved per match when the route is emitted.
///
public string? PrefixReplacement { get; init; }
+
+ /// The redirect keeps the request host and no concrete route hostname can be named in it.
+ public bool UseRequestHost { get; init; }
}
internal sealed class RulePlan
diff --git a/src/Titanium.Web.Proxy.Abstractions/PublicAPI.Unshipped.txt b/src/Titanium.Web.Proxy.Abstractions/PublicAPI.Unshipped.txt
index 00655a1ba..7863a0755 100644
--- a/src/Titanium.Web.Proxy.Abstractions/PublicAPI.Unshipped.txt
+++ b/src/Titanium.Web.Proxy.Abstractions/PublicAPI.Unshipped.txt
@@ -660,6 +660,8 @@ Titanium.Web.Proxy.Abstractions.Routing.RoutePolicyConfig.Methods.get -> string?
Titanium.Web.Proxy.Abstractions.Routing.MirrorConfig.MaxBodyBytes.get -> int
Titanium.Web.Proxy.Abstractions.Routing.RoutePolicyConfig.Limit.get -> int?
Titanium.Web.Proxy.Abstractions.Routing.RedirectConfig.HttpToHttps.init -> void
+Titanium.Web.Proxy.Abstractions.Routing.RedirectConfig.UseRequestHost.get -> bool
+Titanium.Web.Proxy.Abstractions.Routing.RedirectConfig.UseRequestHost.init -> void
Titanium.Web.Proxy.Abstractions.Routing.RedirectConfig.ReplacePrefixFrom.get -> string?
Titanium.Web.Proxy.Abstractions.Routing.RedirectConfig.ReplacePrefixFrom.init -> void
Titanium.Web.Proxy.Abstractions.Routing.RedirectConfig.ReplacePrefixTo.get -> string?
diff --git a/src/Titanium.Web.Proxy.Abstractions/Routing/RouteConfig.cs b/src/Titanium.Web.Proxy.Abstractions/Routing/RouteConfig.cs
index 51b287361..34cbd674a 100644
--- a/src/Titanium.Web.Proxy.Abstractions/Routing/RouteConfig.cs
+++ b/src/Titanium.Web.Proxy.Abstractions/Routing/RouteConfig.cs
@@ -399,6 +399,14 @@ public sealed class RedirectConfig
/// When the request is cleartext, the redirect scheme is https.
public bool HttpToHttps { get; init; }
+
+ ///
+ /// When is unset, build an absolute Location from the host the client asked for (the
+ /// request Host or :authority). The request port is kept only when neither nor
+ /// is set (and did not upgrade). An absolute-form request target or a
+ /// host that is not a plain name or address falls back to a relative Location.
+ ///
+ public bool UseRequestHost { get; init; }
}
/// Response produced without an upstream.
diff --git a/src/Titanium.Web.Proxy.Configuration/twp.schema.json b/src/Titanium.Web.Proxy.Configuration/twp.schema.json
index 24b71318c..d51b26401 100644
--- a/src/Titanium.Web.Proxy.Configuration/twp.schema.json
+++ b/src/Titanium.Web.Proxy.Configuration/twp.schema.json
@@ -1413,6 +1413,9 @@
},
"status": {
"type": "integer"
+ },
+ "useRequestHost": {
+ "type": "boolean"
}
}
},
diff --git a/src/Titanium.Web.Proxy/Routing/ReverseProxySessionDispatch.cs b/src/Titanium.Web.Proxy/Routing/ReverseProxySessionDispatch.cs
index 6ba8f6b42..b54b949a6 100644
--- a/src/Titanium.Web.Proxy/Routing/ReverseProxySessionDispatch.cs
+++ b/src/Titanium.Web.Proxy/Routing/ReverseProxySessionDispatch.cs
@@ -434,19 +434,38 @@ internal static string BuildRedirect(Request request, RedirectConfig redirect)
var target = OriginForm(request.RequestUriString8.GetString());
var path = RedirectPath(target, redirect);
var query = PreservedQuery(target, redirect.PreserveQuery);
+
+ string? host;
+ int? requestPort = null;
+ var hostFromRequest = false;
if (!string.IsNullOrEmpty(redirect.Host))
{
- var scheme = RedirectScheme(request, redirect);
- var port = redirect.Port is int p and > 0 ? ":" + p.ToString() : "";
- if (path.Contains('?', StringComparison.Ordinal))
- {
- path = path[..path.IndexOf('?')];
- }
+ host = redirect.Host;
+ }
+ else if (redirect.UseRequestHost && TryRequestAuthority(request, out var requestHost, out requestPort))
+ {
+ host = requestHost;
+ hostFromRequest = true;
+ }
+ else
+ {
+ host = null;
+ }
- return scheme + "://" + redirect.Host + port + path + query;
+ // Absolute Location, or a relative one that re-appends the query, must not keep a ? already on the path.
+ var absolute = !string.IsNullOrEmpty(host);
+ if ((absolute || redirect.PreserveQuery) && path.Contains('?', StringComparison.Ordinal))
+ {
+ path = path[..path.IndexOf('?')];
}
- return path + query;
+ if (!absolute)
+ {
+ return path + query;
+ }
+
+ var scheme = RedirectScheme(request, redirect);
+ return scheme + "://" + host + RedirectPortSuffix(redirect, scheme, hostFromRequest ? requestPort : null) + path + query;
}
private static string RedirectPath(string target, RedirectConfig redirect)
@@ -502,6 +521,97 @@ private static string PreservedQuery(string target, bool preserve)
return q >= 0 ? target[q..] : "";
}
+ ///
+ /// The client's requested host for an absolute Location. Absolute-form targets and authorities that are not a
+ /// plain DNS name, IPv4 address, or bracketed IPv6 literal return false so the redirect stays relative.
+ ///
+ private static bool TryRequestAuthority(Request request, out string host, out int? requestPort)
+ {
+ host = string.Empty;
+ requestPort = null;
+ if (UriExtensions.GetScheme(request.RequestUriString8).Length > 0)
+ {
+ return false;
+ }
+
+ var authority = request.Host;
+ if (string.IsNullOrEmpty(authority))
+ {
+ if (request.Authority.Length == 0)
+ {
+ return false;
+ }
+
+ authority = request.Authority.GetString();
+ }
+
+ if (!AuthorityParser.TryParse(authority, 0, out var parsed, out var port))
+ {
+ return false;
+ }
+
+ if (parsed.Contains(':', StringComparison.Ordinal))
+ {
+ host = HttpHostHeader.Format(parsed, 0);
+ }
+ else
+ {
+ if (parsed.Length == 0 || !IsSafeHostName(parsed))
+ {
+ return false;
+ }
+
+ host = parsed;
+ }
+
+ if (port > 0)
+ {
+ requestPort = port;
+ }
+
+ return true;
+ }
+
+ private static bool IsSafeHostName(string name)
+ {
+ foreach (var c in name)
+ {
+ if (!(char.IsAsciiLetterOrDigit(c) || c is '.' or '-' or '_'))
+ {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ ///
+ /// Port for an absolute Location. A configured port wins (omitted when it is the scheme default). A configured
+ /// scheme or an http-to-https upgrade uses the well-known port. Otherwise a request host may keep its non-default port.
+ ///
+ private static string RedirectPortSuffix(RedirectConfig redirect, string scheme, int? requestPort)
+ {
+ if (redirect.Port is int configured and > 0)
+ {
+ return OmitDefaultPort(scheme, configured) ? "" : ":" + configured.ToString();
+ }
+
+ if (redirect.Scheme is not null || redirect.HttpToHttps)
+ {
+ return "";
+ }
+
+ if (requestPort is int inherited)
+ {
+ return OmitDefaultPort(scheme, inherited) ? "" : ":" + inherited.ToString();
+ }
+
+ return "";
+ }
+
+ private static bool OmitDefaultPort(string scheme, int port) =>
+ (scheme == "http" && port == 80) || (scheme == "https" && port == 443);
+
private static string RedirectScheme(Request request, RedirectConfig redirect)
{
if (redirect.HttpToHttps && !request.IsHttps)
diff --git a/tests/Titanium.E2E.Tests/InspectorHeadlessUiE2ETests.cs b/tests/Titanium.E2E.Tests/InspectorHeadlessUiE2ETests.cs
index f900b1659..90b73b423 100644
--- a/tests/Titanium.E2E.Tests/InspectorHeadlessUiE2ETests.cs
+++ b/tests/Titanium.E2E.Tests/InspectorHeadlessUiE2ETests.cs
@@ -20,6 +20,8 @@ public async Task Commands_StartInstallProxy_AutoResponder_Composer()
var settingsPath = Path.Combine(Path.GetTempPath(), "twp-insp-e2e-" + Guid.NewGuid().ToString("N") + ".json");
var settings = new SettingsService(settingsPath);
settings.Current.AutoSystemProxyOnStart = false;
+ // Skip the PAC-replace confirm so a leftover AutoConfigURL on the agent cannot cancel the toggle.
+ settings.Current.WarnedAboutPacReplace = true;
settings.Save();
var registry = new SessionRegistry();
var buffer = new SessionStreamBuffer(registry);
@@ -28,85 +30,95 @@ public async Task Commands_StartInstallProxy_AutoResponder_Composer()
var interception = new InterceptionService(recorder) { UseInMemoryTrustState = true };
var vm = new MainWindowViewModel(buffer, registry, updates, settings, interception);
- Assert.IsNotNull(vm.StartCaptureCommand);
- Assert.IsNotNull(vm.InstallCaCommand);
- Assert.IsNotNull(vm.ToggleSystemProxyCommand);
-
- vm.BindPort = 0;
- vm.BindAddress = "127.0.0.1";
-
- // Execute commands like the Avalonia bindings do (RelayCommand is async void).
- vm.StartCaptureCommand.Execute(null);
- var deadline = DateTime.UtcNow.AddSeconds(15);
- while ((!interception.IsRunning || vm.StatusText != "Ready") && DateTime.UtcNow < deadline)
+ try
{
- await Task.Delay(50);
- }
+ Assert.IsNotNull(vm.StartCaptureCommand);
+ Assert.IsNotNull(vm.InstallCaCommand);
+ Assert.IsNotNull(vm.ToggleSystemProxyCommand);
- Assert.IsTrue(interception.IsRunning, vm.StatusText);
- Assert.IsTrue(vm.Capturing);
- Assert.AreEqual("Ready", vm.StatusText);
- Assert.IsTrue(
- vm.EndpointStatusText.Contains("Proxy running", StringComparison.OrdinalIgnoreCase),
- vm.EndpointStatusText);
+ vm.BindPort = 0;
+ vm.BindAddress = "127.0.0.1";
- vm.InstallCaCommand.Execute(null);
- await Task.Delay(100);
+ // Execute commands like the Avalonia bindings do (RelayCommand is async void).
+ vm.StartCaptureCommand.Execute(null);
+ var deadline = DateTime.UtcNow.AddSeconds(15);
+ while ((!interception.IsRunning || vm.StatusText != "Ready") && DateTime.UtcNow < deadline)
+ {
+ await Task.Delay(50);
+ }
- vm.ToggleSystemProxyCommand.Execute(null);
- var deadlineProxy = DateTime.UtcNow.AddSeconds(10);
- while (recorder.SetCount < 1 && DateTime.UtcNow < deadlineProxy)
- {
- await Task.Delay(50);
- }
+ Assert.IsTrue(interception.IsRunning, vm.StatusText);
+ Assert.IsTrue(vm.Capturing);
+ Assert.AreEqual("Ready", vm.StatusText);
+ Assert.IsTrue(
+ vm.EndpointStatusText.Contains("Proxy running", StringComparison.OrdinalIgnoreCase),
+ vm.EndpointStatusText);
- Assert.AreEqual(1, recorder.SetCount, "System proxy should go through controller seam");
- Assert.IsTrue(vm.SystemProxy);
- Assert.IsTrue(
- vm.StatusText.StartsWith("System proxy enabled", StringComparison.Ordinal),
- vm.StatusText);
- if (OperatingSystem.IsWindows())
- {
- Assert.IsTrue(vm.StatusText.Contains("quic", StringComparison.OrdinalIgnoreCase), vm.StatusText);
- }
- else if (OperatingSystem.IsMacOS())
- {
- Assert.IsTrue(vm.StatusText.Contains("Firefox", StringComparison.OrdinalIgnoreCase), vm.StatusText);
- }
+ vm.InstallCaCommand.Execute(null);
+ var deadlineCa = DateTime.UtcNow.AddSeconds(10);
+ while (!vm.StatusText.Contains("Root CA", StringComparison.Ordinal) && DateTime.UtcNow < deadlineCa)
+ {
+ await Task.Delay(50);
+ }
- vm.AutoResponderMatch = "*ui-e2e*";
- vm.AutoResponderStatus = 201;
- vm.AutoResponderBody = "from-ui";
- vm.AddAutoResponderRuleCommand.Execute(null);
- Assert.AreEqual(1, vm.AutoResponder.Rules.Count);
-
- using var origin = new EchoOrigin();
- vm.ComposerMethod = "GET";
- vm.ComposerUrl = origin.BaseUrl + "ui-composer";
- vm.SendComposerCommand.Execute(null);
- // Do not match "Composer sending…" (set immediately before ReplayAsync); wait for settle.
- deadline = DateTime.UtcNow.AddSeconds(20);
- while (!IsComposerSettled(vm) && DateTime.UtcNow < deadline)
- {
- await Task.Delay(50);
- }
+ Assert.IsTrue(
+ vm.StatusText.Contains("Root CA", StringComparison.Ordinal),
+ vm.StatusText);
- Assert.IsTrue(IsComposerSettled(vm), vm.StatusText);
+ vm.ToggleSystemProxyCommand.Execute(null);
+ var deadlineProxy = DateTime.UtcNow.AddSeconds(10);
+ while ((recorder.SetCount < 1
+ || !vm.StatusText.StartsWith("System proxy enabled", StringComparison.Ordinal))
+ && DateTime.UtcNow < deadlineProxy)
+ {
+ await Task.Delay(50);
+ }
- vm.StopCaptureCommand.Execute(null);
- await Task.Delay(100);
- Assert.IsFalse(interception.IsRunning);
+ Assert.AreEqual(1, recorder.SetCount, "System proxy should go through controller seam");
+ Assert.IsTrue(vm.SystemProxy);
+ Assert.IsTrue(
+ vm.StatusText.StartsWith("System proxy enabled", StringComparison.Ordinal),
+ vm.StatusText);
+ if (OperatingSystem.IsMacOS())
+ {
+ Assert.IsTrue(vm.StatusText.Contains("Firefox", StringComparison.OrdinalIgnoreCase), vm.StatusText);
+ }
- try
- {
- if (File.Exists(settingsPath))
+ vm.AutoResponderMatch = "*ui-e2e*";
+ vm.AutoResponderStatus = 201;
+ vm.AutoResponderBody = "from-ui";
+ vm.AddAutoResponderRuleCommand.Execute(null);
+ Assert.AreEqual(1, vm.AutoResponder.Rules.Count);
+
+ using var origin = new EchoOrigin();
+ vm.ComposerMethod = "GET";
+ vm.ComposerUrl = origin.BaseUrl + "ui-composer";
+ vm.SendComposerCommand.Execute(null);
+ // Do not match "Composer sending…" (set immediately before ReplayAsync); wait for settle.
+ deadline = DateTime.UtcNow.AddSeconds(20);
+ while (!IsComposerSettled(vm) && DateTime.UtcNow < deadline)
{
- File.Delete(settingsPath);
+ await Task.Delay(50);
}
+
+ Assert.IsTrue(IsComposerSettled(vm), vm.StatusText);
+
+ vm.EnsureShutdown();
+ Assert.IsFalse(interception.IsRunning);
}
- catch
+ finally
{
- // ignore
+ try
+ {
+ if (File.Exists(settingsPath))
+ {
+ File.Delete(settingsPath);
+ }
+ }
+ catch
+ {
+ // ignore
+ }
}
}
diff --git a/tests/Titanium.Gateway.Tests/GatewayRedirectMirrorAndRewriteCoverageBehaviorTests.cs b/tests/Titanium.Gateway.Tests/GatewayRedirectMirrorAndRewriteCoverageBehaviorTests.cs
index 905231aa1..363afcf33 100644
--- a/tests/Titanium.Gateway.Tests/GatewayRedirectMirrorAndRewriteCoverageBehaviorTests.cs
+++ b/tests/Titanium.Gateway.Tests/GatewayRedirectMirrorAndRewriteCoverageBehaviorTests.cs
@@ -16,6 +16,8 @@ namespace Titanium.Gateway.Tests;
[TestClass]
public class GatewayRedirectMirrorAndRewriteCoverageBehaviorTests
{
+ private static readonly string[] ConcreteRedirectHosts = ["a.example", "b.example"];
+
private static void AssertValid(RenderResult result) =>
Assert.AreEqual(0, TwpConfigValidator.Validate(result.Config).Count, string.Join("; ", TwpConfigValidator.Validate(result.Config)));
@@ -26,9 +28,10 @@ private static RenderResult Render(StateBuilder b, HttpRoute route)
}
[TestMethod]
- public void Redirect_WithoutHostname_AndOnlyWildcardHosts_IsUnsupported()
+ public void Redirect_WithoutHostname_AndOnlyWildcardHosts_UsesTheRequestHost()
{
- // Bug class: wildcard hosts (Rank != 0) must not expand into absolute Location hosts.
+ // Bug class: wildcard hosts (Rank != 0) must not expand into absolute Location hosts; the data plane uses
+ // the host the client sent instead.
var b = Basic();
var rule = new HttpRouteRule
{
@@ -45,13 +48,138 @@ public void Redirect_WithoutHostname_AndOnlyWildcardHosts_IsUnsupported()
AssertValid(result);
var route = result.Config.UserRoutes().Single();
- Assert.AreEqual(500, route.DirectResponse?.Status);
- Assert.IsNull(route.Redirect);
- Assert.IsTrue(
+ Assert.IsNull(route.DirectResponse);
+ Assert.IsNotNull(route.Redirect);
+ Assert.IsNull(route.Redirect!.Host, "no host is baked in");
+ Assert.IsTrue(route.Redirect.UseRequestHost);
+ Assert.AreEqual("https", route.Redirect.Scheme);
+ Assert.IsFalse(
result.StatusPatches.Single(p => p.Kind == StatusKind.HttpRoute).Parents!.Single()
.Conditions.Any(c => c.Type == "PartiallyInvalid" && c.Status == "True"));
}
+ [TestMethod]
+ public void Redirect_WithoutHostname_OnARouteWithNoHostnames_UsesTheRequestHost()
+ {
+ var b = Basic();
+ var rule = new HttpRouteRule
+ {
+ Filters =
+ [
+ new HttpRouteFilter
+ {
+ Type = "RequestRedirect",
+ RequestRedirect = new HttpRequestRedirect { Scheme = "https", StatusCode = 301 },
+ },
+ ],
+ };
+ var result = Render(b, Route("r", "default", null, rule));
+ AssertValid(result);
+
+ var route = result.Config.UserRoutes().Single();
+ Assert.IsNull(route.DirectResponse);
+ Assert.IsTrue(route.Redirect!.UseRequestHost);
+ Assert.AreEqual(301, route.Redirect.Status);
+ }
+
+ [TestMethod]
+ public void Redirect_WithoutHostname_OnConcreteHosts_StillBakesEachHostIn()
+ {
+ var b = Basic();
+ var rule = new HttpRouteRule
+ {
+ Filters =
+ [
+ new HttpRouteFilter
+ {
+ Type = "RequestRedirect",
+ RequestRedirect = new HttpRequestRedirect { Scheme = "https" },
+ },
+ ],
+ };
+ var routes = Render(b, Route("r", "default", ConcreteRedirectHosts, rule)).Config.UserRoutes();
+
+ CollectionAssert.AreEquivalent(ConcreteRedirectHosts, routes.Select(r => r.Redirect!.Host).ToArray());
+ Assert.IsTrue(routes.All(r => !r.Redirect!.UseRequestHost));
+ }
+
+ [TestMethod]
+ public void Redirect_WithoutHostname_OnMixedConcreteAndWildcardHosts_UsesTheRequestHost()
+ {
+ var b = Basic();
+ var rule = new HttpRouteRule
+ {
+ Filters =
+ [
+ new HttpRouteFilter
+ {
+ Type = "RequestRedirect",
+ RequestRedirect = new HttpRequestRedirect { Scheme = "https" },
+ },
+ ],
+ };
+ var routes = Render(b, Route("r", "default", ["a.example.com", "*.example.com"], rule)).Config.UserRoutes();
+
+ Assert.AreEqual(1, routes.Count);
+ Assert.IsNull(routes[0].Redirect!.Host);
+ Assert.IsTrue(routes[0].Redirect!.UseRequestHost);
+ }
+
+ [TestMethod]
+ public void Redirect_ReplacePrefixMatch_WithoutHostname_UsesTheRequestHost()
+ {
+ var b = Basic();
+ var rule = new HttpRouteRule
+ {
+ Matches = [PathPrefix("/old")],
+ Filters =
+ [
+ new HttpRouteFilter
+ {
+ Type = "RequestRedirect",
+ RequestRedirect = new HttpRequestRedirect
+ {
+ Scheme = "https",
+ Path = new HttpPathModifier { Type = "ReplacePrefixMatch", ReplacePrefixMatch = "/new" },
+ },
+ },
+ ],
+ };
+ var route = Render(b, Route("r", "default", ["*.example.com"], rule)).Config.UserRoutes().Single();
+
+ Assert.IsTrue(route.Redirect!.UseRequestHost);
+ Assert.IsNull(route.Redirect.Host);
+ Assert.AreEqual("/old", route.Redirect.ReplacePrefixFrom);
+ Assert.AreEqual("/new", route.Redirect.ReplacePrefixTo);
+ }
+
+ [TestMethod]
+ public void Redirect_WithoutHostname_SurvivesConfigRoundTrip()
+ {
+ var b = Basic();
+ var rule = new HttpRouteRule
+ {
+ Filters =
+ [
+ new HttpRouteFilter
+ {
+ Type = "RequestRedirect",
+ RequestRedirect = new HttpRequestRedirect { Scheme = "https", StatusCode = 301 },
+ },
+ ],
+ };
+ var result = Render(b, Route("r", "default", null, rule));
+ AssertValid(result);
+
+ var loaded = TwpConfigLoader.LoadJson(TwpConfigLoader.ToJson(result.Config));
+ Assert.AreEqual(0, TwpConfigValidator.Validate(loaded).Count, string.Join("; ", TwpConfigValidator.Validate(loaded)));
+ var redirect = loaded.UserRoutes().Single().Redirect!;
+ Assert.IsTrue(redirect.UseRequestHost);
+ Assert.IsNull(redirect.Host);
+ Assert.AreEqual("https", redirect.Scheme);
+ Assert.AreEqual(301, redirect.Status);
+ }
+
[TestMethod]
public void DualRequestMirror_IsUnsupported_AndLeavesPrimaryRoute()
{
diff --git a/tests/Titanium.Gateway.Tests/GatewayTranslatorBackendTests.cs b/tests/Titanium.Gateway.Tests/GatewayTranslatorBackendTests.cs
index 40e5816ae..92e189c7d 100644
--- a/tests/Titanium.Gateway.Tests/GatewayTranslatorBackendTests.cs
+++ b/tests/Titanium.Gateway.Tests/GatewayTranslatorBackendTests.cs
@@ -366,7 +366,7 @@ public void Redirect_WithoutHostname_IsRenderedOncePerConcreteHostname()
}
[TestMethod]
- public void Redirect_WithoutAnyHostname_IsUnsupported()
+ public void Redirect_WithoutAnyHostname_UsesTheRequestHost()
{
var b = Basic();
var rule = new HttpRouteRule
@@ -375,8 +375,10 @@ public void Redirect_WithoutAnyHostname_IsUnsupported()
};
var result = Render(b, Route("r", "default", null, rule));
- Assert.AreEqual(500, result.Config.UserRoutes().Single().DirectResponse?.Status);
- Assert.AreEqual("True", Cond(Parent(result).Conditions, "PartiallyInvalid").Status);
+ var route = result.Config.UserRoutes().Single();
+ Assert.IsNull(route.DirectResponse);
+ Assert.IsTrue(route.Redirect!.UseRequestHost);
+ Assert.AreEqual("True", Cond(Parent(result).Conditions, "Accepted").Status);
}
private static HttpRouteRule PrefixRedirectRule(HttpRouteMatch match, string? replacement, int? status = null) => new()
diff --git a/tests/Titanium.Web.Proxy.UnitTests/Routing/RedirectRequestHostTests.cs b/tests/Titanium.Web.Proxy.UnitTests/Routing/RedirectRequestHostTests.cs
new file mode 100644
index 000000000..fc818b5d0
--- /dev/null
+++ b/tests/Titanium.Web.Proxy.UnitTests/Routing/RedirectRequestHostTests.cs
@@ -0,0 +1,234 @@
+using System.Linq;
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using Titanium.Web.Proxy.Abstractions.Routing;
+using Titanium.Web.Proxy.Configuration;
+using Titanium.Web.Proxy.Configuration.Models;
+using Titanium.Web.Proxy.Http;
+using Titanium.Web.Proxy.Models;
+using Titanium.Web.Proxy.Routing;
+
+namespace Titanium.Web.Proxy.UnitTests.Routing;
+
+/// Redirect UseRequestHost: Gateway API RequestRedirect with no hostname.
+[TestClass]
+public class RedirectRequestHostTests
+{
+ private static string Location(
+ string? requestHost,
+ RedirectConfig redirect,
+ string target = "/a?x=1",
+ bool https = false,
+ string? authority = null)
+ {
+ var request = new Request { Method = "GET", Host = requestHost, IsHttps = https };
+ if (authority is not null)
+ {
+ request.Authority = (ByteString)authority;
+ }
+
+ request.RequestUriString8 = (ByteString)target;
+ return ReverseProxySessionDispatch.BuildRedirect(request, redirect);
+ }
+
+ [TestMethod]
+ public void BuildsAnAbsoluteLocationFromTheRequestHost()
+ {
+ Assert.AreEqual(
+ "https://app.example.com/a?x=1",
+ Location("app.example.com", new RedirectConfig { Scheme = "https", UseRequestHost = true }));
+ }
+
+ [TestMethod]
+ public void DropsTheRequestPort_WhenTheSchemeIsConfigured()
+ {
+ Assert.AreEqual(
+ "https://app.example.com/a?x=1",
+ Location("app.example.com:8080", new RedirectConfig { Scheme = "https", UseRequestHost = true }));
+ Assert.AreEqual(
+ "https://app.example.com:8443/a?x=1",
+ Location("app.example.com:8080", new RedirectConfig { Scheme = "https", Port = 8443, UseRequestHost = true }));
+ }
+
+ [TestMethod]
+ public void KeepsANonDefaultRequestPort_WhenNeitherSchemeNorPortIsSet()
+ {
+ Assert.AreEqual(
+ "http://app.example.com:8080/a?x=1",
+ Location("app.example.com:8080", new RedirectConfig { UseRequestHost = true }));
+ }
+
+ [TestMethod]
+ public void OmitsARequestPortThatMatchesTheEffectiveSchemeDefault()
+ {
+ Assert.AreEqual(
+ "http://app.example.com/a?x=1",
+ Location("app.example.com:80", new RedirectConfig { UseRequestHost = true }));
+ Assert.AreEqual(
+ "https://app.example.com:80/a?x=1",
+ Location("app.example.com:80", new RedirectConfig { UseRequestHost = true }, https: true));
+ }
+
+ [TestMethod]
+ public void HttpToHttps_DropsTheRequestPort()
+ {
+ Assert.AreEqual(
+ "https://app.example.com/a?x=1",
+ Location("app.example.com:8080", new RedirectConfig { HttpToHttps = true, UseRequestHost = true }));
+ }
+
+ [TestMethod]
+ public void ConfiguredPort_IsKept_UnlessItIsTheSchemeDefault()
+ {
+ Assert.AreEqual(
+ "https://app.example.com:9443/a?x=1",
+ Location(
+ "app.example.com:8080",
+ new RedirectConfig { Scheme = "https", Port = 9443, UseRequestHost = true }));
+ Assert.AreEqual(
+ "https://app.example.com/a?x=1",
+ Location(
+ "app.example.com:8080",
+ new RedirectConfig { Scheme = "https", Port = 443, UseRequestHost = true }));
+ }
+
+ [TestMethod]
+ public void KeepsABracketedIpv6Literal_AndItsPortWhenTheSchemeIsUnset()
+ {
+ Assert.AreEqual(
+ "https://[::1]/a?x=1",
+ Location("[::1]:8080", new RedirectConfig { Scheme = "https", UseRequestHost = true }));
+ Assert.AreEqual(
+ "http://[::1]:8080/a?x=1",
+ Location("[::1]:8080", new RedirectConfig { UseRequestHost = true }));
+ }
+
+ [TestMethod]
+ [DataRow("evil.com/x")]
+ [DataRow("a@evil.com")]
+ [DataRow("user:pass@evil.com")]
+ [DataRow("evil.com\\x")]
+ [DataRow("a b")]
+ [DataRow("a\r\nb")]
+ [DataRow("[::1]junk")]
+ [DataRow("[::1")]
+ [DataRow("::1")]
+ [DataRow("app:abc")]
+ [DataRow("app:0")]
+ [DataRow("app:70000")]
+ [DataRow("")]
+ [DataRow(null)]
+ public void AnOddHostFallsBackToARelativeLocation(string? host)
+ {
+ Assert.AreEqual("/a?x=1", Location(host, new RedirectConfig { Scheme = "https", UseRequestHost = true }));
+ }
+
+ [TestMethod]
+ public void AbsoluteFormTarget_FallsBackToARelativeLocation()
+ {
+ Assert.AreEqual(
+ "/a?x=1",
+ Location(
+ "app.example.com",
+ new RedirectConfig { Scheme = "https", UseRequestHost = true },
+ target: "http://other.test/a?x=1"));
+ }
+
+ [TestMethod]
+ public void AuthorityIsUsed_WhenHostIsUnset()
+ {
+ Assert.AreEqual(
+ "https://app.example.com/a?x=1",
+ Location(
+ null,
+ new RedirectConfig { Scheme = "https", UseRequestHost = true },
+ authority: "app.example.com"));
+ }
+
+ [TestMethod]
+ public void AnExplicitHostWins_AndNeverInheritsTheRequestPort()
+ {
+ Assert.AreEqual(
+ "https://dest.test/a?x=1",
+ Location(
+ "app.example.com:8080",
+ new RedirectConfig { Host = "dest.test", Scheme = "https", UseRequestHost = true }));
+ Assert.AreEqual(
+ "https://dest.test/a?x=1",
+ Location(
+ "app.example.com:8080",
+ new RedirectConfig { Host = "dest.test", Scheme = "https" }));
+ }
+
+ [TestMethod]
+ public void TheFlagOffKeepsTheOldRelativeBehavior_WithoutDoublingTheQuery()
+ {
+ Assert.AreEqual("/a?x=1", Location("app.example.com", new RedirectConfig { Scheme = "https" }));
+ Assert.AreEqual(
+ "/a?x=1",
+ Location("app.example.com", new RedirectConfig { Scheme = "https" }, target: "/a?x=1"));
+ }
+
+ [TestMethod]
+ public void PreserveQueryFalse_LeavesAnExplicitRelativePathAlone()
+ {
+ Assert.AreEqual(
+ "/fixed",
+ Location(
+ "app.example.com",
+ new RedirectConfig { Path = "/fixed", PreserveQuery = false },
+ target: "/a?x=1"));
+ }
+
+ [TestMethod]
+ public void PreserveQueryFalse_StripsAQueryFromAnAbsolutePath()
+ {
+ Assert.AreEqual(
+ "https://h/fixed",
+ Location(
+ "ignored",
+ new RedirectConfig
+ {
+ Host = "h",
+ Scheme = "https",
+ Path = "/fixed?k=1",
+ PreserveQuery = false,
+ }));
+ }
+
+ [TestMethod]
+ public void UseRequestHost_SurvivesJsonRoundTrip_AndBuildsFromTheRequestHost()
+ {
+ var original = new TwpConfig
+ {
+ Clusters =
+ [
+ new()
+ {
+ Id = "c",
+ Destinations = [new() { Id = "d", Address = "127.0.0.1", Port = 9 }],
+ },
+ ],
+ Routes =
+ [
+ new RouteConfig
+ {
+ Id = "rd",
+ ClusterId = "c",
+ Match = new RouteMatch { Path = "/", PathKind = PathMatchKind.Prefix },
+ Redirect = new RedirectConfig { Scheme = "https", UseRequestHost = true },
+ },
+ ],
+ };
+
+ Assert.AreEqual(0, TwpConfigValidator.Validate(original).Count, string.Join("; ", TwpConfigValidator.Validate(original)));
+ var loaded = TwpConfigLoader.LoadJson(TwpConfigLoader.ToJson(original));
+ Assert.AreEqual(0, TwpConfigValidator.Validate(loaded).Count, string.Join("; ", TwpConfigValidator.Validate(loaded)));
+
+ var redirect = loaded.Routes!.Single().Redirect!;
+ Assert.IsTrue(redirect.UseRequestHost);
+ Assert.IsNull(redirect.Host);
+ Assert.AreEqual(
+ "https://app.example.com/a?x=1",
+ Location("app.example.com", redirect));
+ }
+}
diff --git a/tests/Titanium.Web.Proxy.UnitTests/Titanium.Web.Proxy.UnitTests.csproj b/tests/Titanium.Web.Proxy.UnitTests/Titanium.Web.Proxy.UnitTests.csproj
index 405d74879..7193b79de 100644
--- a/tests/Titanium.Web.Proxy.UnitTests/Titanium.Web.Proxy.UnitTests.csproj
+++ b/tests/Titanium.Web.Proxy.UnitTests/Titanium.Web.Proxy.UnitTests.csproj
@@ -29,6 +29,7 @@
+
diff --git a/tools/gateway-conformance/run.sh b/tools/gateway-conformance/run.sh
index 8375004bf..44ac9d485 100644
--- a/tools/gateway-conformance/run.sh
+++ b/tools/gateway-conformance/run.sh
@@ -26,8 +26,8 @@ REPORT="${REPORT:-$ROOT/artifacts/gateway-conformance-report.yaml}"
VERSION="$(sed -n 's/^appVersion: "\(.*\)"/\1/p' "$ROOT/deploy/helm/titanium-gateway/Chart.yaml")"
# Extended features this controller claims (see website/docs/gateway-api.md). Everything not listed is not claimed.
-# Not claimed, because the data plane cannot honor them: response header add (it keeps one value per header) and a
-# redirect without a hostname (HTTPRouteSchemeRedirect, HTTPRoutePortRedirect, HTTPRoutePathRedirect).
+# Not claimed until the suite has been re-run: response header add (the data plane now appends) and hostless redirects
+# (HTTPRouteSchemeRedirect, HTTPRoutePortRedirect, HTTPRoutePathRedirect — the data plane builds them).
SUPPORTED_FEATURES="HTTPRouteQueryParamMatching,HTTPRouteMethodMatching,HTTPRouteHostRewrite,HTTPRoutePathRewrite,HTTPRouteRequestMirror"
cleanup() {
diff --git a/website/docs/configuration.md b/website/docs/configuration.md
index 790359d92..8ad207ce1 100644
--- a/website/docs/configuration.md
+++ b/website/docs/configuration.md
@@ -347,7 +347,7 @@ A trailing newline in a file is dropped. For `security.apiKeys` and `security.ba
}
```
-Match fields typically include host, path (`Exact` / `Prefix` / `Template`), method, headers, and query. `queryParameters` is an optional list of `name` and `value` entries (add `kind: Regex` to match the whole decoded value with a regular expression); every entry must match, and leaving it unset costs nothing per request. `headerMatches` works the same way for request headers: a list of `name` and `value` entries, `kind: Regex` for a whole-value regular expression (the name is case-insensitive, the value is case-sensitive), every entry must match, and leaving it unset costs nothing per request. A `headerMatches` regular expression that uses lookahead, a backreference, or an unbalanced parenthesis is a validation error. A lower `order` wins. Equal `order` is resolved by specificity (exact over segment prefix over prefix, then a longer path, then host, method, and header or query constraints) and then by file order. `segmentPrefix` does not match a longer segment (`/api` does not match `/apiary`). `regex` paths are compiled once. Host lists accept `*.example.com`. Matching uses the path the origin sees: dot segments are removed and unreserved percent-escapes are decoded; other escapes stay encoded. Cluster algorithms include RoundRobin, Random, LeastRequests, and LeastTime; destinations support weight and sticky cookie/header. Destination ids must be unique across clusters. When a route matches and no destination is eligible, the proxy answers 503 with `Retry-After: 1` instead of forwarding to the request host. Passive health, when left unset, treats 502, 503, and 504 as destination failures and treats other statuses, including 500, as success. After `reactivateAfterSeconds` an ejected destination receives one trial; a successful trial restores it. `healthCheck.passive.maxEjectionPercent` stops ejecting once that share of the cluster is already out. `healthCheck.active` probes on a background timer and only restores destinations that probe marked unhealthy (Plus: needs `plus.enabled`; prefer `plus.options resilience.activeHealth`). `retry.attempts` fails a connect over to the next destination for idempotent methods; set `retryNonIdempotent` to allow a buffered POST. `maxConcurrentRequests`, `priority`, and `backup` limit and order the pool. A destination or cluster `slowStartSeconds` ramps weight from 10 percent. `queue` waits briefly when every destination is at its cap. Dispose `ClusterManager` on shutdown so active probes stop. These knobs leave the single-destination fast path unchanged while they are unset. A single route with a host, path, method, header, or query constraint on a ForwardHost listener is enforced for GET and HEAD too and leaves terminate-lite; a catch-all single route (no path, or prefix `/`, and no other constraint) is unchanged. `useDestinationHost`, or passive health with `enabled` not false, also leaves terminate-lite. `server.forwardedHeaders` is off until an action other than Off is set. A peer outside `trustedProxies` cannot append to an inbound X-Forwarded-* value. Setting any forwarded action leaves terminate-lite. A route `policies` list can require a header, check an API key, cap the request body, answer a CORS preflight, or apply a rate, connection, or address limit before a destination is chosen. JWT and distributed rate limits stay in Titanium Plus. An empty policy list leaves terminate-lite alone. An `extAuth` policy asks an external service whether to proceed. It sends a bodyless subrequest to `url` (`method` defaults to GET) that carries the `sendHeaders` (default Authorization and Cookie) plus `X-Original-Method`, `X-Original-Uri`, `X-Original-Host`, and `X-Forwarded-For`. Any 2xx lets the request through, and the headers named in `copyResponseHeaders` replace the same-named client headers before the origin sees them. Any other status is answered to the client with that status, a body of at most 64 KiB, and only the `denyResponseHeaders` (default Location, Set-Cookie, WWW-Authenticate, Content-Type). Redirects are relayed, never followed. A timeout (`timeoutMilliseconds`, default 2000, 1 to 60000), a connection error, or a 5xx answers 503 unless `failOpen` is true, in which case the request proceeds and `twp.extauth.requests` counts `error_open`. `cacheSeconds` (default 0) keeps a 2xx verdict for the same method, host, path, and `sendHeaders` values, bounded by `cacheMaxEntries` (default 10000, oldest evicted first). A subrequest carrying `X-Titanium-ExtAuth: 1` is refused with 508 so two proxies cannot loop. A route that lists an `extAuth` policy leaves terminate-lite; routes without one are unchanged. Each request that misses the cache waits for one extra round trip to the service, so keep it on the same host or network and use `cacheSeconds` where a short-lived verdict is acceptable. The `twp-cli-extauth-http1` arm of the load probe measures that cost against an allowing service; re-measure on your own hardware before relying on a number. A route `clusters` list splits traffic by weight. `redirect.httpToHttps` upgrades a cleartext redirect. `redirect.replacePrefixFrom` with `redirect.replacePrefixTo` swaps a leading path prefix on a segment boundary and keeps the rest and the query; an explicit `redirect.path` wins. `errorPages` replaces the body of a generated status. `sessionAffinity` pins an opaque cookie. `hashKey` feeds consistent hashing. Listener `passthrough` copies TCP bytes, and `acceptProxyProtocol` reads a PROXY preamble. When any cluster uses `LeastTime`, the CLI automatically enables request timing capture so latency EWMA can drive selection.
+Match fields typically include host, path (`Exact` / `Prefix` / `Template`), method, headers, and query. `queryParameters` is an optional list of `name` and `value` entries (add `kind: Regex` to match the whole decoded value with a regular expression); every entry must match, and leaving it unset costs nothing per request. `headerMatches` works the same way for request headers: a list of `name` and `value` entries, `kind: Regex` for a whole-value regular expression (the name is case-insensitive, the value is case-sensitive), every entry must match, and leaving it unset costs nothing per request. A `headerMatches` regular expression that uses lookahead, a backreference, or an unbalanced parenthesis is a validation error. A lower `order` wins. Equal `order` is resolved by specificity (exact over segment prefix over prefix, then a longer path, then host, method, and header or query constraints) and then by file order. `segmentPrefix` does not match a longer segment (`/api` does not match `/apiary`). `regex` paths are compiled once. Host lists accept `*.example.com`. Matching uses the path the origin sees: dot segments are removed and unreserved percent-escapes are decoded; other escapes stay encoded. Cluster algorithms include RoundRobin, Random, LeastRequests, and LeastTime; destinations support weight and sticky cookie/header. Destination ids must be unique across clusters. When a route matches and no destination is eligible, the proxy answers 503 with `Retry-After: 1` instead of forwarding to the request host. Passive health, when left unset, treats 502, 503, and 504 as destination failures and treats other statuses, including 500, as success. After `reactivateAfterSeconds` an ejected destination receives one trial; a successful trial restores it. `healthCheck.passive.maxEjectionPercent` stops ejecting once that share of the cluster is already out. `healthCheck.active` probes on a background timer and only restores destinations that probe marked unhealthy (Plus: needs `plus.enabled`; prefer `plus.options resilience.activeHealth`). `retry.attempts` fails a connect over to the next destination for idempotent methods; set `retryNonIdempotent` to allow a buffered POST. `maxConcurrentRequests`, `priority`, and `backup` limit and order the pool. A destination or cluster `slowStartSeconds` ramps weight from 10 percent. `queue` waits briefly when every destination is at its cap. Dispose `ClusterManager` on shutdown so active probes stop. These knobs leave the single-destination fast path unchanged while they are unset. A single route with a host, path, method, header, or query constraint on a ForwardHost listener is enforced for GET and HEAD too and leaves terminate-lite; a catch-all single route (no path, or prefix `/`, and no other constraint) is unchanged. `useDestinationHost`, or passive health with `enabled` not false, also leaves terminate-lite. `server.forwardedHeaders` is off until an action other than Off is set. A peer outside `trustedProxies` cannot append to an inbound X-Forwarded-* value. Setting any forwarded action leaves terminate-lite. A route `policies` list can require a header, check an API key, cap the request body, answer a CORS preflight, or apply a rate, connection, or address limit before a destination is chosen. JWT and distributed rate limits stay in Titanium Plus. An empty policy list leaves terminate-lite alone. An `extAuth` policy asks an external service whether to proceed. It sends a bodyless subrequest to `url` (`method` defaults to GET) that carries the `sendHeaders` (default Authorization and Cookie) plus `X-Original-Method`, `X-Original-Uri`, `X-Original-Host`, and `X-Forwarded-For`. Any 2xx lets the request through, and the headers named in `copyResponseHeaders` replace the same-named client headers before the origin sees them. Any other status is answered to the client with that status, a body of at most 64 KiB, and only the `denyResponseHeaders` (default Location, Set-Cookie, WWW-Authenticate, Content-Type). Redirects are relayed, never followed. A timeout (`timeoutMilliseconds`, default 2000, 1 to 60000), a connection error, or a 5xx answers 503 unless `failOpen` is true, in which case the request proceeds and `twp.extauth.requests` counts `error_open`. `cacheSeconds` (default 0) keeps a 2xx verdict for the same method, host, path, and `sendHeaders` values, bounded by `cacheMaxEntries` (default 10000, oldest evicted first). A subrequest carrying `X-Titanium-ExtAuth: 1` is refused with 508 so two proxies cannot loop. A route that lists an `extAuth` policy leaves terminate-lite; routes without one are unchanged. Each request that misses the cache waits for one extra round trip to the service, so keep it on the same host or network and use `cacheSeconds` where a short-lived verdict is acceptable. The `twp-cli-extauth-http1` arm of the load probe measures that cost against an allowing service; re-measure on your own hardware before relying on a number. A route `clusters` list splits traffic by weight. `redirect.httpToHttps` upgrades a cleartext redirect. `redirect.replacePrefixFrom` with `redirect.replacePrefixTo` swaps a leading path prefix on a segment boundary and keeps the rest and the query; an explicit `redirect.path` wins. With `redirect.useRequestHost` and no `redirect.host`, the `Location` is built from the host the client sent; the request port is kept only when neither `redirect.scheme` nor `redirect.port` is set (and `httpToHttps` did not upgrade), and a `Host` that is not a plain name or address, or an absolute-form request target, gets a relative `Location`. `errorPages` replaces the body of a generated status. `sessionAffinity` pins an opaque cookie. `hashKey` feeds consistent hashing. Listener `passthrough` copies TCP bytes, and `acceptProxyProtocol` reads a PROXY preamble. When any cluster uses `LeastTime`, the CLI automatically enables request timing capture so latency EWMA can drive selection.
`retry` also replays a gateway answer onto another destination before anything reaches the client. `attempts` bounds the replays, `statusCodes` replaces the default 502, 503, and 504 (429 and 500-599 are accepted), and `perTryTimeoutMilliseconds` ends a slow try as a 504 on every try but the last. `backoffMilliseconds` doubles per replay up to `maxBackoffMilliseconds`, with `jitterPercent` added. `honorRetryAfter` takes a delta-seconds `Retry-After` instead, capped the same way. `budgetPercent` limits replays in flight for a cluster to that share of its in-flight requests, never below `budgetMinConcurrent`, and refused replays count in `twp.proxy.status.retries_refused`. HTTP/2 origins also replay a request whose body was fully buffered. See [Protocol support](https://github.com/justcoding121/titanium-web-proxy/wiki/Protocol-Support) for the paths that retry.
diff --git a/website/docs/gateway-api.md b/website/docs/gateway-api.md
index 397fddeeb..441df9ed6 100644
--- a/website/docs/gateway-api.md
+++ b/website/docs/gateway-api.md
@@ -128,7 +128,6 @@ The controller never guesses. A rule it cannot honor is answered with `500` and
| `Gateway.spec.addresses` | `Accepted` `False`, reason `UnsupportedAddress`. The address comes from the data-plane Service. |
| Header `RegularExpression` that uses lookahead, a backreference, or an unbalanced parenthesis | The rule answers `500` (`UnsupportedValue`). |
| `RequestRedirect` with `ReplacePrefixMatch` on a match that is not a `PathPrefix` | The rule answers `500` (a prefix is needed to replace). |
-| `RequestRedirect` without a `hostname` | The redirect needs a concrete host, so the route must list concrete `hostnames`. |
| More than one `RequestMirror` in a rule | The rule answers `500`; the data plane mirrors to one cluster. |
| A listener on a port not in `dataPlane.ports` | Listener not programmed, reason `PortUnavailable`. |
@@ -139,6 +138,7 @@ These follow from how the data plane works. They are deliberate and listed so no
- **Gateways of one class share one data plane.** Listeners of different Gateways on the same port merge into one data-plane listener, and routes are not scoped to a Gateway. Two listeners with the same hostname conflict only inside one Gateway. A request no route matches answers `404`; it is never forwarded to the host the client named.
- **`add` in a header modifier joins values with `, `.** A request or response header that already carries a value gets the new one appended (`foo` and `bar` become `foo, bar`), which is the same list as the specification's `foo,bar`. Repeated lines of one header collapse into one. `Set-Cookie` is the exception: each added cookie keeps its own line.
- **Routes are global across listeners.** The data plane matches routes by host, path, header, query, and method, not by listener port. Two Gateway listeners that would serve different routes on different ports serve the same routes.
+- **A hostless `RequestRedirect` keeps a non-default request port only when the redirect sets neither `scheme` nor `port`.** The request `Host` is the only listener-port signal the data plane has. A configured scheme (or `httpToHttps`) uses the well-known port and omits it from `Location`.
- **A mix of valid and invalid backends answers `503` for the invalid share**, where the specification says `500`. When no backend of a rule is usable, the rule answers `500`.
- **Header names match case-insensitively**, and so do `Exact` header values. The specification makes values case-sensitive. `RegularExpression` header values are case-sensitive and match the whole value, and only the first value of a repeated header is tested.
- **Upgrade the data plane with the controller.** An older data plane ignores `headerMatches` and routes without that constraint.
@@ -152,7 +152,7 @@ The repository runs the upstream Gateway API conformance suite (v1.2.1, profile
- `tools/gateway-conformance/run.sh` builds both images, creates a `kind` cluster, installs MetalLB (so the data-plane Service gets an address), installs the chart with `deploy/helm/titanium-gateway/ci/kind-values.yaml`, runs the suite in a `golang` container on the `kind` network, and writes the report to `artifacts/gateway-conformance-report.yaml`.
- `.github/workflows/gateway-conformance.yml` runs it on Linux for pull requests that touch the controller, the chart, or the images, and weekly. It uploads the report. **The first runs report only**: a failing suite does not fail the workflow until the repository variable `GATEWAY_CONFORMANCE_ENFORCE` is `true`.
- **It needs Linux, Docker, `kind`, `kubectl`, and Helm. It does not run on a Windows host**, because the suite reaches the Gateway address over the container network; use WSL 2 or a Linux machine.
-- Only features this controller implements are claimed (`--supported-features`). Not claimed: `HTTPRouteResponseHeaderModification` (response header `add` now appends, but the feature is not claimed until the suite has been re-run against it), redirects without a hostname, the request and backend timeout features (not yet verified against the suite), `GRPCRoute`, `TLSRoute`, and port-based listener features. The report lists what passed, failed, and was skipped.
+- Only features this controller implements are claimed (`--supported-features`). Not claimed: `HTTPRouteResponseHeaderModification` (response header `add` now appends, but the feature is not claimed until the suite has been re-run against it), hostless redirects (`HTTPRouteSchemeRedirect`, `HTTPRoutePortRedirect`, `HTTPRoutePathRedirect` — the data plane builds them, but the suite has not been re-run), the request and backend timeout features (not yet verified against the suite), `GRPCRoute`, `TLSRoute`, and port-based listener features. The report lists what passed, failed, and was skipped.
**Status.** The suite was last run on a `kind` cluster (Linux containers on Docker Desktop) before two data-plane fixes: 31 of the 33 core tests passed, and 2 of the 5 extended tests. The failures had two causes, both in the data plane rather than the controller, and both are now fixed in the data plane:
diff --git a/wiki/Gateway-API.md b/wiki/Gateway-API.md
index bff758da0..3565f2ff0 100644
--- a/wiki/Gateway-API.md
+++ b/wiki/Gateway-API.md
@@ -38,7 +38,7 @@ The controller watches ReferenceGrant **v1**. Helm does not upgrade CRDs that ar
## Conformance
-`tools/gateway-conformance/run.sh` runs the upstream Gateway API conformance suite (v1.2.1, `GATEWAY-HTTP`) against the controller in a `kind` cluster; `.github/workflows/gateway-conformance.yml` runs it on Linux and uploads the report. It needs Linux, Docker, `kind`, `kubectl`, and Helm, and does not run on a Windows host (use WSL 2). Only implemented features are claimed. The last run (before the data-plane header-append and SNI-handshake fixes) passed 31 of 33 core tests; it has not been re-run since. The first runs report only; setting the repository variable `GATEWAY_CONFORMANCE_ENFORCE` to `true` makes a failing suite fail the workflow.
+`tools/gateway-conformance/run.sh` runs the upstream Gateway API conformance suite (v1.2.1, `GATEWAY-HTTP`) against the controller in a `kind` cluster; `.github/workflows/gateway-conformance.yml` runs it on Linux and uploads the report. It needs Linux, Docker, `kind`, `kubectl`, and Helm, and does not run on a Windows host (use WSL 2). Only implemented features are claimed; hostless redirects and response-header `add` stay unclaimed until the suite has been re-run. The last run (before the data-plane header-append and SNI-handshake fixes) passed 31 of 33 core tests; it has not been re-run since. The first runs report only; setting the repository variable `GATEWAY_CONFORMANCE_ENFORCE` to `true` makes a failing suite fail the workflow.
## Ingress