diff --git a/src/NzbDrone.Api/Config/HostConfigModule.cs b/src/NzbDrone.Api/Config/HostConfigModule.cs index 9326cf0db..6be688c69 100644 --- a/src/NzbDrone.Api/Config/HostConfigModule.cs +++ b/src/NzbDrone.Api/Config/HostConfigModule.cs @@ -15,12 +15,14 @@ namespace NzbDrone.Api.Config public class HostConfigModule : NzbDroneRestModule { private readonly IConfigFileProvider _configFileProvider; + private readonly IConfigService _configService; private readonly IUserService _userService; - public HostConfigModule(IConfigFileProvider configFileProvider, IUserService userService) + public HostConfigModule(IConfigFileProvider configFileProvider, IConfigService configService, IUserService userService) : base("/config/host") { _configFileProvider = configFileProvider; + _configService = configService; _userService = userService; GetResourceSingle = GetHostConfig; @@ -49,7 +51,7 @@ namespace NzbDrone.Api.Config private HostConfigResource GetHostConfig() { var resource = new HostConfigResource(); - resource.InjectFrom(_configFileProvider); + resource.InjectFrom(_configFileProvider, _configService); resource.Id = 1; var user = _userService.FindUser(); @@ -75,6 +77,7 @@ namespace NzbDrone.Api.Config .ToDictionary(prop => prop.Name, prop => prop.GetValue(resource, null)); _configFileProvider.SaveConfigDictionary(dictionary); + _configService.SaveConfigDictionary(dictionary); if (resource.Username.IsNotNullOrWhiteSpace() && resource.Password.IsNotNullOrWhiteSpace()) { diff --git a/src/NzbDrone.Api/Config/HostConfigResource.cs b/src/NzbDrone.Api/Config/HostConfigResource.cs index 247f46b15..a100556ff 100644 --- a/src/NzbDrone.Api/Config/HostConfigResource.cs +++ b/src/NzbDrone.Api/Config/HostConfigResource.cs @@ -2,6 +2,7 @@ using NzbDrone.Api.REST; using NzbDrone.Core.Authentication; using NzbDrone.Core.Update; +using NzbDrone.Common.Http.Proxy; namespace NzbDrone.Api.Config { @@ -25,5 +26,13 @@ namespace NzbDrone.Api.Config public bool UpdateAutomatically { get; set; } public UpdateMechanism UpdateMechanism { get; set; } public string UpdateScriptPath { get; set; } + public bool ProxyEnabled { get; set; } + public ProxyType ProxyType { get; set; } + public string ProxyHostname { get; set; } + public int ProxyPort { get; set; } + public string ProxyUsername { get; set; } + public string ProxyPassword { get; set; } + public string ProxyBypassFilter { get; set; } + public bool ProxyBypassLocalAddresses { get; set; } } } diff --git a/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs b/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs index 2dfca280c..d4a74e32a 100644 --- a/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs +++ b/src/NzbDrone.Common.Test/Http/HttpClientFixture.cs @@ -11,6 +11,7 @@ using NUnit.Framework; using NzbDrone.Common.Cache; using NzbDrone.Common.Http; using NzbDrone.Common.Http.Dispatchers; +using NzbDrone.Common.Http.Proxy; using NzbDrone.Common.TPL; using NzbDrone.Test.Common; using NzbDrone.Test.Common.Categories; @@ -21,20 +22,33 @@ namespace NzbDrone.Common.Test.Http [TestFixture(typeof(ManagedHttpDispatcher))] [TestFixture(typeof(CurlHttpDispatcher))] public class HttpClientFixture : TestBase where TDispatcher : IHttpDispatcher - { + { + private static string[] _httpBinHosts = new[] { "eu.httpbin.org", "httpbin.org" }; + private static int _httpBinRandom; + private string _httpBinHost; + [SetUp] public void SetUp() { Mocker.SetConstant(Mocker.Resolve()); + Mocker.SetConstant(Mocker.Resolve()); Mocker.SetConstant(Mocker.Resolve()); Mocker.SetConstant>(new IHttpRequestInterceptor[0]); Mocker.SetConstant(Mocker.Resolve()); + + // Used for manual testing of socks proxies. + //Mocker.GetMock() + // .Setup(v => v.GetProxySettings(It.IsAny())) + // .Returns(new HttpProxySettings(ProxyType.Socks5, "127.0.0.1", 5476, "", false)); + + // Roundrobin over the two servers, to reduce the chance of hitting the ratelimiter. + _httpBinHost = _httpBinHosts[_httpBinRandom++ % _httpBinHosts.Length]; } [Test] public void should_execute_simple_get() { - var request = new HttpRequest("http://eu.httpbin.org/get"); + var request = new HttpRequest(string.Format("http://{0}/get", _httpBinHost)); var response = Subject.Execute(request); @@ -44,7 +58,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_execute_https_get() { - var request = new HttpRequest("https://eu.httpbin.org/get"); + var request = new HttpRequest(string.Format("https://{0}/get", _httpBinHost)); var response = Subject.Execute(request); @@ -54,7 +68,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_execute_typed_get() { - var request = new HttpRequest("http://eu.httpbin.org/get"); + var request = new HttpRequest(string.Format("http://{0}/get", _httpBinHost)); var response = Subject.Get(request); @@ -66,7 +80,7 @@ namespace NzbDrone.Common.Test.Http { var message = "{ my: 1 }"; - var request = new HttpRequest("http://eu.httpbin.org/post"); + var request = new HttpRequest(string.Format("http://{0}/post", _httpBinHost)); request.SetContent(message); var response = Subject.Post(request); @@ -77,7 +91,7 @@ namespace NzbDrone.Common.Test.Http [TestCase("gzip")] public void should_execute_get_using_gzip(string compression) { - var request = new HttpRequest("http://eu.httpbin.org/" + compression); + var request = new HttpRequest(string.Format("http://{0}/{1}", _httpBinHost, compression)); var response = Subject.Get(request); @@ -93,7 +107,7 @@ namespace NzbDrone.Common.Test.Http [TestCase(HttpStatusCode.BadGateway)] public void should_throw_on_unsuccessful_status_codes(int statusCode) { - var request = new HttpRequest("http://eu.httpbin.org/status/" + statusCode); + var request = new HttpRequest(string.Format("http://{0}/status/{1}", _httpBinHost, statusCode)); var exception = Assert.Throws(() => Subject.Get(request)); @@ -105,7 +119,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_not_follow_redirects_when_not_in_production() { - var request = new HttpRequest("http://eu.httpbin.org/redirect/1"); + var request = new HttpRequest(string.Format("http://{0}/redirect/1", _httpBinHost)); Subject.Get(request); @@ -115,7 +129,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_follow_redirects() { - var request = new HttpRequest("http://eu.httpbin.org/redirect/1"); + var request = new HttpRequest(string.Format("http://{0}/redirect/1", _httpBinHost)); request.AllowAutoRedirect = true; Subject.Get(request); @@ -126,7 +140,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_send_user_agent() { - var request = new HttpRequest("http://eu.httpbin.org/get"); + var request = new HttpRequest(string.Format("http://{0}/get", _httpBinHost)); var response = Subject.Get(request); @@ -140,7 +154,7 @@ namespace NzbDrone.Common.Test.Http [TestCase("Accept", "text/xml, text/rss+xml, application/rss+xml")] public void should_send_headers(string header, string value) { - var request = new HttpRequest("http://eu.httpbin.org/get"); + var request = new HttpRequest(string.Format("http://{0}/get", _httpBinHost)); request.Headers.Add(header, value); var response = Subject.Get(request); @@ -163,7 +177,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_send_cookie() { - var request = new HttpRequest("http://eu.httpbin.org/get"); + var request = new HttpRequest(string.Format("http://{0}/get", _httpBinHost)); request.Cookies["my"] = "cookie"; var response = Subject.Get(request); @@ -220,12 +234,12 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_not_store_response_cookie() { - var requestSet = new HttpRequest("http://eu.httpbin.org/cookies/set?my=cookie"); + var requestSet = new HttpRequest(string.Format("http://{0}/cookies/set?my=cookie", _httpBinHost)); requestSet.AllowAutoRedirect = false; var responseSet = Subject.Get(requestSet); - var request = new HttpRequest("http://eu.httpbin.org/get"); + var request = new HttpRequest(string.Format("http://{0}/get", _httpBinHost)); var response = Subject.Get(request); @@ -237,13 +251,13 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_store_response_cookie() { - var requestSet = new HttpRequest("http://eu.httpbin.org/cookies/set?my=cookie"); + var requestSet = new HttpRequest(string.Format("http://{0}/cookies/set?my=cookie", _httpBinHost)); requestSet.AllowAutoRedirect = false; requestSet.StoreResponseCookie = true; var responseSet = Subject.Get(requestSet); - var request = new HttpRequest("http://eu.httpbin.org/get"); + var request = new HttpRequest(string.Format("http://{0}/get", _httpBinHost)); var response = Subject.Get(request); @@ -259,14 +273,14 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_overwrite_response_cookie() { - var requestSet = new HttpRequest("http://eu.httpbin.org/cookies/set?my=cookie"); + var requestSet = new HttpRequest(string.Format("http://{0}/cookies/set?my=cookie", _httpBinHost)); requestSet.AllowAutoRedirect = false; requestSet.StoreResponseCookie = true; requestSet.Cookies["my"] = "oldcookie"; var responseSet = Subject.Get(requestSet); - var request = new HttpRequest("http://eu.httpbin.org/get"); + var request = new HttpRequest(string.Format("http://{0}/get", _httpBinHost)); var response = Subject.Get(request); @@ -282,7 +296,7 @@ namespace NzbDrone.Common.Test.Http [Test] public void should_throw_on_http429_too_many_requests() { - var request = new HttpRequest("http://eu.httpbin.org/status/429"); + var request = new HttpRequest(string.Format("http://{0}/status/429", _httpBinHost)); Assert.Throws(() => Subject.Get(request)); @@ -302,7 +316,7 @@ namespace NzbDrone.Common.Test.Http .Setup(v => v.PostResponse(It.IsAny())) .Returns(r => r); - var request = new HttpRequest("http://eu.httpbin.org/get"); + var request = new HttpRequest(string.Format("http://{0}/get", _httpBinHost)); Subject.Get(request); @@ -324,7 +338,7 @@ namespace NzbDrone.Common.Test.Http { // the date is bad in the below - should be 13-Jul-2016 string malformedCookie = @"__cfduid=d29e686a9d65800021c66faca0a29b4261436890790; expires=Wed, 13-Jul-16 16:19:50 GMT; path=/; HttpOnly"; - var requestSet = new HttpRequestBuilder("http://eu.httpbin.org/response-headers") + var requestSet = new HttpRequestBuilder(string.Format("http://{0}/response-headers", _httpBinHost)) .AddQueryParam("Set-Cookie", malformedCookie) .Build(); @@ -333,7 +347,7 @@ namespace NzbDrone.Common.Test.Http var responseSet = Subject.Get(requestSet); - var request = new HttpRequest("http://eu.httpbin.org/get"); + var request = new HttpRequest(string.Format("http://{0}/get", _httpBinHost)); var response = Subject.Get(request); @@ -358,7 +372,7 @@ namespace NzbDrone.Common.Test.Http try { // the date is bad in the below - should be 13-Jul-2016 - string url = "http://eu.httpbin.org/response-headers?Set-Cookie=" + Uri.EscapeUriString(malformedCookie); + string url = string.Format("http://{0}/response-headers?Set-Cookie={1}", _httpBinHost, Uri.EscapeUriString(malformedCookie)); var requestSet = new HttpRequest(url); requestSet.AllowAutoRedirect = false; @@ -366,7 +380,7 @@ namespace NzbDrone.Common.Test.Http var responseSet = Subject.Get(requestSet); - var request = new HttpRequest("http://eu.httpbin.org/get"); + var request = new HttpRequest(string.Format("http://{0}/get", _httpBinHost)); var response = Subject.Get(request); @@ -378,21 +392,6 @@ namespace NzbDrone.Common.Test.Http { } } - - public void should_submit_formparameters_in_body() - { - Assert.Fail(); - } - - public void should_submit_attachments_as_multipart() - { - Assert.Fail(); - } - - public void should_submit_formparameters_as_multipart_if_attachments_exist() - { - Assert.Fail(); - } } public class HttpBinResource diff --git a/src/NzbDrone.Common/Http/Dispatchers/CurlHttpDispatcher.cs b/src/NzbDrone.Common/Http/Dispatchers/CurlHttpDispatcher.cs index 9ea8d1a80..72f0cc30f 100644 --- a/src/NzbDrone.Common/Http/Dispatchers/CurlHttpDispatcher.cs +++ b/src/NzbDrone.Common/Http/Dispatchers/CurlHttpDispatcher.cs @@ -4,6 +4,7 @@ using System.IO; using System.IO.Compression; using System.Linq; using System.Net; +using System.Reflection; using System.Runtime.InteropServices; using System.Text; using System.Text.RegularExpressions; @@ -11,8 +12,7 @@ using CurlSharp; using NLog; using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Common.Extensions; -using NzbDrone.Common.Instrumentation; -using System.Reflection; +using NzbDrone.Common.Http.Proxy; namespace NzbDrone.Common.Http.Dispatchers { @@ -20,7 +20,8 @@ namespace NzbDrone.Common.Http.Dispatchers { private static readonly Regex ExpiryDate = new Regex(@"(expires=)([^;]+)", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static readonly Logger _logger = NzbDroneLogger.GetLogger(typeof(CurlHttpDispatcher)); + private readonly IHttpProxySettingsProvider _proxySettingsProvider; + private readonly Logger _logger; private const string _caBundleFileName = "curl-ca-bundle.crt"; private static readonly string _caBundleFilePath; @@ -36,8 +37,14 @@ namespace NzbDrone.Common.Http.Dispatchers _caBundleFilePath = _caBundleFileName; } } + + public CurlHttpDispatcher(IHttpProxySettingsProvider proxySettingsProvider, Logger logger) + { + _proxySettingsProvider = proxySettingsProvider; + _logger = logger; + } - public static bool CheckAvailability() + public bool CheckAvailability() { try { @@ -76,7 +83,10 @@ namespace NzbDrone.Common.Http.Dispatchers return s * n; }; + AddProxy(curlEasy, request); + curlEasy.Url = request.Url.FullUri; + switch (request.Method) { case HttpMethod.GET: @@ -149,6 +159,34 @@ namespace NzbDrone.Common.Http.Dispatchers } } + private void AddProxy(CurlEasy curlEasy, HttpRequest request) + { + var proxySettings = _proxySettingsProvider.GetProxySettings(request); + if (proxySettings != null) + + { + switch (proxySettings.Type) + { + case ProxyType.Http: + curlEasy.SetOpt(CurlOption.ProxyType, CurlProxyType.Http); + curlEasy.SetOpt(CurlOption.ProxyAuth, CurlHttpAuth.Basic); + curlEasy.SetOpt(CurlOption.ProxyUserPwd, proxySettings.Username + ":" + proxySettings.Password.ToString()); + break; + case ProxyType.Socks4: + curlEasy.SetOpt(CurlOption.ProxyType, CurlProxyType.Socks4); + curlEasy.SetOpt(CurlOption.ProxyUsername, proxySettings.Username); + curlEasy.SetOpt(CurlOption.ProxyPassword, proxySettings.Password); + break; + case ProxyType.Socks5: + curlEasy.SetOpt(CurlOption.ProxyType, CurlProxyType.Socks5); + curlEasy.SetOpt(CurlOption.ProxyUsername, proxySettings.Username); + curlEasy.SetOpt(CurlOption.ProxyPassword, proxySettings.Password); + break; + } + curlEasy.SetOpt(CurlOption.Proxy, proxySettings.Host + ":" + proxySettings.Port.ToString()); + } + } + private CurlSlist SerializeHeaders(HttpRequest request) { if (!request.Headers.ContainsKey("Accept-Encoding")) diff --git a/src/NzbDrone.Common/Http/Dispatchers/FallbackHttpDispatcher.cs b/src/NzbDrone.Common/Http/Dispatchers/FallbackHttpDispatcher.cs index 0fc64af8e..109d4aec2 100644 --- a/src/NzbDrone.Common/Http/Dispatchers/FallbackHttpDispatcher.cs +++ b/src/NzbDrone.Common/Http/Dispatchers/FallbackHttpDispatcher.cs @@ -8,17 +8,18 @@ namespace NzbDrone.Common.Http.Dispatchers { public class FallbackHttpDispatcher : IHttpDispatcher { - private readonly Logger _logger; - private readonly ICached _curlTLSFallbackCache; private readonly ManagedHttpDispatcher _managedDispatcher; private readonly CurlHttpDispatcher _curlDispatcher; + private readonly Logger _logger; + + private readonly ICached _curlTLSFallbackCache; - public FallbackHttpDispatcher(ICached curlTLSFallbackCache, Logger logger) + public FallbackHttpDispatcher(ManagedHttpDispatcher managedDispatcher, CurlHttpDispatcher curlDispatcher, ICacheManager cacheManager, Logger logger) { + _managedDispatcher = managedDispatcher; + _curlDispatcher = curlDispatcher; + _curlTLSFallbackCache = cacheManager.GetCache(GetType(), "curlTLSFallback"); _logger = logger; - _curlTLSFallbackCache = curlTLSFallbackCache; - _managedDispatcher = new ManagedHttpDispatcher(); - _curlDispatcher = new CurlHttpDispatcher(); } public HttpResponse GetResponse(HttpRequest request, CookieContainer cookies) @@ -46,7 +47,7 @@ namespace NzbDrone.Common.Http.Dispatchers } } - if (CurlHttpDispatcher.CheckAvailability()) + if (_curlDispatcher.CheckAvailability()) { return _curlDispatcher.GetResponse(request, cookies); } diff --git a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs index 1ffbfd35a..b1802696a 100644 --- a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs +++ b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs @@ -1,11 +1,21 @@ using System; using System.Net; using NzbDrone.Common.Extensions; +using NzbDrone.Common.Http.Proxy; namespace NzbDrone.Common.Http.Dispatchers { public class ManagedHttpDispatcher : IHttpDispatcher { + private readonly IHttpProxySettingsProvider _proxySettingsProvider; + private readonly ICreateManagedWebProxy _createManagedWebProxy; + + public ManagedHttpDispatcher(IHttpProxySettingsProvider proxySettingsProvider, ICreateManagedWebProxy createManagedWebProxy) + { + _proxySettingsProvider = proxySettingsProvider; + _createManagedWebProxy = createManagedWebProxy; + } + public HttpResponse GetResponse(HttpRequest request, CookieContainer cookies) { var webRequest = (HttpWebRequest)WebRequest.Create((Uri)request.Url); @@ -26,6 +36,8 @@ namespace NzbDrone.Common.Http.Dispatchers webRequest.Timeout = (int)Math.Ceiling(request.RequestTimeout.TotalMilliseconds); } + AddProxy(webRequest, request); + if (request.Headers != null) { AddRequestHeaders(webRequest, request.Headers); @@ -69,6 +81,15 @@ namespace NzbDrone.Common.Http.Dispatchers return new HttpResponse(request, new HttpHeader(httpWebResponse.Headers), data, httpWebResponse.StatusCode); } + protected virtual void AddProxy(HttpWebRequest webRequest, HttpRequest request) + { + var proxySettings = _proxySettingsProvider.GetProxySettings(request); + if (proxySettings != null) + { + webRequest.Proxy = _createManagedWebProxy.GetWebProxy(proxySettings); + } + } + protected virtual void AddRequestHeaders(HttpWebRequest webRequest, HttpHeader headers) { foreach (var header in headers) diff --git a/src/NzbDrone.Common/Http/HttpClient.cs b/src/NzbDrone.Common/Http/HttpClient.cs index 8c4f5968c..bcf66ecc9 100644 --- a/src/NzbDrone.Common/Http/HttpClient.cs +++ b/src/NzbDrone.Common/Http/HttpClient.cs @@ -35,21 +35,15 @@ namespace NzbDrone.Common.Http public HttpClient(IEnumerable requestInterceptors, ICacheManager cacheManager, IRateLimitService rateLimitService, IHttpDispatcher httpDispatcher, Logger logger) { - _logger = logger; - _rateLimitService = rateLimitService; _requestInterceptors = requestInterceptors.ToList(); - ServicePointManager.DefaultConnectionLimit = 12; + _rateLimitService = rateLimitService; _httpDispatcher = httpDispatcher; + _logger = logger; + ServicePointManager.DefaultConnectionLimit = 12; _cookieContainerCache = cacheManager.GetCache(typeof(HttpClient)); } - public HttpClient(IEnumerable requestInterceptors, ICacheManager cacheManager, IRateLimitService rateLimitService, Logger logger) - : this(requestInterceptors, cacheManager, rateLimitService, null, logger) - { - _httpDispatcher = new FallbackHttpDispatcher(cacheManager.GetCache(typeof(HttpClient), "curlTLSFallback"), _logger); - } - public HttpResponse Execute(HttpRequest request) { foreach (var interceptor in _requestInterceptors) diff --git a/src/NzbDrone.Common/Http/HttpRequest.cs b/src/NzbDrone.Common/Http/HttpRequest.cs index 405ba6dfd..471cf19e8 100644 --- a/src/NzbDrone.Common/Http/HttpRequest.cs +++ b/src/NzbDrone.Common/Http/HttpRequest.cs @@ -16,7 +16,7 @@ namespace NzbDrone.Common.Http Headers = new HttpHeader(); AllowAutoRedirect = true; Cookies = new Dictionary(); - + if (!RuntimeInfoBase.IsProduction) { AllowAutoRedirect = false; diff --git a/src/NzbDrone.Common/Http/Proxy/HttpProxySettings.cs b/src/NzbDrone.Common/Http/Proxy/HttpProxySettings.cs new file mode 100644 index 000000000..84664bf8a --- /dev/null +++ b/src/NzbDrone.Common/Http/Proxy/HttpProxySettings.cs @@ -0,0 +1,63 @@ +using System; +using System.Net; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Common.Http.Proxy +{ + public class HttpProxySettings + { + public HttpProxySettings(ProxyType type, string host, int port, string bypassFilter, bool bypassLocalAddress, string username = null, string password = null) + { + Type = type; + Host = host.IsNullOrWhiteSpace() ? "127.0.0.1" : host; + Port = port; + Username = username ?? string.Empty; + Password = password ?? string.Empty; + BypassFilter = bypassFilter ?? string.Empty; + BypassLocalAddress = bypassLocalAddress; + } + + public ProxyType Type { get; private set; } + public string Host { get; private set; } + public int Port { get; private set; } + public string Username { get; private set; } + public string Password { get; private set; } + public string BypassFilter { get; private set; } + public bool BypassLocalAddress { get; private set; } + + public string[] BypassListAsArray + { + get + { + if (!string.IsNullOrWhiteSpace(BypassFilter)) + { + var hostlist = BypassFilter.Split(','); + for(int i = 0; i < hostlist.Length; i++) + { + if(hostlist[i].StartsWith("*")) + { + hostlist[i] = ";" + hostlist[i]; + } + } + return hostlist; + } + return new string[] { }; + } + } + + public string Key + { + get + { + return string.Join("_", + Type, + Host, + Port, + Username, + Password, + BypassFilter, + BypassLocalAddress); + } + } + } +} diff --git a/src/NzbDrone.Common/Http/Proxy/IHttpProxySettingsProvider.cs b/src/NzbDrone.Common/Http/Proxy/IHttpProxySettingsProvider.cs new file mode 100644 index 000000000..39ecbbbf0 --- /dev/null +++ b/src/NzbDrone.Common/Http/Proxy/IHttpProxySettingsProvider.cs @@ -0,0 +1,11 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace NzbDrone.Common.Http.Proxy +{ + public interface IHttpProxySettingsProvider + { + HttpProxySettings GetProxySettings(HttpRequest request); + } +} diff --git a/src/NzbDrone.Common/Http/Proxy/ManagedWebProxyFactory.cs b/src/NzbDrone.Common/Http/Proxy/ManagedWebProxyFactory.cs new file mode 100644 index 000000000..163435e84 --- /dev/null +++ b/src/NzbDrone.Common/Http/Proxy/ManagedWebProxyFactory.cs @@ -0,0 +1,86 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Net.Sockets; +using System.Text; +using com.LandonKey.SocksWebProxy; +using com.LandonKey.SocksWebProxy.Proxy; +using NzbDrone.Common.Cache; +using NzbDrone.Common.Extensions; + +namespace NzbDrone.Common.Http.Proxy +{ + public interface ICreateManagedWebProxy + { + IWebProxy GetWebProxy(HttpProxySettings proxySettings); + } + + public class ManagedWebProxyFactory : ICreateManagedWebProxy + { + private readonly ICached _webProxyCache; + + public ManagedWebProxyFactory(ICacheManager cacheManager) + { + _webProxyCache = cacheManager.GetCache(GetType(), "webProxy"); + } + + public IWebProxy GetWebProxy(HttpProxySettings proxySettings) + { + var proxy = _webProxyCache.Get(proxySettings.Key, () => CreateWebProxy(proxySettings), TimeSpan.FromMinutes(5)); + + _webProxyCache.ClearExpired(); + + return proxy; + } + private IWebProxy CreateWebProxy(HttpProxySettings proxySettings) + { + switch (proxySettings.Type) + { + case ProxyType.Http: + if (proxySettings.Username.IsNotNullOrWhiteSpace() && proxySettings.Password.IsNotNullOrWhiteSpace()) + { + return new WebProxy(proxySettings.Host + ":" + proxySettings.Port, proxySettings.BypassLocalAddress, proxySettings.BypassListAsArray, new NetworkCredential(proxySettings.Username, proxySettings.Password)); + } + else + { + return new WebProxy(proxySettings.Host + ":" + proxySettings.Port, proxySettings.BypassLocalAddress, proxySettings.BypassListAsArray); + } + case ProxyType.Socks4: + return new SocksWebProxy(new ProxyConfig(IPAddress.Loopback, GetNextFreePort(), GetProxyIpAddress(proxySettings.Host), proxySettings.Port, ProxyConfig.SocksVersion.Four, proxySettings.Username, proxySettings.Password), false); + case ProxyType.Socks5: + return new SocksWebProxy(new ProxyConfig(IPAddress.Loopback, GetNextFreePort(), GetProxyIpAddress(proxySettings.Host), proxySettings.Port, ProxyConfig.SocksVersion.Five, proxySettings.Username, proxySettings.Password), false); + } + + return null; + } + + private static IPAddress GetProxyIpAddress(string host) + { + IPAddress ipAddress; + if (!IPAddress.TryParse(host, out ipAddress)) + { + try + { + ipAddress = Dns.GetHostEntry(host).AddressList.OrderByDescending(a => a.AddressFamily == AddressFamily.InterNetwork).First(); + } + catch (Exception e) + { + throw new InvalidOperationException(string.Format("Unable to resolve proxy hostname '{0}' to a valid IP address.", host), e); + } + } + + return ipAddress; + } + + private static int GetNextFreePort() + { + var listener = new TcpListener(IPAddress.Loopback, 0); + listener.Start(); + var port = ((IPEndPoint)listener.LocalEndpoint).Port; + listener.Stop(); + + return port; + } + } +} diff --git a/src/NzbDrone.Common/Http/Proxy/ProxyType.cs b/src/NzbDrone.Common/Http/Proxy/ProxyType.cs new file mode 100644 index 000000000..19f3ea38e --- /dev/null +++ b/src/NzbDrone.Common/Http/Proxy/ProxyType.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Common.Http.Proxy +{ + public enum ProxyType + { + Http, + Socks4, + Socks5 + } +} diff --git a/src/NzbDrone.Common/NzbDrone.Common.csproj b/src/NzbDrone.Common/NzbDrone.Common.csproj index 8fc2c1e82..d0d40b81f 100644 --- a/src/NzbDrone.Common/NzbDrone.Common.csproj +++ b/src/NzbDrone.Common/NzbDrone.Common.csproj @@ -45,6 +45,13 @@ ..\packages\NLog.4.3.0-rc1\lib\net40\NLog.dll + + + ..\packages\DotNet4.SocksProxy.1.1.0.0\lib\net40\Org.Mentalis.dll + True + + + ..\packages\DotNet4.SocksProxy.1.1.0.0\lib\net40\SocksWebProxy.dll True @@ -150,6 +157,7 @@ + Component @@ -161,8 +169,10 @@ + + @@ -171,6 +181,7 @@ + diff --git a/src/NzbDrone.Common/packages.config b/src/NzbDrone.Common/packages.config index 006141366..b1757923b 100644 --- a/src/NzbDrone.Common/packages.config +++ b/src/NzbDrone.Common/packages.config @@ -1,5 +1,6 @@  + diff --git a/src/NzbDrone.Core.Test/Framework/CoreTest.cs b/src/NzbDrone.Core.Test/Framework/CoreTest.cs index 89a3a02c1..3a2f01a9c 100644 --- a/src/NzbDrone.Core.Test/Framework/CoreTest.cs +++ b/src/NzbDrone.Core.Test/Framework/CoreTest.cs @@ -3,8 +3,12 @@ using NUnit.Framework; using NzbDrone.Common.Cache; using NzbDrone.Common.Cloud; using NzbDrone.Common.Http; +using NzbDrone.Common.Http.Dispatchers; using NzbDrone.Common.TPL; using NzbDrone.Test.Common; +using NzbDrone.Common.Http.Proxy; +using NzbDrone.Core.Http; +using NzbDrone.Core.Configuration; namespace NzbDrone.Core.Test.Framework { @@ -12,8 +16,12 @@ namespace NzbDrone.Core.Test.Framework { protected void UseRealHttp() { + Mocker.SetConstant(new HttpProxySettingsProvider(Mocker.Resolve())); + Mocker.SetConstant(new ManagedWebProxyFactory(Mocker.Resolve())); + Mocker.SetConstant(new ManagedHttpDispatcher(Mocker.Resolve(), Mocker.Resolve())); + Mocker.SetConstant(new CurlHttpDispatcher(Mocker.Resolve(), Mocker.Resolve())); Mocker.SetConstant(new HttpProvider(TestLogger)); - Mocker.SetConstant(new HttpClient(new IHttpRequestInterceptor[0], Mocker.Resolve(), Mocker.Resolve(), TestLogger)); + Mocker.SetConstant(new HttpClient(new IHttpRequestInterceptor[0], Mocker.Resolve(), Mocker.Resolve(), Mocker.Resolve(), TestLogger)); Mocker.SetConstant(new SonarrCloudRequestBuilder()); } } diff --git a/src/NzbDrone.Core.Test/Http/HttpProxySettingsProviderFixture.cs b/src/NzbDrone.Core.Test/Http/HttpProxySettingsProviderFixture.cs new file mode 100644 index 000000000..20679a417 --- /dev/null +++ b/src/NzbDrone.Core.Test/Http/HttpProxySettingsProviderFixture.cs @@ -0,0 +1,40 @@ +using NzbDrone.Core.Http; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using NUnit.Framework; +using FluentAssertions; +using NzbDrone.Test.Common; +using NzbDrone.Common.Http.Proxy; +using NzbDrone.Common.Http; + +namespace NzbDrone.Core.Test.Http +{ + [TestFixture] + public class HttpProxySettingsProviderFixture : TestBase + { + private HttpProxySettings GetProxySettings() + { + return new HttpProxySettings(ProxyType.Socks5, "localhost", 8080, "*.httpbin.org,google.com", true, null, null); + } + + [Test] + public void should_bypass_proxy() + { + var settings = GetProxySettings(); + + Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://eu.httpbin.org/get")).Should().BeTrue(); + Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://google.com/get")).Should().BeTrue(); + Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://localhost:8654/get")).Should().BeTrue(); + } + + [Test] + public void should_not_bypass_proxy() + { + var settings = GetProxySettings(); + + Subject.ShouldProxyBeBypassed(settings, new HttpUri("http://bing.com/get")).Should().BeFalse(); + } + } +} diff --git a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj index b0760522d..b1da9d2b2 100644 --- a/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj +++ b/src/NzbDrone.Core.Test/NzbDrone.Core.Test.csproj @@ -220,6 +220,7 @@ + diff --git a/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs b/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs index 7db6d8508..a9bad109d 100644 --- a/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs +++ b/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs @@ -16,7 +16,6 @@ using NzbDrone.Core.Messaging.Commands; using NzbDrone.Core.Messaging.Events; using NzbDrone.Core.Update; - namespace NzbDrone.Core.Configuration { public interface IConfigFileProvider : IHandleAsync, diff --git a/src/NzbDrone.Core/Configuration/ConfigService.cs b/src/NzbDrone.Core/Configuration/ConfigService.cs index 1f94d5471..e8068584a 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -7,6 +7,7 @@ using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Configuration.Events; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Messaging.Events; +using NzbDrone.Common.Http.Proxy; namespace NzbDrone.Core.Configuration { @@ -312,6 +313,46 @@ namespace NzbDrone.Core.Configuration get { return GetValue("HmacSalt", Guid.NewGuid().ToString(), true); } } + public bool ProxyEnabled + { + get { return GetValueBoolean("ProxyEnabled", false); } + } + + public ProxyType ProxyType + { + get { return GetValueEnum("ProxyType", ProxyType.Http); } + } + + public string ProxyHostname + { + get { return GetValue("ProxyHostname", string.Empty); } + } + + public int ProxyPort + { + get { return GetValueInt("ProxyPort", 8080); } + } + + public string ProxyUsername + { + get { return GetValue("ProxyUsername", string.Empty); } + } + + public string ProxyPassword + { + get { return GetValue("ProxyPassword", string.Empty); } + } + + public string ProxyBypassFilter + { + get { return GetValue("ProxyBypassFilter", string.Empty); } + } + + public bool ProxyBypassLocalAddresses + { + get { return GetValueBoolean("ProxyBypassLocalAddresses", true); } + } + private string GetValue(string key) { return GetValue(key, string.Empty); diff --git a/src/NzbDrone.Core/Configuration/IConfigService.cs b/src/NzbDrone.Core/Configuration/IConfigService.cs index b2f408595..891b0c494 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -1,6 +1,9 @@ using System; using System.Collections.Generic; using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Http; +using NzbDrone.Common.Http; +using NzbDrone.Common.Http.Proxy; namespace NzbDrone.Core.Configuration { @@ -64,5 +67,15 @@ namespace NzbDrone.Core.Configuration string HmacPassphrase { get; } string RijndaelSalt { get; } string HmacSalt { get; } + + //Proxy + bool ProxyEnabled { get; } + ProxyType ProxyType { get; } + string ProxyHostname { get; } + int ProxyPort { get; } + string ProxyUsername { get; } + string ProxyPassword { get; } + string ProxyBypassFilter { get; } + bool ProxyBypassLocalAddresses { get; } } } diff --git a/src/NzbDrone.Core/HealthCheck/Checks/ProxyCheck.cs b/src/NzbDrone.Core/HealthCheck/Checks/ProxyCheck.cs new file mode 100644 index 000000000..e9d2405cb --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/Checks/ProxyCheck.cs @@ -0,0 +1,65 @@ +using NLog; +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text; +using NzbDrone.Common.Cloud; + +namespace NzbDrone.Core.HealthCheck.Checks +{ + public class ProxyCheck : HealthCheckBase + { + private readonly Logger _logger; + private readonly IConfigService _configService; + private readonly IHttpClient _client; + + private readonly IHttpRequestBuilderFactory _cloudRequestBuilder; + + public ProxyCheck(ISonarrCloudRequestBuilder cloudRequestBuilder, IConfigService configService, IHttpClient client, Logger logger) + { + _configService = configService; + _client = client; + _logger = logger; + + _cloudRequestBuilder = cloudRequestBuilder.Services; + } + + public override HealthCheck Check() + { + if (_configService.ProxyEnabled) + { + var addresses = Dns.GetHostAddresses(_configService.ProxyHostname); + if(!addresses.Any()) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, string.Format("Failed to resolve the IP Address for the Configured Proxy Host {0}", _configService.ProxyHostname)); + } + + var request = _cloudRequestBuilder.Create() + .Resource("/ping") + .Build(); + + try + { + var response = _client.Execute(request); + + // We only care about 400 responses, other error codes can be ignored + if (response.StatusCode == HttpStatusCode.BadRequest) + { + _logger.Error("Proxy Health Check failed: {0}", response.StatusCode); + return new HealthCheck(GetType(), HealthCheckResult.Error, string.Format("Failed to test proxy: StatusCode {1}", request.Url, response.StatusCode)); + } + } + catch (Exception ex) + { + _logger.Error(ex, "Proxy Health Check failed: {0}", ex.Message); + return new HealthCheck(GetType(), HealthCheckResult.Error, string.Format("Failed to test proxy: {1}", request.Url, ex.Message)); + } + } + + return new HealthCheck(GetType()); + } + } +} diff --git a/src/NzbDrone.Core/Http/HttpProxySettingsProvider.cs b/src/NzbDrone.Core/Http/HttpProxySettingsProvider.cs new file mode 100644 index 000000000..6e5a23e07 --- /dev/null +++ b/src/NzbDrone.Core/Http/HttpProxySettingsProvider.cs @@ -0,0 +1,49 @@ +using System; +using System.Net; +using NzbDrone.Common.Http; +using NzbDrone.Common.Http.Proxy; +using NzbDrone.Core.Configuration; + +namespace NzbDrone.Core.Http +{ + public class HttpProxySettingsProvider : IHttpProxySettingsProvider + { + private readonly IConfigService _configService; + + public HttpProxySettingsProvider(IConfigService configService) + { + _configService = configService; + } + + public HttpProxySettings GetProxySettings(HttpRequest request) + { + if (!_configService.ProxyEnabled) + { + return null; + } + + var proxySettings = new HttpProxySettings(_configService.ProxyType, + _configService.ProxyHostname, + _configService.ProxyPort, + _configService.ProxyBypassFilter, + _configService.ProxyBypassLocalAddresses, + _configService.ProxyUsername, + _configService.ProxyPassword); + + if (ShouldProxyBeBypassed(proxySettings, request.Url)) + { + return null; + } + + return proxySettings; + } + + public bool ShouldProxyBeBypassed(HttpProxySettings proxySettings, HttpUri url) + { + //We are utilising the WebProxy implementation here to save us having to reimplement it. This way we use Microsofts implementation + var proxy = new WebProxy(proxySettings.Host + ":" + proxySettings.Port, proxySettings.BypassLocalAddress, proxySettings.BypassListAsArray); + + return proxy.IsBypassed((Uri)url); + } + } +} diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index 75fd71535..0f5b94c59 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -489,6 +489,7 @@ + @@ -517,6 +518,7 @@ + diff --git a/src/NzbDrone.Host/MainAppContainerBuilder.cs b/src/NzbDrone.Host/MainAppContainerBuilder.cs index 7c2cdc800..33c05878e 100644 --- a/src/NzbDrone.Host/MainAppContainerBuilder.cs +++ b/src/NzbDrone.Host/MainAppContainerBuilder.cs @@ -4,6 +4,7 @@ using Nancy.Bootstrapper; using NzbDrone.Api; using NzbDrone.Common.Composition; using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Http.Dispatchers; using NzbDrone.Core.Datastore; using NzbDrone.Core.Organizer; using NzbDrone.SignalR; @@ -42,6 +43,7 @@ namespace NzbDrone.Host AutoRegisterImplementations(); Container.Register(); + Container.Register(); } } } \ No newline at end of file diff --git a/src/NzbDrone.Update/UpdateContainerBuilder.cs b/src/NzbDrone.Update/UpdateContainerBuilder.cs index 4dc5e9662..e3f449586 100644 --- a/src/NzbDrone.Update/UpdateContainerBuilder.cs +++ b/src/NzbDrone.Update/UpdateContainerBuilder.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using NzbDrone.Common.Composition; using NzbDrone.Common.EnvironmentInfo; +using NzbDrone.Common.Http.Dispatchers; namespace NzbDrone.Update { @@ -10,7 +11,7 @@ namespace NzbDrone.Update private UpdateContainerBuilder(IStartupContext startupContext, string[] assemblies) : base(startupContext, assemblies) { - + Container.Register(); } public static IContainer Build(IStartupContext startupContext) diff --git a/src/UI/Settings/General/GeneralView.js b/src/UI/Settings/General/GeneralView.js index 57caf7545..81f638f34 100644 --- a/src/UI/Settings/General/GeneralView.js +++ b/src/UI/Settings/General/GeneralView.js @@ -11,6 +11,7 @@ var view = Marionette.ItemView.extend({ events : { 'change .x-auth' : '_setAuthOptionsVisibility', + 'change .x-proxy' : '_setProxyOptionsVisibility', 'change .x-ssl' : '_setSslOptionsVisibility', 'click .x-reset-api-key' : '_resetApiKey', 'change .x-update-mechanism' : '_setScriptGroupVisibility' @@ -25,7 +26,9 @@ var view = Marionette.ItemView.extend({ copyApiKey : '.x-copy-api-key', apiKeyInput : '.x-api-key', updateMechanism : '.x-update-mechanism', - scriptGroup : '.x-script-group' + scriptGroup : '.x-script-group', + proxyToggle : '.x-proxy', + proxyOptions : '.x-proxy-settings' }, initialize : function() { @@ -37,6 +40,10 @@ var view = Marionette.ItemView.extend({ this.ui.authOptions.hide(); } + if (!this.ui.proxyToggle.prop('checked')) { + this.ui.proxyOptions.hide(); + } + if (!this.ui.sslToggle.prop('checked')) { this.ui.sslOptions.hide(); } @@ -70,6 +77,15 @@ var view = Marionette.ItemView.extend({ } }, + _setProxyOptionsVisibility : function() { + if (this.ui.proxyToggle.prop('checked')) { + this.ui.proxyOptions.slideDown(); + } + else { + this.ui.proxyOptions.slideUp(); + } + }, + _setSslOptionsVisibility : function() { var showSslOptions = this.ui.sslToggle.prop('checked'); diff --git a/src/UI/Settings/General/GeneralViewTemplate.hbs b/src/UI/Settings/General/GeneralViewTemplate.hbs index 9c94011d4..b22abe9a6 100644 --- a/src/UI/Settings/General/GeneralViewTemplate.hbs +++ b/src/UI/Settings/General/GeneralViewTemplate.hbs @@ -162,6 +162,114 @@ + +
+ Proxy Settings + +
+ + +
+
+
+
+ +
+
+ + +
+ +
+
+ +
+ + +
+ +
+
+ +
+ + +
+ +
+
+ +
+ + +
+ +
+ +
+ +
+
+ +
+ + +
+ +
+ +
+ +
+
+ +
+ + +
+ +
+ +
+ +
+
+ +
+ + +
+
+
+
+
+
Logging