From bfe134ee54c17a2593eb5fd7a312123c1d79635f Mon Sep 17 00:00:00 2001 From: Lloyd Sparkes Date: Tue, 2 Feb 2016 18:42:22 +0000 Subject: [PATCH] Proxy Support for Sonarr #732 Switch to use Port #0 which just uses the next free port Non Functional - Code Cleanup Tabs -> Spaces, Opps Deleted too much Refactoring & Code Cleanup & Move Config to DB remove unneeded line Clean Up Spaces Code Review Points on the UI FIx extra space Clean Up unrequired changes Add a HealthCheck. Extra Check in ProxyCheck. Correctly deal with Socks Authentication Remove SubModule Add in Nuget + Tweak ProxyCheck Code Review Points Missed Review Point Add Subnet Filtering, Add ProxyBypass for local addresses. UI updated for property changes. Fix typo, and copy&paste error Tweak URL to hit --- src/NzbDrone.Api/Config/HostConfigModule.cs | 7 +- src/NzbDrone.Api/Config/HostConfigResource.cs | 10 ++ .../Http/Dispatchers/CurlHttpDispatcher.cs | 25 ++++ .../Http/Dispatchers/ManagedHttpDispatcher.cs | 40 +++++++ src/NzbDrone.Common/Http/HttpRequest.cs | 3 +- .../Http/HttpRequestProxySettings.cs | 45 ++++++++ src/NzbDrone.Common/Http/ProxyType.cs | 14 +++ src/NzbDrone.Common/NzbDrone.Common.csproj | 8 ++ src/NzbDrone.Common/packages.config | 1 + .../Configuration/ConfigFileProvider.cs | 1 - .../Configuration/ConfigService.cs | 42 +++++++ .../Configuration/IConfigService.cs | 12 ++ .../HealthCheck/Checks/ProxyCheck.cs | 58 ++++++++++ .../Http/ProxyHttpInterceptor.cs | 40 +++++++ src/NzbDrone.Core/NzbDrone.Core.csproj | 2 + src/UI/Settings/General/GeneralView.js | 18 ++- .../Settings/General/GeneralViewTemplate.hbs | 108 ++++++++++++++++++ 17 files changed, 429 insertions(+), 5 deletions(-) create mode 100644 src/NzbDrone.Common/Http/HttpRequestProxySettings.cs create mode 100644 src/NzbDrone.Common/Http/ProxyType.cs create mode 100644 src/NzbDrone.Core/HealthCheck/Checks/ProxyCheck.cs create mode 100644 src/NzbDrone.Core/Http/ProxyHttpInterceptor.cs 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..7bdc6a735 100644 --- a/src/NzbDrone.Api/Config/HostConfigResource.cs +++ b/src/NzbDrone.Api/Config/HostConfigResource.cs @@ -2,6 +2,8 @@ using NzbDrone.Api.REST; using NzbDrone.Core.Authentication; using NzbDrone.Core.Update; +using NzbDrone.Core.Http; +using NzbDrone.Common.Http; namespace NzbDrone.Api.Config { @@ -25,5 +27,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 ProxySubnetFilter { get; set; } + public bool ProxyBypassLocalAddresses { get; set; } } } diff --git a/src/NzbDrone.Common/Http/Dispatchers/CurlHttpDispatcher.cs b/src/NzbDrone.Common/Http/Dispatchers/CurlHttpDispatcher.cs index 9ea8d1a80..0a06d64d1 100644 --- a/src/NzbDrone.Common/Http/Dispatchers/CurlHttpDispatcher.cs +++ b/src/NzbDrone.Common/Http/Dispatchers/CurlHttpDispatcher.cs @@ -76,7 +76,32 @@ namespace NzbDrone.Common.Http.Dispatchers return s * n; }; + if(request.Proxy != null && !request.Proxy.ShouldProxyBeBypassed(request.Url)) + + { + switch (request.Proxy.Type) + { + case ProxyType.Http: + curlEasy.SetOpt(CurlOption.ProxyType, CurlProxyType.Http); + curlEasy.SetOpt(CurlOption.ProxyAuth, CurlHttpAuth.Basic); + curlEasy.SetOpt(CurlOption.ProxyUserPwd, request.Proxy.Username + ":" + request.Proxy.Password.ToString()); + break; + case ProxyType.Socks4: + curlEasy.SetOpt(CurlOption.ProxyType, CurlProxyType.Socks4); + curlEasy.SetOpt(CurlOption.ProxyUsername, request.Proxy.Username); + curlEasy.SetOpt(CurlOption.ProxyPassword, request.Proxy.Password); + break; + case ProxyType.Socks5: + curlEasy.SetOpt(CurlOption.ProxyType, CurlProxyType.Socks5); + curlEasy.SetOpt(CurlOption.ProxyUsername, request.Proxy.Username); + curlEasy.SetOpt(CurlOption.ProxyPassword, request.Proxy.Password); + break; + } + curlEasy.SetOpt(CurlOption.Proxy, request.Proxy.Host + ":" + request.Proxy.Port.ToString()); + } + curlEasy.Url = request.Url.FullUri; + switch (request.Method) { case HttpMethod.GET: diff --git a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs index 1ffbfd35a..8c6e55633 100644 --- a/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs +++ b/src/NzbDrone.Common/Http/Dispatchers/ManagedHttpDispatcher.cs @@ -1,6 +1,9 @@ using System; using System.Net; using NzbDrone.Common.Extensions; +using com.LandonKey.SocksWebProxy.Proxy; +using com.LandonKey.SocksWebProxy; +using System.Net.Sockets; namespace NzbDrone.Common.Http.Dispatchers { @@ -26,6 +29,33 @@ namespace NzbDrone.Common.Http.Dispatchers webRequest.Timeout = (int)Math.Ceiling(request.RequestTimeout.TotalMilliseconds); } + if (request.Proxy != null && !request.Proxy.ShouldProxyBeBypassed(request.Url)) + { + var addresses = Dns.GetHostAddresses(request.Proxy.Host); + var socksUsername = request.Proxy.Username == null ? string.Empty : request.Proxy.Username; + var socksPassword = request.Proxy.Password == null ? string.Empty : request.Proxy.Password; + + switch (request.Proxy.Type) + { + case ProxyType.Http: + if(request.Proxy.Username.IsNotNullOrWhiteSpace() && request.Proxy.Password.IsNotNullOrWhiteSpace()) + { + webRequest.Proxy = new WebProxy(request.Proxy.Host + ":" + request.Proxy.Port, request.Proxy.BypassLocalAddress, request.Proxy.SubnetFilterAsArray, new NetworkCredential(request.Proxy.Username, request.Proxy.Password)); + } + else + { + webRequest.Proxy = new WebProxy(request.Proxy.Host + ":" + request.Proxy.Port, request.Proxy.BypassLocalAddress, request.Proxy.SubnetFilterAsArray); + } + break; + case ProxyType.Socks4: + webRequest.Proxy = new SocksWebProxy(new ProxyConfig(IPAddress.Parse("127.0.0.1"), GetNextFreePort(), addresses[0], request.Proxy.Port, ProxyConfig.SocksVersion.Four, socksUsername, socksPassword), false); + break; + case ProxyType.Socks5: + webRequest.Proxy = new SocksWebProxy(new ProxyConfig(IPAddress.Parse("127.0.0.1"), GetNextFreePort(), addresses[0], request.Proxy.Port, ProxyConfig.SocksVersion.Five, socksUsername, socksPassword), false); + break; + } + } + if (request.Headers != null) { AddRequestHeaders(webRequest, request.Headers); @@ -117,5 +147,15 @@ namespace NzbDrone.Common.Http.Dispatchers } } } + + 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/HttpRequest.cs b/src/NzbDrone.Common/Http/HttpRequest.cs index bf2fe3568..966a3b534 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; @@ -41,6 +41,7 @@ namespace NzbDrone.Common.Http public bool StoreResponseCookie { get; set; } public TimeSpan RequestTimeout { get; set; } public TimeSpan RateLimit { get; set; } + public HttpRequestProxySettings Proxy {get; set;} public override string ToString() { diff --git a/src/NzbDrone.Common/Http/HttpRequestProxySettings.cs b/src/NzbDrone.Common/Http/HttpRequestProxySettings.cs new file mode 100644 index 000000000..ba533eb56 --- /dev/null +++ b/src/NzbDrone.Common/Http/HttpRequestProxySettings.cs @@ -0,0 +1,45 @@ +using System; +using System.Net; + +namespace NzbDrone.Common.Http +{ + public class HttpRequestProxySettings + { + public HttpRequestProxySettings(ProxyType type, string host, int port, string filterSubnet, bool bypassLocalAddress, string username = null, string password = null) + { + Type = type; + Host = host; + Port = port; + Username = username; + Password = password; + } + + 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 SubnetFilter { get; private set; } + public bool BypassLocalAddress { get; private set; } + + public string[] SubnetFilterAsArray + { + get + { + if (!string.IsNullOrWhiteSpace(SubnetFilter)) + { + return SubnetFilter.Split(';'); + } + return new string[] { }; + } + } + + public bool ShouldProxyBeBypassed(Uri url) + { + //We are utilising the WebProxy implementation here to save us having to reimplement it. This way we use Microsofts implementation + WebProxy proxy = new WebProxy(Host + ":" + Port, BypassLocalAddress, SubnetFilterAsArray); + + return proxy.IsBypassed(url); + } + } +} diff --git a/src/NzbDrone.Common/Http/ProxyType.cs b/src/NzbDrone.Common/Http/ProxyType.cs new file mode 100644 index 000000000..a415fc749 --- /dev/null +++ b/src/NzbDrone.Common/Http/ProxyType.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Common.Http +{ + public enum ProxyType + { + Http, + Socks4, + Socks5 + } +} diff --git a/src/NzbDrone.Common/NzbDrone.Common.csproj b/src/NzbDrone.Common/NzbDrone.Common.csproj index 8fc2c1e82..5fe24e16d 100644 --- a/src/NzbDrone.Common/NzbDrone.Common.csproj +++ b/src/NzbDrone.Common/NzbDrone.Common.csproj @@ -45,6 +45,12 @@ ..\packages\NLog.4.3.0-rc1\lib\net40\NLog.dll + + ..\packages\DotNet4.SocksProxy.1.0.0.0\lib\net40\Org.Mentalis.dll + True + + + ..\packages\DotNet4.SocksProxy.1.0.0.0\lib\net40\SocksWebProxy.dll True @@ -161,6 +167,7 @@ + @@ -171,6 +178,7 @@ + diff --git a/src/NzbDrone.Common/packages.config b/src/NzbDrone.Common/packages.config index 006141366..05011894d 100644 --- a/src/NzbDrone.Common/packages.config +++ b/src/NzbDrone.Common/packages.config @@ -1,5 +1,6 @@  + diff --git a/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs b/src/NzbDrone.Core/Configuration/ConfigFileProvider.cs index b9c509e64..a2295ef75 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..5939bb99f 100644 --- a/src/NzbDrone.Core/Configuration/ConfigService.cs +++ b/src/NzbDrone.Core/Configuration/ConfigService.cs @@ -7,6 +7,8 @@ using NzbDrone.Common.EnvironmentInfo; using NzbDrone.Core.Configuration.Events; using NzbDrone.Core.MediaFiles; using NzbDrone.Core.Messaging.Events; +using NzbDrone.Core.Http; +using NzbDrone.Common.Http; namespace NzbDrone.Core.Configuration { @@ -312,6 +314,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 ProxySubnetFilter + { + get { return GetValue("ProxySubnetFilter", 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..334f8c31f 100644 --- a/src/NzbDrone.Core/Configuration/IConfigService.cs +++ b/src/NzbDrone.Core/Configuration/IConfigService.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using NzbDrone.Core.MediaFiles; +using NzbDrone.Core.Http; +using NzbDrone.Common.Http; namespace NzbDrone.Core.Configuration { @@ -64,5 +66,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 ProxySubnetFilter { 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..ede53acf2 --- /dev/null +++ b/src/NzbDrone.Core/HealthCheck/Checks/ProxyCheck.cs @@ -0,0 +1,58 @@ +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; + +namespace NzbDrone.Core.HealthCheck.Checks +{ + public class ProxyCheck : HealthCheckBase + { + private readonly Logger _logger; + private readonly IConfigService _configService; + private readonly IHttpClient _client; + + public ProxyCheck(IConfigService configService, IHttpClient client, Logger logger) + { + _configService = configService; + _client = client; + _logger = logger; + } + + public override HealthCheck Check() + { + if (_configService.ProxyEnabled) + { + var addresses = Dns.GetHostAddresses(_configService.ProxyHostname); + if(addresses.Length != 1) + { + return new HealthCheck(GetType(), HealthCheckResult.Error, "Failed to resolve the IP Address for the Configured Proxy Host: " + _configService.ProxyHostname); + } + + var request = new HttpRequestBuilder("https://services.sonarr.tv/").Build("/ping"); + + 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 Data: {1} ", response.StatusCode.ToString(), response.ResponseData); + return new HealthCheck(GetType(), HealthCheckResult.Error, "Failed to load https://sonarr.tv/, got HTTP " + response.StatusCode.ToString()); + } + } + catch (Exception ex) + { + _logger.ErrorException("Proxy Health Check failed.", ex); + return new HealthCheck(GetType(), HealthCheckResult.Error, "An exception occured while trying to load https://sonarr.tv/: " + ex.Message); + } + } + + return new HealthCheck(GetType()); + } + } +} diff --git a/src/NzbDrone.Core/Http/ProxyHttpInterceptor.cs b/src/NzbDrone.Core/Http/ProxyHttpInterceptor.cs new file mode 100644 index 000000000..1207e1281 --- /dev/null +++ b/src/NzbDrone.Core/Http/ProxyHttpInterceptor.cs @@ -0,0 +1,40 @@ +using NzbDrone.Common.Http; +using NzbDrone.Core.Configuration; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace NzbDrone.Core.Http +{ + public class ProxyHttpInterceptor : IHttpRequestInterceptor + { + private readonly IConfigService _configService; + + public ProxyHttpInterceptor(IConfigService configService) + { + this._configService = configService; + } + + public HttpResponse PostResponse(HttpResponse response) + { + return response; + } + + public HttpRequest PreRequest(HttpRequest request) + { + if(_configService.ProxyEnabled) + { + request.Proxy = new HttpRequestProxySettings(_configService.ProxyType, + _configService.ProxyHostname, + _configService.ProxyPort, + _configService.ProxySubnetFilter, + _configService.ProxyBypassLocalAddresses, + _configService.ProxyUsername, + _configService.ProxyPassword); + } + + return request; + } + } +} diff --git a/src/NzbDrone.Core/NzbDrone.Core.csproj b/src/NzbDrone.Core/NzbDrone.Core.csproj index bc0ffc2a4..920b002fd 100644 --- a/src/NzbDrone.Core/NzbDrone.Core.csproj +++ b/src/NzbDrone.Core/NzbDrone.Core.csproj @@ -487,6 +487,7 @@ + @@ -514,6 +515,7 @@ + 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..2dce9a2da 100644 --- a/src/UI/Settings/General/GeneralViewTemplate.hbs +++ b/src/UI/Settings/General/GeneralViewTemplate.hbs @@ -162,6 +162,114 @@ + +
+ Proxy Settings + +
+ + +
+
+
+
+ +
+
+ + +
+ +
+
+ +
+ + +
+ +
+
+ +
+ + +
+ +
+
+ +
+ + +
+ +
+ +
+ +
+
+ +
+ + +
+ +
+ +
+ +
+
+ +
+ + +
+ +
+ +
+ +
+
+ +
+ + +
+
+
+
+
+
Logging