diff --git a/docs/reference/schema.md b/docs/reference/schema.md
index ae4bd999..876ffa3f 100644
--- a/docs/reference/schema.md
+++ b/docs/reference/schema.md
@@ -526,6 +526,11 @@ The port of the binding.
The protocol (`http` or `https`).
+#### `ip` (`string`)
+
+The optional IP adress to bind to. Can be '*' for all addresses.
+Default is localhost.
+
## IngressRule
`IngressRule` elements appear in an array within the `rules` property of the `Ingress` element. Rules configure the routing behavior of the ingress proxy.
diff --git a/src/Microsoft.Tye.Core/ApplicationFactory.cs b/src/Microsoft.Tye.Core/ApplicationFactory.cs
index 37377495..dd126b47 100644
--- a/src/Microsoft.Tye.Core/ApplicationFactory.cs
+++ b/src/Microsoft.Tye.Core/ApplicationFactory.cs
@@ -461,6 +461,7 @@ namespace Microsoft.Tye
Name = configBinding.Name,
Port = configBinding.Port,
Protocol = configBinding.Protocol ?? "http",
+ IPAddress = configBinding.IPAddress,
};
ingress.Bindings.Add(binding);
}
diff --git a/src/Microsoft.Tye.Core/ConfigModel/ConfigIngressBinding.cs b/src/Microsoft.Tye.Core/ConfigModel/ConfigIngressBinding.cs
index b5fe74bb..a95b88ef 100644
--- a/src/Microsoft.Tye.Core/ConfigModel/ConfigIngressBinding.cs
+++ b/src/Microsoft.Tye.Core/ConfigModel/ConfigIngressBinding.cs
@@ -9,5 +9,6 @@ namespace Microsoft.Tye.ConfigModel
public string? Name { get; set; }
public int? Port { get; set; }
public string? Protocol { get; set; } // HTTP or HTTPS
+ public string? IPAddress { get; set; } // Can be * or any address to listen on
}
}
diff --git a/src/Microsoft.Tye.Core/CoreStrings.resx b/src/Microsoft.Tye.Core/CoreStrings.resx
index 350964f0..c1ca0b98 100644
--- a/src/Microsoft.Tye.Core/CoreStrings.resx
+++ b/src/Microsoft.Tye.Core/CoreStrings.resx
@@ -153,6 +153,9 @@
"{value}" value must be an integer.
+
+ "{value}" value must be an IP address, "*" or "localhost".
+
"{value}" value cannot be negative.
diff --git a/src/Microsoft.Tye.Core/IngressBindingBuilder.cs b/src/Microsoft.Tye.Core/IngressBindingBuilder.cs
index 8a3e1cb6..6153b342 100644
--- a/src/Microsoft.Tye.Core/IngressBindingBuilder.cs
+++ b/src/Microsoft.Tye.Core/IngressBindingBuilder.cs
@@ -9,5 +9,6 @@ namespace Microsoft.Tye
public string? Name { get; set; }
public int? Port { get; set; }
public string? Protocol { get; set; } // HTTP or HTTPS
+ public string? IPAddress { get; set; }
}
}
diff --git a/src/Microsoft.Tye.Core/Serialization/ConfigIngressParser.cs b/src/Microsoft.Tye.Core/Serialization/ConfigIngressParser.cs
index bea20051..602bc02d 100644
--- a/src/Microsoft.Tye.Core/Serialization/ConfigIngressParser.cs
+++ b/src/Microsoft.Tye.Core/Serialization/ConfigIngressParser.cs
@@ -2,7 +2,9 @@
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
+using System;
using System.Collections.Generic;
+using System.Net;
using Microsoft.Tye.ConfigModel;
using YamlDotNet.RepresentationModel;
@@ -144,6 +146,17 @@ namespace Tye.Serialization
binding.Port = port;
break;
+ case "ip":
+ if (YamlParser.GetScalarValue(key, child.Value) is string ipString
+ && (IPAddress.TryParse(ipString, out var ip) || ipString == "*" || ipString.Equals("localhost", StringComparison.OrdinalIgnoreCase)))
+ {
+ binding.IPAddress = ipString;
+ }
+ else
+ {
+ throw new TyeYamlException(child.Value.Start, CoreStrings.FormatMustBeAnIPAddress(key));
+ }
+ break;
case "protocol":
binding.Protocol = YamlParser.GetScalarValue(key, child.Value);
break;
diff --git a/src/Microsoft.Tye.Hosting/HttpProxyService.cs b/src/Microsoft.Tye.Hosting/HttpProxyService.cs
index cf3b1a08..c132eb6a 100644
--- a/src/Microsoft.Tye.Hosting/HttpProxyService.cs
+++ b/src/Microsoft.Tye.Hosting/HttpProxyService.cs
@@ -76,7 +76,8 @@ namespace Microsoft.Tye.Hosting
var port = binding.ReplicaPorts[i];
ports.Add(port);
- var url = $"{binding.Protocol}://localhost:{port}";
+
+ var url = $"{binding.Protocol}://{binding.IPAddress ?? "localhost"}:{port}";
urls.Add(url);
}
diff --git a/src/Microsoft.Tye.Hosting/Model/ServiceBinding.cs b/src/Microsoft.Tye.Hosting/Model/ServiceBinding.cs
index ac08ea03..2adaa479 100644
--- a/src/Microsoft.Tye.Hosting/Model/ServiceBinding.cs
+++ b/src/Microsoft.Tye.Hosting/Model/ServiceBinding.cs
@@ -3,6 +3,7 @@
// See the LICENSE file in the project root for more information.
using System.Collections.Generic;
+using System.Net;
namespace Microsoft.Tye.Hosting.Model
{
@@ -13,6 +14,7 @@ namespace Microsoft.Tye.Hosting.Model
public int? Port { get; set; }
public int? ContainerPort { get; set; }
public string? Host { get; set; }
+ public string? IPAddress { get; set; }
public string? Protocol { get; set; }
public List ReplicaPorts { get; } = new List();
}
diff --git a/src/schema/tye-schema.json b/src/schema/tye-schema.json
index 2d8685cf..f7fcf9da 100644
--- a/src/schema/tye-schema.json
+++ b/src/schema/tye-schema.json
@@ -463,6 +463,10 @@
"protocol": {
"description": "The protocol used by the binding",
"type": "string"
+ },
+ "ip": {
+ "description": "The ip address the ingress listens on.",
+ "type": "string"
}
}
},
diff --git a/src/tye/ApplicationBuilderExtensions.cs b/src/tye/ApplicationBuilderExtensions.cs
index 34256471..350e860f 100644
--- a/src/tye/ApplicationBuilderExtensions.cs
+++ b/src/tye/ApplicationBuilderExtensions.cs
@@ -212,6 +212,7 @@ namespace Microsoft.Tye
Name = binding.Name,
Port = binding.Port,
Protocol = binding.Protocol,
+ IPAddress = binding.IPAddress,
});
}
diff --git a/test/E2ETest/Microsoft.Tye.E2ETests.csproj b/test/E2ETest/Microsoft.Tye.E2ETests.csproj
index 08c75665..5c5078ce 100644
--- a/test/E2ETest/Microsoft.Tye.E2ETests.csproj
+++ b/test/E2ETest/Microsoft.Tye.E2ETests.csproj
@@ -45,4 +45,4 @@
-
\ No newline at end of file
+
diff --git a/test/E2ETest/TyeRunTests.cs b/test/E2ETest/TyeRunTests.cs
index 03261a90..ba2940fa 100644
--- a/test/E2ETest/TyeRunTests.cs
+++ b/test/E2ETest/TyeRunTests.cs
@@ -9,6 +9,8 @@ using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
+using System.Net.NetworkInformation;
+using System.Net.Sockets;
using System.Runtime.InteropServices;
using System.Text.Json;
using System.Text.Json.Serialization;
@@ -767,6 +769,101 @@ services:
});
}
+ [Fact]
+ public async Task IngressSpecificIPTest()
+ {
+ var allIps = GetLiveIPAddresses().ToList();
+ var testIp = allIps[new Random().Next(allIps.Count)];
+ await TestIngressIP($"'{testIp}'", new[] { testIp }, allIps.Where(ip => ip != testIp).Take(1));
+ }
+
+
+ [Fact]
+ public async Task IngressAllIPv6Test()
+ {
+ var ipV6 = GetLiveIPAddresses(AddressFamily.InterNetworkV6).FirstOrDefault();
+ if (ipV6 == null) return;
+ await TestIngressIP($"'{IPAddress.IPv6Any}'", ipV6);
+ }
+
+ [Fact]
+ public async Task IngressAllIPv4Test()
+ {
+ var ipV4 = GetLiveIPAddresses(AddressFamily.InterNetwork).FirstOrDefault();
+ if (ipV4 == null) return;
+ var ipV6 = GetLiveIPAddresses(AddressFamily.InterNetworkV6).FirstOrDefault();
+ var failIp = ipV6 == null ? Enumerable.Empty() : new[] { ipV6 };
+ await TestIngressIP($"'{IPAddress.Any}'", new[] { ipV4 }, failIp);
+ }
+
+ [Fact]
+ public async Task IngressAllIPTest()
+ {
+ await TestIngressIP($"'*'", GetLiveIPAddresses().FirstOrDefault());
+ }
+
+
+ private static IEnumerable GetLiveIPAddresses(AddressFamily? family = null)
+ {
+ return from ni in NetworkInterface.GetAllNetworkInterfaces()
+ where ni.OperationalStatus == OperationalStatus.Up
+ let prop = ni.GetIPProperties()
+ from unicast in prop.UnicastAddresses
+ let addr = unicast.Address
+ where addr != IPAddress.IPv6Loopback && (family == null || addr.AddressFamily == family)
+ select addr;
+ }
+
+ private Task TestIngressIP(string ipSetting, params IPAddress[] mustAnswer) => TestIngressIP(ipSetting, mustAnswer, Enumerable.Empty());
+ private async Task TestIngressIP(string ipSetting, IEnumerable mustAnswer, IEnumerable mustFail)
+ {
+#if !DEBUG
+ if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
+ return; //disables running this test on windows as it stucks the test runner on the firewall open prompt
+#endif
+ if (!mustAnswer.Any() && !mustFail.Any())
+ return; // no IP to test against
+
+ using var projectDirectory = CopyTestProjectDirectory("apps-with-ingress");
+ var projectFile = new FileInfo(Path.Combine(projectDirectory.DirectoryPath, "tye-ip_test.yaml"));
+ File.WriteAllText(projectFile.FullName, File.ReadAllText(projectFile.FullName).Replace("__TEST_IP_STRING__", ipSetting));
+ var outputContext = new OutputContext(_sink, Verbosity.Debug);
+ var application = await ApplicationFactory.CreateAsync(outputContext, projectFile);
+
+ var handler = new HttpClientHandler
+ {
+ ServerCertificateCustomValidationCallback = (a, b, c, d) => true,
+ AllowAutoRedirect = true
+ };
+
+ var client = new HttpClient(new RetryHandler(handler));
+
+ await RunHostingApplication(application, new HostOptions(), async (app, uri) =>
+ {
+ foreach (var ip in mustAnswer.Concat(mustFail))
+ {
+ try
+ {
+ var ingressUri = await GetServiceUrl(client, uri, "ingress");
+ var reqUri = new UriBuilder(ingressUri + "/index.html")
+ {
+ Host = ip.ToString()
+ };
+
+ var htmlRequest = new HttpRequestMessage(HttpMethod.Get, reqUri.Uri);
+ htmlRequest.Headers.Host = "ui.example.com";
+
+ var htmlResponse = await client.SendAsync(htmlRequest);
+ htmlResponse.EnsureSuccessStatusCode();
+ }
+ catch (Exception) when (mustFail.Contains(ip))
+ {
+ // this is an expected failure
+ }
+ }
+ });
+ }
+
[ConditionalFact]
[SkipIfDockerNotRunning]
public async Task NginxIngressTest()
diff --git a/test/E2ETest/testassets/projects/apps-with-ingress/tye-ip_test.yaml b/test/E2ETest/testassets/projects/apps-with-ingress/tye-ip_test.yaml
new file mode 100644
index 00000000..4e7cd599
--- /dev/null
+++ b/test/E2ETest/testassets/projects/apps-with-ingress/tye-ip_test.yaml
@@ -0,0 +1,20 @@
+# tye application configuration file
+# read all about it at https://github.com/dotnet/tye
+#
+# when you've given us a try, we'd love to know what you think:
+# https://aka.ms/AA7q20u
+#
+name: apps-with-ingress-allip-ui
+ingress:
+ - name: ingress
+ bindings:
+ - port: 8080
+ ip: __TEST_IP_STRING__
+ rules:
+ - host: ui.example.com
+ service: appC-ui
+
+services:
+ - name: appC-ui
+ project: ApplicationC-UI/ApplicationC-UI.csproj
+ replicas: 2
\ No newline at end of file