First pass at introducing kestrel and refactoring to make the RESTAPI be its own package.

This commit is contained in:
Thomas Suckow
2022-12-30 10:59:29 -08:00
parent 83cc35664b
commit 235568dade
79 changed files with 11412 additions and 11074 deletions
@@ -0,0 +1,339 @@
// Copyright (C) 2015, The Duplicati Team
// http://www.duplicati.com, info@duplicati.com
//
// This library is free software; you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as
// published by the Free Software Foundation; either version 2.1 of the
// License, or (at your option) any later version.
//
// This library is distributed in the hope that it will be useful, but
// WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
// Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public
// License along with this library; if not, write to the Free Software
// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
using System;
using System.Collections.Concurrent;
using System.Linq;
using HttpServer;
using HttpServer.HttpModules;
using System.Collections.Generic;
using Duplicati.Library.RestAPI;
namespace Duplicati.Server.WebServer
{
internal class AuthenticationHandler : HttpModule
{
private const string AUTH_COOKIE_NAME = "session-auth";
private const string NONCE_COOKIE_NAME = "session-nonce";
private const string XSRF_COOKIE_NAME = "xsrf-token";
private const string XSRF_HEADER_NAME = "X-XSRF-Token";
private const string TRAYICONPASSWORDSOURCE_HEADER = "X-TrayIcon-PasswordSource";
public const string LOGIN_SCRIPT_URI = "/login.cgi";
public const string LOGOUT_SCRIPT_URI = "/logout.cgi";
public const string CAPTCHA_IMAGE_URI = RESTHandler.API_URI_PATH + "/captcha/";
private const int XSRF_TIMEOUT_MINUTES = 10;
private const int AUTH_TIMEOUT_MINUTES = 10;
private readonly ConcurrentDictionary<string, DateTime> m_activeTokens = new ConcurrentDictionary<string, DateTime>();
private readonly ConcurrentDictionary<string, Tuple<DateTime, string>> m_activeNonces = new ConcurrentDictionary<string, Tuple<DateTime, string>>();
private readonly ConcurrentDictionary<string, DateTime> m_activexsrf = new ConcurrentDictionary<string, DateTime>();
readonly System.Security.Cryptography.RandomNumberGenerator m_prng = System.Security.Cryptography.RNGCryptoServiceProvider.Create();
private string FindXSRFToken(HttpServer.IHttpRequest request)
{
string xsrftoken = request.Headers[XSRF_HEADER_NAME] ?? "";
if (string.IsNullOrWhiteSpace(xsrftoken))
{
var xsrfq = request.Form[XSRF_HEADER_NAME] ?? request.Form[Duplicati.Library.Utility.Uri.UrlEncode(XSRF_HEADER_NAME)];
xsrftoken = (xsrfq == null || string.IsNullOrWhiteSpace(xsrfq.Value)) ? "" : xsrfq.Value;
}
if (string.IsNullOrWhiteSpace(xsrftoken))
{
var xsrfq = request.QueryString[XSRF_HEADER_NAME] ?? request.QueryString[Duplicati.Library.Utility.Uri.UrlEncode(XSRF_HEADER_NAME)];
xsrftoken = (xsrfq == null || string.IsNullOrWhiteSpace(xsrfq.Value)) ? "" : xsrfq.Value;
}
return xsrftoken;
}
private bool AddXSRFTokenToRespone(HttpServer.IHttpResponse response)
{
if (m_activexsrf.Count > 500)
return false;
var buf = new byte[32];
var expires = DateTime.UtcNow.AddMinutes(XSRF_TIMEOUT_MINUTES);
m_prng.GetBytes(buf);
var token = Convert.ToBase64String(buf);
m_activexsrf.AddOrUpdate(token, key => expires, (key, existingExpires) =>
{
// Simulate the original behavior => if the random token, against all odds, is already used
// we throw an ArgumentException
throw new ArgumentException("An element with the same key already exists in the dictionary.");
});
response.Cookies.Add(new HttpServer.ResponseCookie(XSRF_COOKIE_NAME, token, expires));
return true;
}
private string FindAuthCookie(HttpServer.IHttpRequest request)
{
var authcookie = request.Cookies[AUTH_COOKIE_NAME] ?? request.Cookies[Library.Utility.Uri.UrlEncode(AUTH_COOKIE_NAME)];
var authform = request.Form["auth-token"] ?? request.Form[Library.Utility.Uri.UrlEncode("auth-token")];
var authquery = request.QueryString["auth-token"] ?? request.QueryString[Library.Utility.Uri.UrlEncode("auth-token")];
var auth_token = string.IsNullOrWhiteSpace(authcookie?.Value) ? null : authcookie.Value;
if (!string.IsNullOrWhiteSpace(authquery?.Value))
auth_token = authquery.Value;
if (!string.IsNullOrWhiteSpace(authform?.Value))
auth_token = authform.Value;
return auth_token;
}
private bool HasXSRFCookie(HttpServer.IHttpRequest request)
{
// Clean up expired XSRF cookies
foreach (var k in (from n in m_activexsrf where DateTime.UtcNow > n.Value select n.Key))
m_activexsrf.TryRemove(k, out _);
var xsrfcookie = request.Cookies[XSRF_COOKIE_NAME] ?? request.Cookies[Library.Utility.Uri.UrlEncode(XSRF_COOKIE_NAME)];
var value = xsrfcookie == null ? null : xsrfcookie.Value;
if (string.IsNullOrWhiteSpace(value))
return false;
if (m_activexsrf.ContainsKey(value))
{
m_activexsrf[value] = DateTime.UtcNow.AddMinutes(XSRF_TIMEOUT_MINUTES);
return true;
}
else if (m_activexsrf.ContainsKey(Library.Utility.Uri.UrlDecode(value)))
{
m_activexsrf[Library.Utility.Uri.UrlDecode(value)] = DateTime.UtcNow.AddMinutes(XSRF_TIMEOUT_MINUTES);
return true;
}
return false;
}
public override bool Process(HttpServer.IHttpRequest request, HttpServer.IHttpResponse response, HttpServer.Sessions.IHttpSession session)
{
HttpServer.HttpInput input = String.Equals(request.Method, "POST", StringComparison.OrdinalIgnoreCase) ? request.Form : request.QueryString;
var auth_token = FindAuthCookie(request);
var xsrf_token = FindXSRFToken(request);
if (!HasXSRFCookie(request))
{
var cookieAdded = AddXSRFTokenToRespone(response);
if (!cookieAdded)
{
response.Status = System.Net.HttpStatusCode.ServiceUnavailable;
response.Reason = "Too Many Concurrent Request, try again later";
return true;
}
}
if (LOGOUT_SCRIPT_URI.Equals(request.Uri.AbsolutePath, StringComparison.OrdinalIgnoreCase))
{
if (!string.IsNullOrWhiteSpace(auth_token))
{
// Remove the active auth token
m_activeTokens.TryRemove(auth_token, out _);
}
response.Status = System.Net.HttpStatusCode.NoContent;
response.Reason = "OK";
return true;
}
else if (LOGIN_SCRIPT_URI.Equals(request.Uri.AbsolutePath, StringComparison.OrdinalIgnoreCase))
{
// Remove expired nonces
foreach(var k in (from n in m_activeNonces where DateTime.UtcNow > n.Value.Item1 select n.Key))
m_activeNonces.TryRemove(k, out _);
if (input["get-nonce"] != null && !string.IsNullOrWhiteSpace(input["get-nonce"].Value))
{
if (m_activeNonces.Count > 50)
{
response.Status = System.Net.HttpStatusCode.ServiceUnavailable;
response.Reason = "Too many active login attempts";
return true;
}
var password = FIXMEGlobal.DataConnection.ApplicationSettings.WebserverPassword;
if (request.Headers[TRAYICONPASSWORDSOURCE_HEADER] == "database")
password = FIXMEGlobal.DataConnection.ApplicationSettings.WebserverPasswordTrayIconHash;
var buf = new byte[32];
var expires = DateTime.UtcNow.AddMinutes(AUTH_TIMEOUT_MINUTES);
m_prng.GetBytes(buf);
var nonce = Convert.ToBase64String(buf);
var sha256 = System.Security.Cryptography.SHA256.Create();
sha256.TransformBlock(buf, 0, buf.Length, buf, 0);
buf = Convert.FromBase64String(password);
sha256.TransformFinalBlock(buf, 0, buf.Length);
var pwd = Convert.ToBase64String(sha256.Hash);
m_activeNonces.AddOrUpdate(nonce, key => new Tuple<DateTime, string>(expires, pwd), (key, existingValue) =>
{
// Simulate the original behavior => if the nonce, against all odds, is already used
// we throw an ArgumentException
throw new ArgumentException("An element with the same key already exists in the dictionary.");
});
response.Cookies.Add(new HttpServer.ResponseCookie(NONCE_COOKIE_NAME, nonce, expires));
using(var bw = new BodyWriter(response, request))
{
bw.OutputOK(new {
Status = "OK",
Nonce = nonce,
Salt = FIXMEGlobal.DataConnection.ApplicationSettings.WebserverPasswordSalt
});
}
return true;
}
else
{
if (input["password"] != null && !string.IsNullOrWhiteSpace(input["password"].Value))
{
var nonce_el = request.Cookies[NONCE_COOKIE_NAME] ?? request.Cookies[Library.Utility.Uri.UrlEncode(NONCE_COOKIE_NAME)];
var nonce = nonce_el == null || string.IsNullOrWhiteSpace(nonce_el.Value) ? "" : nonce_el.Value;
var urldecoded = nonce == null ? "" : Duplicati.Library.Utility.Uri.UrlDecode(nonce);
if (m_activeNonces.ContainsKey(urldecoded))
nonce = urldecoded;
if (!m_activeNonces.ContainsKey(nonce))
{
response.Status = System.Net.HttpStatusCode.Unauthorized;
response.Reason = "Unauthorized";
response.ContentType = "application/json";
return true;
}
var pwd = m_activeNonces[nonce].Item2;
// Remove the nonce
m_activeNonces.TryRemove(nonce, out _);
if (pwd != input["password"].Value)
{
response.Status = System.Net.HttpStatusCode.Unauthorized;
response.Reason = "Unauthorized";
response.ContentType = "application/json";
return true;
}
var buf = new byte[32];
var expires = DateTime.UtcNow.AddHours(1);
m_prng.GetBytes(buf);
var token = Duplicati.Library.Utility.Utility.Base64UrlEncode(buf);
while (token.Length > 0 && token.EndsWith("=", StringComparison.Ordinal))
token = token.Substring(0, token.Length - 1);
m_activeTokens.AddOrUpdate(token, key => expires, (key, existingValue) =>
{
// Simulate the original behavior => if the token, against all odds, is already used
// we throw an ArgumentException
throw new ArgumentException("An element with the same key already exists in the dictionary.");
});
response.Cookies.Add(new HttpServer.ResponseCookie(AUTH_COOKIE_NAME, token, expires));
using(var bw = new BodyWriter(response, request))
bw.OutputOK();
return true;
}
}
}
var limitedAccess =
request.Uri.AbsolutePath.StartsWith(RESTHandler.API_URI_PATH, StringComparison.OrdinalIgnoreCase)
;
// Override to allow the CAPTCHA call to go through
if (request.Uri.AbsolutePath.StartsWith(CAPTCHA_IMAGE_URI, StringComparison.OrdinalIgnoreCase) && request.Method == "GET")
limitedAccess = false;
if (limitedAccess)
{
if (xsrf_token != null && m_activexsrf.ContainsKey(xsrf_token))
{
var expires = DateTime.UtcNow.AddMinutes(XSRF_TIMEOUT_MINUTES);
m_activexsrf[xsrf_token] = expires;
response.Cookies.Add(new ResponseCookie(XSRF_COOKIE_NAME, xsrf_token, expires));
}
else
{
response.Status = System.Net.HttpStatusCode.BadRequest;
response.Reason = "Missing XSRF Token. Please reload the page";
return true;
}
}
if (string.IsNullOrWhiteSpace(FIXMEGlobal.DataConnection.ApplicationSettings.WebserverPassword))
return false;
foreach(var k in (from n in m_activeTokens where DateTime.UtcNow > n.Value select n.Key))
m_activeTokens.TryRemove(k, out _);
// If we have a valid token, proceed
if (!string.IsNullOrWhiteSpace(auth_token))
{
DateTime expires;
var found = m_activeTokens.TryGetValue(auth_token, out expires);
if (!found)
{
auth_token = Duplicati.Library.Utility.Uri.UrlDecode(auth_token);
found = m_activeTokens.TryGetValue(auth_token, out expires);
}
if (found && DateTime.UtcNow < expires)
{
expires = DateTime.UtcNow.AddHours(1);
m_activeTokens[auth_token] = expires;
response.Cookies.Add(new ResponseCookie(AUTH_COOKIE_NAME, auth_token, expires));
return false;
}
}
if ("/".Equals(request.Uri.AbsolutePath, StringComparison.OrdinalIgnoreCase) || "/index.html".Equals(request.Uri.AbsolutePath, StringComparison.OrdinalIgnoreCase))
{
response.Redirect("/login.html");
return true;
}
if (limitedAccess)
{
response.Status = System.Net.HttpStatusCode.Unauthorized;
response.Reason = "Not logged in";
response.AddHeader("Location", "login.html");
return true;
}
return false;
}
}
}
@@ -0,0 +1,97 @@
// Copyright (C) 2015, The Duplicati Team
// http://www.duplicati.com, info@duplicati.com
//
// This library is free software; you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as
// published by the Free Software Foundation; either version 2.1 of the
// License, or (at your option) any later version.
//
// This library is distributed in the hope that it will be useful, but
// WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
// Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public
// License along with this library; if not, write to the Free Software
// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
using System;
using Duplicati.Server.Serialization;
namespace Duplicati.Server.WebServer
{
public class BodyWriter : System.IO.StreamWriter, IDisposable
{
private readonly HttpServer.IHttpResponse m_resp;
private readonly string m_jsonp;
private static readonly object SUCCESS_RESPONSE = new { Status = "OK" };
// We override the format provider so all JSON output uses US format
public override IFormatProvider FormatProvider
{
get { return System.Globalization.CultureInfo.InvariantCulture; }
}
public BodyWriter(HttpServer.IHttpResponse resp, HttpServer.IHttpRequest request)
: this(resp, request.QueryString["jsonp"].Value)
{
}
public BodyWriter(HttpServer.IHttpResponse resp, string jsonp)
: base(resp.Body, resp.Encoding)
{
m_resp = resp;
m_jsonp = jsonp;
if (!m_resp.HeadersSent)
m_resp.AddHeader("Cache-Control", "no-cache, no-store, must-revalidate, max-age=0");
}
protected override void Dispose (bool disposing)
{
if (!m_resp.HeadersSent)
{
base.Flush();
m_resp.ContentLength = base.BaseStream.Length;
m_resp.Send();
}
base.Dispose(disposing);
}
public void SetOK()
{
m_resp.Reason = "OK";
m_resp.Status = System.Net.HttpStatusCode.OK;
}
public void OutputOK(object result = null)
{
SetOK();
WriteJsonObject(result ?? SUCCESS_RESPONSE);
}
public void WriteJsonObject(object o)
{
if (!m_resp.HeadersSent)
m_resp.ContentType = "application/json";
using(this)
{
if (!string.IsNullOrEmpty(m_jsonp))
{
this.Write(m_jsonp);
this.Write('(');
}
Serializer.SerializeJson(this, o, true);
if (!string.IsNullOrEmpty(m_jsonp))
{
this.Write(')');
this.Flush();
}
}
}
}
}
@@ -0,0 +1,129 @@
// Copyright (C) 2016, The Duplicati Team
// http://www.duplicati.com, info@duplicati.com
//
// This library is free software; you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as
// published by the Free Software Foundation; either version 2.1 of the
// License, or (at your option) any later version.
//
// This library is distributed in the hope that it will be useful, but
// WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
// Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public
// License along with this library; if not, write to the Free Software
// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
using System;
using System.Linq;
using System.Drawing;
using System.Drawing.Drawing2D;
using System.Drawing.Text;
namespace Duplicati.Server.WebServer
{
/// <summary>
/// Helper class for creating Captcha images
/// </summary>
public static class CaptchaUtil
{
/// <summary>
/// A lookup string with characters to use
/// </summary>
private static readonly string DEFAULT_CHARS = "ACDEFGHJKLMNPQRTUVWXY34679";
/// <summary>
/// A range of possible brush colors
/// </summary>
private static readonly Brush[] BRUSH_COLORS =
typeof(Brushes)
.GetProperties(System.Reflection.BindingFlags.Static | System.Reflection.BindingFlags.Public)
.Where(x => x.PropertyType == typeof(Brush))
.Select(x => x.GetValue(null, null) as Brush)
.Where(x => x != null)
.ToArray();
/// <summary>
/// Approximate the size in pixels of text drawn at the given fontsize
/// </summary>
private static int ApproxTextWidth(string text, FontFamily fontfamily, int fontsize)
{
using (var font = new Font(fontfamily, fontsize, GraphicsUnit.Pixel))
using (var graphics = Graphics.FromImage(new Bitmap(1, 1))) {
return (int) graphics.MeasureString(text, font).Width;
}
}
/// <summary>
/// Creates a random answer.
/// </summary>
/// <returns>The random answer.</returns>
/// <param name="allowedchars">The list of allowed chars, supply a character multiple times to change frequency.</param>
/// <param name="minlength">The minimum answer length.</param>
/// <param name="maxlength">The maximum answer length.</param>
public static string CreateRandomAnswer(string allowedchars = null, int minlength = 10, int maxlength = 12)
{
allowedchars = allowedchars ?? DEFAULT_CHARS;
var rnd = new Random();
var len = rnd.Next(Math.Min(minlength, maxlength), Math.Max(minlength, maxlength) + 1);
if (len <= 0)
throw new ArgumentException($"The values ${minlength} and ${maxlength} gave a final length of {len} and it must be greater than 0");
return new string(Enumerable.Range(0, len).Select(x => allowedchars[rnd.Next(0, allowedchars.Length)]).ToArray());
}
/// <summary>
/// Creates a captcha image.
/// </summary>
/// <returns>The captcha image.</returns>
/// <param name="answer">The captcha solution string.</param>
/// <param name="size">The size of the image, omit to get a size based on the string.</param>
/// <param name="fontsize">The size of the font used to create the captcha, in pixels.</param>
public static Bitmap CreateCaptcha(string answer, Size size = default(Size), int fontsize = 40)
{
var fontfamily = FontFamily.GenericSansSerif;
var text_width = ApproxTextWidth(answer, fontfamily, fontsize);
if (size.Width == 0 || size.Height == 0)
size = new Size((int) (text_width * 1.2), (int) (fontsize * 1.2));
var bmp = new Bitmap(size.Width, size.Height);
var rnd = new Random();
var stray_x = fontsize / 2;
var stray_y = size.Height / 4;
var ans_stray_x = fontsize / 3;
var ans_stray_y = size.Height / 6;
using (var graphics = Graphics.FromImage(bmp))
using (var font1 = new Font(fontfamily, fontsize, GraphicsUnit.Pixel))
using (var font2 = new Font(fontfamily, fontsize, GraphicsUnit.Pixel))
using (var font3 = new HatchBrush(HatchStyle.Shingle, Color.GhostWhite, Color.DarkBlue))
{
graphics.Clear(Color.White);
graphics.TextRenderingHint = TextRenderingHint.AntiAlias;
// Apply a some background string to make it hard to do OCR
foreach (var color in new[] { Color.Yellow, Color.LightGreen, Color.GreenYellow })
using (var brush = new SolidBrush(color))
graphics.DrawString(CreateRandomAnswer(minlength: answer.Length, maxlength: answer.Length), font2, brush, rnd.Next(-stray_x, stray_x), rnd.Next(-stray_y, stray_y));
var spacing = (size.Width / fontsize) + rnd.Next(0, stray_x);
// Create a vertical background lines
for (var i = rnd.Next(0, stray_x); i < size.Width; i += spacing)
using (var pen = new Pen(BRUSH_COLORS[rnd.Next(0, BRUSH_COLORS.Length)]))
graphics.DrawLine(pen, i + rnd.Next(-stray_x, stray_x), rnd.Next(0, stray_y), i + rnd.Next(-stray_x, stray_x), size.Height - rnd.Next(0, stray_y));
spacing = (size.Height / fontsize) + rnd.Next(0, stray_y);
// Create a horizontal background lines
for (var i = rnd.Next(0, stray_y); i < size.Height; i += spacing)
using (var pen = new Pen(BRUSH_COLORS[rnd.Next(0, BRUSH_COLORS.Length)]))
graphics.DrawLine(pen, rnd.Next(0, stray_x), i + rnd.Next(-stray_y, stray_y), size.Width - rnd.Next(0, stray_x), i + rnd.Next(-stray_y, stray_y));
// Draw the actual answer
graphics.DrawString(answer, font1, font3, ((size.Width - text_width) / 2) + rnd.Next(-ans_stray_x, ans_stray_x), ((size.Height - fontsize) / 2) + rnd.Next(-ans_stray_y, ans_stray_y));
return bmp;
}
}
}
}
@@ -0,0 +1,79 @@
// Copyright (C) 2015, The Duplicati Team
// http://www.duplicati.com, info@duplicati.com
//
// This library is free software; you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as
// published by the Free Software Foundation; either version 2.1 of the
// License, or (at your option) any later version.
//
// This library is distributed in the hope that it will be useful, but
// WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
// Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public
// License along with this library; if not, write to the Free Software
// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
using System;
using System.Linq;
using HttpServer;
using HttpServer.HttpModules;
using HttpServer.Exceptions;
using Duplicati.Library.Common.IO;
namespace Duplicati.Server.WebServer
{
internal class IndexHtmlHandler : HttpModule
{
private readonly string m_webroot;
private static readonly string[] ForbiddenChars = new string[] {"\\", "..", ":"}.Union(from n in System.IO.Path.GetInvalidPathChars() select n.ToString()).Distinct().ToArray();
private static readonly string DirSep = Util.DirectorySeparatorString;
public IndexHtmlHandler(string webroot) { m_webroot = webroot; }
public override bool Process(HttpServer.IHttpRequest request, HttpServer.IHttpResponse response, HttpServer.Sessions.IHttpSession session)
{
var path = this.GetPath(request.Uri);
var html = System.IO.Path.Combine(path, "index.html");
var htm = System.IO.Path.Combine(path, "index.htm");
if (System.IO.Directory.Exists(path) && (System.IO.File.Exists(html) || System.IO.File.Exists(htm)))
{
if (!request.Uri.AbsolutePath.EndsWith("/", StringComparison.Ordinal))
{
response.Redirect(request.Uri.AbsolutePath + "/");
return true;
}
response.Status = System.Net.HttpStatusCode.OK;
response.Reason = "OK";
response.ContentType = "text/html; charset=utf-8";
response.AddHeader("Cache-Control", "no-cache, no-store, must-revalidate, max-age=0");
using (var fs = System.IO.File.OpenRead(System.IO.File.Exists(html) ? html : htm))
{
response.ContentLength = fs.Length;
response.Body = fs;
response.Send();
}
return true;
}
return false;
}
private string GetPath(Uri uri)
{
if (ForbiddenChars.Any(x => uri.AbsolutePath.Contains(x)))
throw new BadRequestException("Illegal path");
var uripath = Uri.UnescapeDataString(uri.AbsolutePath);
while(uripath.Length > 0 && (uripath.StartsWith("/", StringComparison.Ordinal) || uripath.StartsWith(DirSep, StringComparison.Ordinal)))
uripath = uripath.Substring(1);
return System.IO.Path.Combine(m_webroot, uripath.Replace('/', System.IO.Path.DirectorySeparatorChar));
}
}
}
@@ -0,0 +1,259 @@
// Copyright (C) 2015, The Duplicati Team
// http://www.duplicati.com, info@duplicati.com
//
// This library is free software; you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as
// published by the Free Software Foundation; either version 2.1 of the
// License, or (at your option) any later version.
//
// This library is distributed in the hope that it will be useful, but
// WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
// Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public
// License along with this library; if not, write to the Free Software
// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using HttpServer.HttpModules;
using Duplicati.Server.WebServer.RESTMethods;
using Duplicati.Library.RestAPI;
namespace Duplicati.Server.WebServer
{
public class RESTHandler : HttpModule
{
public const string API_URI_PATH = "/api/v1";
public static readonly int API_URI_SEGMENTS = API_URI_PATH.Split(new char[] {'/'}).Length;
private static readonly Dictionary<string, IRESTMethod> _modules = new Dictionary<string, IRESTMethod>(StringComparer.OrdinalIgnoreCase);
public static IDictionary<string, IRESTMethod> Modules { get { return _modules; } }
/// <summary>
/// Loads all REST modules in the Duplicati.Server.WebServer.RESTMethods namespace
/// </summary>
static RESTHandler()
{
var lst =
from n in typeof(IRESTMethod).Assembly.GetTypes()
where
n.Namespace == typeof(IRESTMethod).Namespace
&&
typeof(IRESTMethod).IsAssignableFrom(n)
&&
!n.IsAbstract
&&
!n.IsInterface
select n;
foreach(var t in lst)
{
var m = (IRESTMethod)Activator.CreateInstance(t);
_modules.Add(t.Name.ToLowerInvariant(), m);
}
}
public static void HandleControlCGI(HttpServer.IHttpRequest request, HttpServer.IHttpResponse response, HttpServer.Sessions.IHttpSession session, Type module)
{
var method = request.Method;
if (!string.IsNullOrWhiteSpace(request.Headers["X-HTTP-Method-Override"]))
method = request.Headers["X-HTTP-Method-Override"];
DoProcess(request, response, session, method, module.Name.ToLowerInvariant(), (String.Equals(request.Method, "POST", StringComparison.OrdinalIgnoreCase) ? request.Form : request.QueryString)["id"].Value);
}
private static readonly ConcurrentDictionary<string, System.Globalization.CultureInfo> _cultureCache = new ConcurrentDictionary<string, System.Globalization.CultureInfo>(StringComparer.OrdinalIgnoreCase);
private static System.Globalization.CultureInfo ParseRequestCulture(RequestInfo info)
{
// Inject the override
return ParseRequestCulture(string.Format("{0},{1}", info.Request.Headers["X-UI-Language"], info.Request.Headers["Accept-Language"]));
}
public static System.Globalization.CultureInfo ParseDefaultRequestCulture(RequestInfo info)
{
if (info == null)
return null;
return ParseRequestCulture(info.Request.Headers["Accept-Language"]);
}
private static System.Globalization.CultureInfo ParseRequestCulture(string acceptheader)
{
acceptheader = acceptheader ?? string.Empty;
// Lock-free read
System.Globalization.CultureInfo ci;
if (_cultureCache.TryGetValue(acceptheader, out ci))
return ci;
// Lock-free assignment, we might compute the value twice
return _cultureCache[acceptheader] =
// Parse headers like "Accept-Language: da, en-gb;q=0.8, en;q=0.7"
acceptheader
.Split(new[] { "," }, StringSplitOptions.RemoveEmptyEntries)
.Select(x =>
{
var opts = x.Split(new[] { ";" }, StringSplitOptions.RemoveEmptyEntries);
var lang = opts.FirstOrDefault();
var weight =
opts.Where(y => y.StartsWith("q=", StringComparison.OrdinalIgnoreCase))
.Select(y =>
{
float f;
float.TryParse(y.Substring(2), System.Globalization.NumberStyles.Float, System.Globalization.CultureInfo.InvariantCulture, out f);
return f;
}).FirstOrDefault();
// Set the default weight=1
if (weight <= 0.001 && weight >= 0)
weight = 1;
return new KeyValuePair<string, float>(lang, weight);
})
// Handle priority
.OrderByDescending(x => x.Value)
.Select(x => x.Key)
.Distinct()
// Filter invalid/unsupported items
.Where(x => !string.IsNullOrWhiteSpace(x) && Library.Localization.LocalizationService.ParseCulture(x) != null)
.Select(x => Library.Localization.LocalizationService.ParseCulture(x))
// And get the first that works
.FirstOrDefault();
}
public static void DoProcess(RequestInfo info, string method, string module, string key)
{
var ci = ParseRequestCulture(info);
using (Library.Localization.LocalizationService.TemporaryContext(ci))
{
try
{
if (ci != null)
info.Response.AddHeader("Content-Language", ci.Name);
IRESTMethod mod;
_modules.TryGetValue(module, out mod);
if (mod == null)
{
info.Response.Status = System.Net.HttpStatusCode.NotFound;
info.Response.Reason = "No such module";
}
else if (method == HttpServer.Method.Get && mod is IRESTMethodGET get)
{
if (info.Request.Form != HttpServer.HttpForm.EmptyForm)
{
if (info.Request.QueryString == HttpServer.HttpInput.Empty)
{
var r = info.Request.GetType().GetField("_queryString", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance);
r.SetValue(info.Request, new HttpServer.HttpInput("formdata"));
}
foreach (HttpServer.HttpInputItem v in info.Request.Form)
if (!info.Request.QueryString.Contains(v.Name))
info.Request.QueryString.Add(v.Name, v.Value);
}
get.GET(key, info);
}
else if (method == HttpServer.Method.Put && mod is IRESTMethodPUT put)
put.PUT(key, info);
else if (method == HttpServer.Method.Post && mod is IRESTMethodPOST post)
{
if (info.Request.Form == HttpServer.HttpForm.EmptyForm || info.Request.Form == HttpServer.HttpInput.Empty)
{
var r = info.Request.GetType().GetMethod("AssignForm", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance, null, new Type[] {typeof(HttpServer.HttpForm)}, null);
r.Invoke(info.Request, new object[] {new HttpServer.HttpForm(info.Request.QueryString)});
}
else
{
foreach (HttpServer.HttpInputItem v in info.Request.QueryString)
if (!info.Request.Form.Contains(v.Name))
info.Request.Form.Add(v.Name, v.Value);
}
post.POST(key, info);
}
else if (method == HttpServer.Method.Delete && mod is IRESTMethodDELETE delete)
delete.DELETE(key, info);
else if (method == "PATCH" && mod is IRESTMethodPATCH patch)
patch.PATCH(key, info);
else
{
info.Response.Status = System.Net.HttpStatusCode.MethodNotAllowed;
info.Response.Reason = "Method is not allowed";
}
}
catch (Exception ex)
{
FIXMEGlobal.DataConnection.LogError("", string.Format("Request for {0} gave error", info.Request.Uri), ex);
Console.WriteLine(ex);
try
{
if (!info.Response.HeadersSent)
{
info.Response.Status = System.Net.HttpStatusCode.InternalServerError;
info.Response.Reason = "Error";
info.Response.ContentType = "text/plain";
var wex = ex;
while (wex is System.Reflection.TargetInvocationException && wex.InnerException != wex)
wex = wex.InnerException;
info.BodyWriter.WriteJsonObject(new
{
Message = wex.Message,
Type = wex.GetType().Name,
#if DEBUG
Stacktrace = wex.ToString()
#endif
});
info.BodyWriter.Flush();
}
}
catch (Exception flex)
{
FIXMEGlobal.DataConnection.LogError("", "Reporting error gave error", flex);
}
}
}
}
public static void DoProcess(HttpServer.IHttpRequest request, HttpServer.IHttpResponse response, HttpServer.Sessions.IHttpSession session, string method, string module, string key)
{
using(var reqinfo = new RequestInfo(request, response, session))
DoProcess(reqinfo, method, module, key);
}
public override bool Process(HttpServer.IHttpRequest request, HttpServer.IHttpResponse response, HttpServer.Sessions.IHttpSession session)
{
if (!request.Uri.AbsolutePath.StartsWith(API_URI_PATH, StringComparison.OrdinalIgnoreCase))
return false;
var module = request.Uri.Segments.Skip(API_URI_SEGMENTS).FirstOrDefault();
if (string.IsNullOrWhiteSpace(module))
module = "help";
module = module.Trim('/');
var key = string.Join("", request.Uri.Segments.Skip(API_URI_SEGMENTS + 1)).Trim('/');
var method = request.Method;
if (!string.IsNullOrWhiteSpace(request.Headers["X-HTTP-Method-Override"]))
method = request.Headers["X-HTTP-Method-Override"];
DoProcess(request, response, session, method, module, key);
return true;
}
}
}
@@ -0,0 +1,455 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using HttpServer.HttpModules;
using System.Security.Cryptography.X509Certificates;
using Duplicati.Library.Common.IO;
using Duplicati.Library.RestAPI;
namespace Duplicati.Server.WebServer
{
public class Server
{
/// <summary>
/// The tag used for logging
/// </summary>
private static readonly string LOGTAG = Duplicati.Library.Logging.Log.LogTagFromType<Server>();
/// <summary>
/// Option for changing the webroot folder
/// </summary>
public const string OPTION_WEBROOT = "webservice-webroot";
/// <summary>
/// Option for changing the webservice listen port
/// </summary>
public const string OPTION_PORT = "webservice-port";
/// <summary>
/// Option for changing the webservice listen interface
/// </summary>
public const string OPTION_INTERFACE = "webservice-interface";
/// <summary>
/// The default path to the web root
/// </summary>
public const string DEFAULT_OPTION_WEBROOT = "webroot";
/// <summary>
/// The default listening port
/// </summary>
public const int DEFAULT_OPTION_PORT = 8200;
/// <summary>
/// Option for setting the webservice SSL certificate
/// </summary>
public const string OPTION_SSLCERTIFICATEFILE = "webservice-sslcertificatefile";
/// <summary>
/// Option for setting the webservice SSL certificate key
/// </summary>
public const string OPTION_SSLCERTIFICATEFILEPASSWORD = "webservice-sslcertificatepassword";
/// <summary>
/// The default listening interface
/// </summary>
public const string DEFAULT_OPTION_INTERFACE = "loopback";
/// <summary>
/// The single webserver instance
/// </summary>
private readonly HttpServer.HttpServer m_server;
/// <summary>
/// The webserver listening port
/// </summary>
public readonly int Port;
/// <summary>
/// A string that is sent out instead of password values
/// </summary>
public const string PASSWORD_PLACEHOLDER = "**********";
/// <summary>
/// Sets up the webserver and starts it
/// </summary>
/// <param name="options">A set of options</param>
public Server(IDictionary<string, string> options)
{
string portstring;
IEnumerable<int> ports = null;
options.TryGetValue(OPTION_PORT, out portstring);
if (!string.IsNullOrEmpty(portstring))
ports =
from n in portstring.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries)
where int.TryParse(n, out _)
select int.Parse(n);
if (ports == null || !ports.Any())
ports = new int[] { DEFAULT_OPTION_PORT };
string interfacestring;
System.Net.IPAddress listenInterface;
options.TryGetValue(OPTION_INTERFACE, out interfacestring);
if (string.IsNullOrWhiteSpace(interfacestring))
interfacestring = FIXMEGlobal.DataConnection.ApplicationSettings.ServerListenInterface;
if (string.IsNullOrWhiteSpace(interfacestring))
interfacestring = DEFAULT_OPTION_INTERFACE;
if (interfacestring.Trim() == "*" || interfacestring.Trim().Equals("any", StringComparison.OrdinalIgnoreCase) || interfacestring.Trim().Equals("all", StringComparison.OrdinalIgnoreCase))
listenInterface = System.Net.IPAddress.Any;
else if (interfacestring.Trim() == "loopback")
listenInterface = System.Net.IPAddress.Loopback;
else
listenInterface = System.Net.IPAddress.Parse(interfacestring);
string certificateFile;
options.TryGetValue(OPTION_SSLCERTIFICATEFILE, out certificateFile);
string certificateFilePassword;
options.TryGetValue(OPTION_SSLCERTIFICATEFILEPASSWORD, out certificateFilePassword);
X509Certificate2 cert = null;
bool certValid = false;
if (certificateFile == null)
{
try
{
cert = FIXMEGlobal.DataConnection.ApplicationSettings.ServerSSLCertificate;
if (cert != null)
certValid = cert.HasPrivateKey;
}
catch (Exception ex)
{
Duplicati.Library.Logging.Log.WriteWarningMessage(LOGTAG, "DefectStoredSSLCert", ex, Strings.Server.DefectSSLCertInDatabase);
}
}
else if (certificateFile.Length == 0)
{
FIXMEGlobal.DataConnection.ApplicationSettings.ServerSSLCertificate = null;
}
else
{
try
{
if (string.IsNullOrWhiteSpace(certificateFilePassword))
cert = new X509Certificate2(certificateFile, "", X509KeyStorageFlags.Exportable);
else
cert = new X509Certificate2(certificateFile, certificateFilePassword, X509KeyStorageFlags.Exportable);
certValid = cert.HasPrivateKey;
}
catch (Exception ex)
{
throw new Exception(Strings.Server.SSLCertificateFailure(ex.Message), ex);
}
}
// If we are in hosted mode with no specified port,
// then try different ports
foreach (var p in ports)
try
{
// Due to the way the server is initialized,
// we cannot try to start it again on another port,
// so we create a new server for each attempt
var server = CreateServer(options);
if (!certValid)
server.Start(listenInterface, p);
else
{
var secProtocols = System.Security.Authentication.SslProtocols.Tls12;
try
{
//try TLS 1.3 (type not available on .NET < 4.8)
secProtocols = System.Security.Authentication.SslProtocols.Tls12 | (System.Security.Authentication.SslProtocols)12288;
}
catch (NotSupportedException)
{
}
server.Start(listenInterface, p, cert, secProtocols, null, false);
}
m_server = server;
m_server.ServerName = string.Format("{0} v{1}", Library.AutoUpdater.AutoUpdateSettings.AppName, System.Reflection.Assembly.GetExecutingAssembly().GetName().Version);
this.Port = p;
if (interfacestring != FIXMEGlobal.DataConnection.ApplicationSettings.ServerListenInterface)
FIXMEGlobal.DataConnection.ApplicationSettings.ServerListenInterface = interfacestring;
if (certValid && !cert.Equals(FIXMEGlobal.DataConnection.ApplicationSettings.ServerSSLCertificate))
FIXMEGlobal.DataConnection.ApplicationSettings.ServerSSLCertificate = cert;
Duplicati.Library.Logging.Log.WriteInformationMessage(LOGTAG, "ServerListening", Strings.Server.StartedServer(listenInterface.ToString(), p));
return;
}
catch (System.Net.Sockets.SocketException)
{
}
throw new Exception(Strings.Server.ServerStartFailure(ports));
}
private static void AddMimeTypes(FileModule fm)
{
fm.AddDefaultMimeTypes();
fm.MimeTypes["htc"] = "text/x-component";
fm.MimeTypes["json"] = "application/json";
fm.MimeTypes["map"] = "application/json";
fm.MimeTypes["htm"] = "text/html; charset=utf-8";
fm.MimeTypes["html"] = "text/html; charset=utf-8";
fm.MimeTypes["hbs"] = "application/x-handlebars-template";
fm.MimeTypes["woff"] = "application/font-woff";
fm.MimeTypes["woff2"] = "application/font-woff";
}
private static HttpServer.HttpServer CreateServer(IDictionary<string, string> options)
{
HttpServer.HttpServer server = new HttpServer.HttpServer();
server.Add(new HostHeaderChecker());
if (string.Equals(Environment.GetEnvironmentVariable("SYNO_DSM_AUTH") ?? string.Empty, "1"))
server.Add(new SynologyAuthenticationHandler());
server.Add(new AuthenticationHandler());
server.Add(new RESTHandler());
string webroot = System.IO.Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location);
string install_webroot = System.IO.Path.Combine(Library.AutoUpdater.UpdaterManager.InstalledBaseDir, "webroot");
#if DEBUG
// Easy test for extensions while debugging
install_webroot = Library.AutoUpdater.UpdaterManager.InstalledBaseDir;
if (!System.IO.Directory.Exists(System.IO.Path.Combine(webroot, "webroot")))
{
//For debug we go "../../../.." to get out of "GUI/Duplicati.GUI.TrayIcon/bin/debug"
string tmpwebroot = System.IO.Path.GetFullPath(System.IO.Path.Combine(webroot, "..", "..", "..", ".."));
tmpwebroot = System.IO.Path.Combine(tmpwebroot, "Server");
if (System.IO.Directory.Exists(System.IO.Path.Combine(tmpwebroot, "webroot")))
webroot = tmpwebroot;
else
{
//If we are running the server standalone, we only need to exit "bin/Debug"
tmpwebroot = System.IO.Path.GetFullPath(System.IO.Path.Combine(webroot, "..", ".."));
if (System.IO.Directory.Exists(System.IO.Path.Combine(tmpwebroot, "webroot")))
webroot = tmpwebroot;
}
}
#endif
webroot = System.IO.Path.Combine(webroot, "webroot");
if (options.ContainsKey(OPTION_WEBROOT))
{
string userroot = options[OPTION_WEBROOT];
#if DEBUG
//In debug mode we do not care where the path points
#else
//In release mode we check that the user supplied path is located
// in the same folders as the running application, to avoid users
// that inadvertently expose top level folders
if (!string.IsNullOrWhiteSpace(userroot)
&&
(
userroot.StartsWith(Util.AppendDirSeparator(System.Reflection.Assembly.GetExecutingAssembly().Location), Library.Utility.Utility.ClientFilenameStringComparison)
||
userroot.StartsWith(Util.AppendDirSeparator(Program.StartupPath), Library.Utility.Utility.ClientFilenameStringComparison)
)
)
#endif
{
webroot = userroot;
install_webroot = webroot;
}
}
if (install_webroot != webroot && System.IO.Directory.Exists(System.IO.Path.Combine(install_webroot, "customized")))
{
var customized_files = new CacheControlFileHandler("/customized/", System.IO.Path.Combine(install_webroot, "customized"));
AddMimeTypes(customized_files);
server.Add(customized_files);
}
if (install_webroot != webroot && System.IO.Directory.Exists(System.IO.Path.Combine(install_webroot, "oem")))
{
var oem_files = new CacheControlFileHandler("/oem/", System.IO.Path.Combine(install_webroot, "oem"));
AddMimeTypes(oem_files);
server.Add(oem_files);
}
if (install_webroot != webroot && System.IO.Directory.Exists(System.IO.Path.Combine(install_webroot, "package")))
{
var proxy_files = new CacheControlFileHandler("/proxy/", System.IO.Path.Combine(install_webroot, "package"));
AddMimeTypes(proxy_files);
server.Add(proxy_files);
}
var fh = new CacheControlFileHandler("/", webroot, true);
AddMimeTypes(fh);
server.Add(fh);
server.Add(new IndexHtmlHandler(webroot));
#if DEBUG
//For debugging, it is nice to know when we get a 404
server.Add(new DebugReportHandler());
#endif
return server;
}
private class DebugReportHandler : HttpModule
{
public override bool Process(HttpServer.IHttpRequest request, HttpServer.IHttpResponse response, HttpServer.Sessions.IHttpSession session)
{
System.Diagnostics.Trace.WriteLine(string.Format("Rejecting request for {0}", request.Uri));
return false;
}
}
private class CacheControlFileHandler : FileModule
{
public CacheControlFileHandler(string baseUri, string basePath, bool useLastModifiedHeader = false)
: base(baseUri, basePath, useLastModifiedHeader)
{
}
public override bool Process(HttpServer.IHttpRequest request, HttpServer.IHttpResponse response, HttpServer.Sessions.IHttpSession session)
{
if (!this.CanHandle(request.Uri))
return false;
if (request.Uri.AbsolutePath.EndsWith("index.html", StringComparison.Ordinal) || request.Uri.AbsolutePath.EndsWith("index.htm", StringComparison.Ordinal))
response.AddHeader("Cache-Control", "no-cache, no-store, must-revalidate, max-age=0");
else
response.AddHeader("Cache-Control", "max-age=" + (60 * 60 * 24));
return base.Process(request, response, session);
}
}
/// <summary>
/// Module for injecting host header verification
/// </summary>
private class HostHeaderChecker : HttpModule
{
/// <summary>
/// The hostnames that we allow
/// </summary>
private string[] m_lastSplitNames;
/// <summary>
/// The string used to generate m_lastSplitNames;
/// </summary>
private string m_lastAllowed;
/// <summary>
/// A regex to detect potential IPv4 addresses.
/// Note that this also detects things that are not valid IPv4.
/// </summary>
private static readonly System.Text.RegularExpressions.Regex IPV4 = new System.Text.RegularExpressions.Regex(@"((\d){1,3}\.){3}(\d){1,3}");
/// <summary>
/// A regex to detect potential IPv6 addresses.
/// Note that this also detects things that are not valid IPv6.
/// </summary>
private static readonly System.Text.RegularExpressions.Regex IPV6 = new System.Text.RegularExpressions.Regex(@"(\:)?(\:?[A-Fa-f0-9]{1,4}\:?){1,8}(\:)?");
/// <summary>
/// The hostnames that are always allowed
/// </summary>
private static readonly string[] DEFAULT_ALLOWED = new string[] { "localhost", "127.0.0.1", "::1", "localhost.localdomain" };
/// <summary>
/// Process the received request
/// </summary>
/// <returns>A flag indicating if the request is handled.</returns>
/// <param name="request">The received request.</param>
/// <param name="response">The response object.</param>
/// <param name="session">The session state.</param>
public override bool Process(HttpServer.IHttpRequest request, HttpServer.IHttpResponse response, HttpServer.Sessions.IHttpSession session)
{
string[] h = null;
var hstring = FIXMEGlobal.DataConnection.ApplicationSettings.AllowedHostnames;
if (!string.IsNullOrWhiteSpace(hstring))
{
h = m_lastSplitNames;
if (hstring != m_lastAllowed)
{
m_lastAllowed = hstring;
h = m_lastSplitNames = (hstring ?? string.Empty).Split(new char[] { ';' }, StringSplitOptions.RemoveEmptyEntries);
}
if (h == null || h.Length == 0)
h = null;
}
// For some reason, the web server strips out the host header
var host = request.Headers["Host"];
if (string.IsNullOrWhiteSpace(host))
host = request.Uri.Host;
// This should not happen
if (string.IsNullOrWhiteSpace(host))
{
response.Reason = "Invalid request, missing host header";
response.Status = System.Net.HttpStatusCode.Forbidden;
var msg = System.Text.Encoding.ASCII.GetBytes(response.Reason);
response.ContentType = "text/plain";
response.ContentLength = msg.Length;
response.Body.Write(msg, 0, msg.Length);
response.Send();
return true;
}
// Check the hostnames we always allow
if (DEFAULT_ALLOWED.Contains(host, StringComparer.OrdinalIgnoreCase))
return false;
// Then the user specified ones
if (h != null && h.Contains(host, StringComparer.OrdinalIgnoreCase))
return false;
// Disable checks if we have an asterisk
if (h != null && Array.IndexOf(h, "*") >= 0)
return false;
// Finally, check if we have a potential IP address
var v4 = IPV4.Match(host);
var v6 = IPV6.Match(host);
if ((v4.Success && v4.Length == host.Length) || (v6.Success && v6.Length == host.Length))
{
try
{
// Verify that the hostname is indeed a valid IP address
System.Net.IPAddress.Parse(host);
return false;
}
catch
{ }
}
// Failed to find a valid header
response.Reason = $"The host header sent by the client is not allowed";
response.Status = System.Net.HttpStatusCode.Forbidden;
var txt = System.Text.Encoding.ASCII.GetBytes(response.Reason);
response.ContentType = "text/plain";
response.ContentLength = txt.Length;
response.Body.Write(txt, 0, txt.Length);
response.Send();
return true;
}
}
}
}
@@ -0,0 +1,289 @@
// Copyright (C) 2017, The Duplicati Team
// http://www.duplicati.com, info@duplicati.com
//
// This library is free software; you can redistribute it and/or modify
// it under the terms of the GNU Lesser General Public License as
// published by the Free Software Foundation; either version 2.1 of the
// License, or (at your option) any later version.
//
// This library is distributed in the hope that it will be useful, but
// WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
// Lesser General Public License for more details.
//
// You should have received a copy of the GNU Lesser General Public
// License along with this library; if not, write to the Free Software
// Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using HttpServer.HttpModules;
namespace Duplicati.Server.WebServer
{
/// <summary>
/// Helper class for enforcing the built-in authentication on Synology DSM
/// </summary>
public class SynologyAuthenticationHandler : HttpModule
{
/// <summary>
/// The path to the login.cgi script
/// </summary>
private readonly string LOGIN_CGI = GetEnvArg("SYNO_LOGIN_CGI", "/usr/syno/synoman/webman/login.cgi");
/// <summary>
/// The path to the authenticate.cgi script
/// </summary>
private readonly string AUTH_CGI = GetEnvArg("SYNO_AUTHENTICATE_CGI", "/usr/syno/synoman/webman/modules/authenticate.cgi");
/// <summary>
/// A flag indicating if only admins are allowed
/// </summary>
private readonly bool ADMIN_ONLY = !(GetEnvArg("SYNO_ALL_USERS", "0") == "1");
/// <summary>
/// A flag indicating if the XSRF token should be fetched automatically
/// </summary>
private readonly bool AUTO_XSRF = GetEnvArg("SYNO_AUTO_XSRF", "1") == "1";
/// <summary>
/// A flag indicating that the auth-module is fully disabled
/// </summary>
private readonly bool FULLY_DISABLED;
/// <summary>
/// Re-evaluate the logins periodically to ensure it is still valid
/// </summary>
private readonly TimeSpan CACHE_TIMEOUT = TimeSpan.FromMinutes(3);
/// <summary>
/// A cache of previously authenticated logins
/// </summary>
private readonly ConcurrentDictionary<string, DateTime> m_logincache = new ConcurrentDictionary<string, DateTime>();
/// <summary>
/// Initializes a new instance of the <see cref="T:Duplicati.Server.WebServer.SynologyAuthenticationHandler"/> class.
/// </summary>
public SynologyAuthenticationHandler()
{
Console.WriteLine("Enabling Synology integrated authentication handler");
var disable = false;
if (!File.Exists(LOGIN_CGI))
{
Console.WriteLine("Disabling webserver as the login script is not found: {0}", LOGIN_CGI);
disable = true;
}
if (!File.Exists(AUTH_CGI))
{
Console.WriteLine("Disabling webserver as the auth script is not found: {0}", AUTH_CGI);
disable = true;
}
FULLY_DISABLED = disable;
}
/// <summary>
/// Processes the request
/// </summary>
/// <returns><c>true</c> if the request is handled <c>false</c> otherwise.</returns>
/// <param name="request">The request.</param>
/// <param name="response">The response.</param>
/// <param name="session">The session.</param>
public override bool Process(HttpServer.IHttpRequest request, HttpServer.IHttpResponse response, HttpServer.Sessions.IHttpSession session)
{
if (FULLY_DISABLED)
{
response.Status = System.Net.HttpStatusCode.ServiceUnavailable;
response.Reason = "The system is incorrectly configured";
return true;
}
var limitedAccess =
request.Uri.AbsolutePath.StartsWith(RESTHandler.API_URI_PATH, StringComparison.OrdinalIgnoreCase)
||
request.Uri.AbsolutePath.StartsWith(AuthenticationHandler.LOGIN_SCRIPT_URI, StringComparison.OrdinalIgnoreCase)
||
request.Uri.AbsolutePath.StartsWith(AuthenticationHandler.LOGOUT_SCRIPT_URI, StringComparison.OrdinalIgnoreCase);
if (!limitedAccess)
return false;
var tmpenv = new Dictionary<string, string>();
tmpenv["REMOTE_ADDR"] = request.RemoteEndPoint.Address.ToString();
tmpenv["REMOTE_PORT"] = request.RemoteEndPoint.Port.ToString();
if (!string.IsNullOrWhiteSpace(request.Headers["X-Real-IP"]))
tmpenv["REMOTE_ADDR"] = request.Headers["X-Real-IP"];
if (!string.IsNullOrWhiteSpace(request.Headers["X-Real-IP"]))
tmpenv["REMOTE_PORT"] = request.Headers["X-Real-Port"];
var loginid = request.Cookies["id"]?.Value;
if (!string.IsNullOrWhiteSpace(loginid))
tmpenv["HTTP_COOKIE"] = "id=" + loginid;
var xsrftoken = request.Headers["X-Syno-Token"];
if (string.IsNullOrWhiteSpace(xsrftoken))
xsrftoken = request.QueryString["SynoToken"]?.Value;
var cachestring = BuildCacheKey(tmpenv, xsrftoken);
DateTime cacheExpires;
if (m_logincache.TryGetValue(cachestring, out cacheExpires) && cacheExpires > DateTime.Now)
{
// We do not refresh the cache, as we need to ask the synology auth system periodically
return false;
}
if (string.IsNullOrWhiteSpace(xsrftoken) && AUTO_XSRF)
{
var authre = new Regex(@"""SynoToken""\s?\:\s?""(?<token>[^""]+)""");
try
{
var resp = ShellExec(LOGIN_CGI, env: tmpenv).Result;
var m = authre.Match(resp);
if (m.Success)
xsrftoken = m.Groups["token"].Value;
else
throw new Exception("Unable to get XSRF token");
}
catch (Exception)
{
response.Status = System.Net.HttpStatusCode.InternalServerError;
response.Reason = "The system is incorrectly configured";
return true;
}
}
if (!string.IsNullOrWhiteSpace(xsrftoken))
tmpenv["HTTP_X_SYNO_TOKEN"] = xsrftoken;
cachestring = BuildCacheKey(tmpenv, xsrftoken);
var username = GetEnvArg("SYNO_USERNAME");
if (string.IsNullOrWhiteSpace(username))
{
try
{
username = ShellExec(AUTH_CGI, shell: false, exitcode: 0, env: tmpenv).Result;
}
catch (Exception)
{
response.Status = System.Net.HttpStatusCode.InternalServerError;
response.Reason = "The system is incorrectly configured";
return true;
}
}
if (string.IsNullOrWhiteSpace(username))
{
response.Status = System.Net.HttpStatusCode.Forbidden;
response.Reason = "Permission denied, not logged in";
return true;
}
username = username.Trim();
if (ADMIN_ONLY)
{
var groups = GetEnvArg("SYNO_GROUP_IDS");
if (string.IsNullOrWhiteSpace(groups))
{
groups = ShellExec("id", "-G '" + username.Trim().Replace("'", "\\'") + "'", exitcode: 0).Result ?? string.Empty;
groups = groups.Replace(Environment.NewLine, String.Empty);
}
if (!groups.Split(new char[] { ' ' }).Contains("101"))
{
response.Status = System.Net.HttpStatusCode.Forbidden;
response.Reason = "Administrator login required";
return true;
}
}
// We are now authenticated, add to cache
m_logincache[cachestring] = DateTime.Now + CACHE_TIMEOUT;
return false;
}
/// <summary>
/// Builds a cache key from the environment data
/// </summary>
/// <returns>The cache key.</returns>
/// <param name="values">The environment.</param>
/// <param name="xsrftoken">The XSRF token.</param>
private static string BuildCacheKey(Dictionary<string, string> values, string xsrftoken)
{
if (!values.ContainsKey("REMOTE_ADDR") || !values.ContainsKey("REMOTE_PORT") || !values.ContainsKey("HTTP_COOKIE"))
return null;
return string.Format("{0}:{1}/{2}?{3}", values["REMOTE_ADDR"], values["REMOTE_PORT"], values["HTTP_COOKIE"], xsrftoken);
}
/// <summary>
/// Runs an external command
/// </summary>
/// <returns>The stdout data.</returns>
/// <param name="command">The executable</param>
/// <param name="args">The executable and the arguments.</param>
/// <param name="shell">If set to <c>true</c> use the shell context for execution.</param>
/// <param name="exitcode">Set the value to check for a particular exitcode.</param>
private static async Task<string> ShellExec(string command, string args = null, bool shell = false, int exitcode = -1, Dictionary<string, string> env = null)
{
var psi = new ProcessStartInfo()
{
FileName = command,
Arguments = shell ? null : args,
UseShellExecute = false,
RedirectStandardInput = shell,
RedirectStandardOutput = true,
RedirectStandardError = false
};
if (env != null)
foreach (var pk in env)
psi.EnvironmentVariables[pk.Key] = pk.Value;
using (var p = System.Diagnostics.Process.Start(psi))
{
if (shell && args != null)
await p.StandardInput.WriteLineAsync(args);
var res = p.StandardOutput.ReadToEndAsync();
var tries = 10;
var ms = (int)TimeSpan.FromSeconds(0.5).TotalMilliseconds;
while (tries > 0 && !p.HasExited)
{
tries--;
p.WaitForExit(ms);
}
if (!p.HasExited)
try { p.Kill(); }
catch { }
if (!p.HasExited || (p.ExitCode != exitcode && exitcode != -1))
throw new Exception(string.Format("Exit code was: {0}, stdout: {1}", p.ExitCode, res));
return await res;
}
}
/// <summary>
/// Gets the environment variable argument.
/// </summary>
/// <returns>The environment variable.</returns>
/// <param name="key">The name of the environment variable.</param>
/// <param name="default">The default value.</param>
private static string GetEnvArg(string key, string @default = null)
{
var res = Environment.GetEnvironmentVariable(key);
return string.IsNullOrWhiteSpace(res) ? @default : res.Trim();
}
}
}