using System.Globalization;
using System.Text;
using Sunfish.Auth;
using Sunfish.Config;
using Sunfish.Http;
using Sunfish.Log;
using Sunfish.Net;
using Sunfish.Qr;
namespace Sunfish.Web;
/// <summary>
/// Panel de administracion web en /$sunfish/admin/ (DOC/WEB.md#panel-de-administración):
/// desactivado por defecto; exige una IP de AllowFrom y el usuario "admin" con su contrasena
/// (HTTP Basic, con el mismo bloqueo por intentos que el resto). HTML clasico, formularios POST
/// con token anti-CSRF. Cambia lo mismo que la GUI salvo el propio HTTP (puerto, interfaces,
/// activarlo), que cortaria la conexion del panel: eso se cambia desde la aplicacion.
/// </summary>
public sealed partial class WebApp
{
public const string AdminUser = "admin";
private const string AdminBase = "/$sunfish/admin/";
private const string AdminKey = "/$sunfish/admin";
private async Task AdminAsync(HttpContext ctx, List<string> segments, ServerState st, ClientProfile profile)
{
var admin = st.Config.Admin;
var from = ctx.Request.RemoteEndPoint?.Address;
if (host is null || !admin.Enabled || admin.PasswordHash.Length == 0 || segments.Count > 3)
{
await SendErrorAsync(ctx, 404, st, profile);
return;
}
if (!AddressRules.Matches(admin.AllowFrom, from))
{
log.Warning($"{from}: admin panel not allowed from this address");
await SendErrorAsync(ctx, 403, st, profile);
return;
}
Authenticator.TryParseBasic(ctx.Request.Headers["Authorization"], out var user, out var password);
var result = _auth.Check([new UserConfig { Name = AdminUser, PasswordHash = admin.PasswordHash }], [AdminUser],
user.Length > 0 ? user : null, user.Length > 0 ? password : null, from);
if (result == AuthResult.Blocked)
{
ctx.Response.Headers["Retry-After"] = "60";
await SendErrorAsync(ctx, 429, st, profile);
return;
}
if (result != AuthResult.Ok)
{
if (result == AuthResult.Wrong)
log.Warning($"{from}: admin panel auth failed for '{user}'");
ctx.Response.Headers["WWW-Authenticate"] = $"Basic realm=\"{AsciiOnly(st.Config.Server.Name)} admin\", charset=\"UTF-8\"";
await SendErrorAsync(ctx, 401, st, profile);
return;
}
string page = segments.Count == 3 ? segments[2] : "";
bool classic = !profile.IsModern;
if (ctx.Request.Method == "POST" && page == "")
{
await AdminPostAsync(ctx, classic);
return;
}
if (ctx.Request.Method is not ("GET" or "HEAD"))
{
await SendErrorAsync(ctx, 405, st, profile);
return;
}
switch (page)
{
case "":
await SendAdminPageAsync(ctx, AdminMainPage(classic, ctx.Request.HasQuery("ok") ? Strings.AdminSaved : null, false));
break;
case "share":
var config = host.Config;
string? name = ctx.Request.GetQuery("n");
var share = name is null ? null : config.Shares.FirstOrDefault(s => s.Name.Equals(name, StringComparison.OrdinalIgnoreCase));
if (name is not null && share is null)
{
await SendErrorAsync(ctx, 404, st, profile);
return;
}
await SendAdminPageAsync(ctx, AdminSharePage(classic, share ?? NewShare(config), share?.Name, null));
break;
case "log":
await SendAdminPageAsync(ctx, AdminLogPage(classic));
break;
case "qr" when ctx.Request.GetQuery("u") is { Length: > 0 and <= 300 } url &&
(url.StartsWith("http://", StringComparison.Ordinal) || url.StartsWith("ftp://", StringComparison.Ordinal)):
ctx.Response.Headers["Cache-Control"] = "no-store";
await ctx.Response.SendAsync(QrImage.Png(QrCode.Encode(url), 5), "image/png", ctx.Aborted);
break;
default:
await SendErrorAsync(ctx, 404, st, profile);
break;
}
}
private static ShareConfig NewShare(SunfishConfig config) =>
new() { Name = ShareSettings.SuggestName(config, "share"), Location = ShareSettings.SuggestLocation(config, "share") };
private static async Task SendAdminPageAsync(HttpContext ctx, HtmlBuilder html)
{
ctx.Response.Headers["Cache-Control"] = "no-store";
await ctx.Response.SendAsync(html.ToBytes(), html.ContentType, ctx.Aborted);
}
// ------------------------------------------------------------------ paginas
private HtmlBuilder AdminStart(bool classic, string title, string? message, bool error)
{
var html = new HtmlBuilder(classic);
DirectoryPage.Header(html, title);
html.Raw(DirectoryPage.Font).Raw("<H2>").Text(title).Raw("</H2>\n")
.Raw("<P><A HREF=\"").Raw(AdminBase).Raw("\">").Text(Strings.AdminHome).Raw("</A> | <A HREF=\"").Raw(AdminBase)
.Raw("share\">").Text(Strings.AdminNewShare).Raw("</A> | <A HREF=\"").Raw(AdminBase).Raw("log\">")
.Text(Strings.AppLog).Raw("</A> | <A HREF=\"/\">").Text(Strings.Shares).Raw("</A></P>\n")
.Raw("<P><FONT SIZE=\"-1\" COLOR=\"#8A5A00\">").Text(Strings.AdminCleartext).Raw("</FONT></P>\n");
if (message is not null)
html.Raw("<TABLE BORDER=1 CELLPADDING=4 CELLSPACING=0><TR><TD BGCOLOR=\"").Raw(error ? "#F8D0D0" : "#D8F0D0").Raw("\">")
.Raw(DirectoryPage.Font).Text(message).Raw(DirectoryPage.EndFont).Raw("</TD></TR></TABLE>\n");
return html;
}
private static HtmlBuilder AdminEnd(HtmlBuilder html)
{
html.Raw(DirectoryPage.EndFont);
DirectoryPage.Footer(html);
return html;
}
private void FormStart(HtmlBuilder html, string action)
{
html.Raw("<FORM METHOD=POST ACTION=\"").Raw(AdminBase).Raw("\"><INPUT TYPE=HIDDEN NAME=\"a\" VALUE=\"").Raw(action)
.Raw("\"><INPUT TYPE=HIDDEN NAME=\"t\" VALUE=\"").Raw(_tokens.Create(action, AdminKey)).Raw("\">");
}
private static void Hidden(HtmlBuilder html, string name, string value) =>
html.Raw("<INPUT TYPE=HIDDEN NAME=\"").Raw(name).Raw("\" VALUE=\"").Text(value).Raw("\">");
private static void Field(HtmlBuilder html, string label, string name, string value, int size = 30, bool password = false)
{
html.Raw("<TR><TD>").Raw(DirectoryPage.Font).Text(label).Raw(DirectoryPage.EndFont).Raw("</TD><TD><INPUT TYPE=")
.Raw(password ? "PASSWORD" : "TEXT").Raw(" NAME=\"").Raw(name).Raw("\" SIZE=").Raw(size.ToString(CultureInfo.InvariantCulture))
.Raw(" VALUE=\"").Text(value).Raw("\"></TD></TR>\n");
}
private static void Check(HtmlBuilder html, string label, string name, bool value) =>
html.Raw("<TR><TD></TD><TD>").Raw(DirectoryPage.Font).Raw("<INPUT TYPE=CHECKBOX NAME=\"").Raw(name).Raw("\" VALUE=\"1\"")
.Raw(value ? " CHECKED" : "").Raw("> ").Text(label).Raw(DirectoryPage.EndFont).Raw("</TD></TR>\n");
private static void Submit(HtmlBuilder html, string label) =>
html.Raw("<TR><TD></TD><TD><INPUT TYPE=SUBMIT VALUE=\"").Text(label).Raw("\"></TD></TR>\n");
private HtmlBuilder AdminMainPage(bool classic, string? message, bool error)
{
var config = host!.Config;
var server = config.Server;
var html = AdminStart(classic, Strings.AdminTitle(server.Name), message, error);
// Estado y direcciones
html.Raw("<H3>").Text(Strings.AdminStatus).Raw("</H3>\n<UL>\n<LI>HTTP: ")
.Text(host.HttpRunning ? Strings.AdminOnPort(host.HttpPort) : Strings.AdminOff).Raw("\n<LI>FTP: ")
.Text(host.FtpRunning ? Strings.AdminOnPort(host.FtpPort) : Strings.AdminOff).Raw("\n</UL>\n");
var addresses = LocalAddresses.Get();
if (addresses.Count > 0 && host.HttpRunning)
{
html.Raw("<P>").Text(Strings.AppAddresses).Raw(":</P>\n<UL>\n");
foreach (var a in addresses)
{
string url = LocalAddresses.HttpUrl(a.Address, host.HttpPort);
html.Raw("<LI><A HREF=\"").Text(url).Raw("\">").Text(url).Raw("</A> (").Text(a.Interface).Raw(") [<A HREF=\"")
.Raw(AdminBase).Raw("qr?u=").Raw(Uri.EscapeDataString(url)).Raw("\">QR</A>]\n");
}
html.Raw("</UL>\n");
}
// Servidor
html.Raw("<H3>").Text(Strings.AdminServer).Raw("</H3>\n");
FormStart(html, "server");
html.Raw("<TABLE BORDER=0 CELLPADDING=2>\n");
Field(html, Strings.FieldName, "name", server.Name);
Check(html, Strings.AdminRootMenu, "rootmenu", server.RootMenu);
Check(html, Strings.AdminFtp, "ftp", server.Ftp);
Field(html, Strings.AdminFtpPort, "ftpport", server.FtpPort.ToString(CultureInfo.InvariantCulture), 6);
Submit(html, Strings.AdminSave);
html.Raw("</TABLE></FORM>\n<P><FONT SIZE=\"-1\">").Text(Strings.AdminHttpFromApp).Raw("</FONT></P>\n");
// Shares
html.Raw("<H3>").Text(Strings.Shares).Raw("</H3>\n<TABLE BORDER=1 CELLPADDING=3 CELLSPACING=0>\n<TR><TH>")
.Text(Strings.FieldName).Raw("</TH><TH>Location</TH><TH>").Text(Strings.FieldPath).Raw("</TH><TH></TH></TR>\n");
foreach (var s in config.Shares)
{
html.Raw("<TR><TD>").Text(s.Name).Raw(s.Enabled ? "" : " (" + HtmlBuilder.Escape(Strings.AdminDisabled, classic) + ")")
.Raw("</TD><TD>").Text(s.Location).Raw("</TD><TD>").Text(s.Path).Raw("</TD><TD NOWRAP>[<A HREF=\"").Raw(AdminBase)
.Raw("share?n=").Raw(Uri.EscapeDataString(s.Name)).Raw("\">").Text(Strings.AdminEdit).Raw("</A>] ");
FormStart(html, "share-del");
Hidden(html, "n", s.Name);
html.Raw("<INPUT TYPE=SUBMIT VALUE=\"").Text(Strings.DeleteShort).Raw("\"></FORM></TD></TR>\n");
}
html.Raw("</TABLE>\n<P><A HREF=\"").Raw(AdminBase).Raw("share\">").Text(Strings.AdminNewShare).Raw("</A></P>\n");
// Usuarios
html.Raw("<H3>").Text(Strings.AdminUsers).Raw("</H3>\n<TABLE BORDER=1 CELLPADDING=3 CELLSPACING=0>\n");
foreach (var u in config.Users)
{
html.Raw("<TR><TD>").Text(u.Name).Raw("</TD><TD>");
FormStart(html, "user-pass");
Hidden(html, "name", u.Name);
html.Raw(DirectoryPage.Font).Text(Strings.FieldPassword).Raw(": ").Raw(DirectoryPage.EndFont)
.Raw("<INPUT TYPE=PASSWORD NAME=\"p1\" SIZE=10> <INPUT TYPE=PASSWORD NAME=\"p2\" SIZE=10> <INPUT TYPE=SUBMIT VALUE=\"")
.Text(Strings.AdminChange).Raw("\"></FORM></TD><TD>");
FormStart(html, "user-del");
Hidden(html, "name", u.Name);
html.Raw("<INPUT TYPE=SUBMIT VALUE=\"").Text(Strings.DeleteShort).Raw("\"></FORM></TD></TR>\n");
}
html.Raw("</TABLE>\n");
FormStart(html, "user-add");
html.Raw("<TABLE BORDER=0 CELLPADDING=2>\n");
Field(html, Strings.AdminNewUser, "name", "", 16);
Field(html, Strings.FieldPassword, "p1", "", 16, password: true);
Field(html, Strings.AdminRepeat, "p2", "", 16, password: true);
Submit(html, Strings.AdminAdd);
html.Raw("</TABLE></FORM>\n");
// Panel
html.Raw("<H3>").Text(Strings.AdminPanel).Raw("</H3>\n");
FormStart(html, "admin");
html.Raw("<TABLE BORDER=0 CELLPADDING=2>\n");
Field(html, Strings.AdminAllowFrom, "allow", string.Join(", ", config.Admin.AllowFrom), 40);
Field(html, Strings.AdminCurrentPassword, "cur", "", 16, password: true);
Field(html, Strings.AdminNewPassword, "p1", "", 16, password: true);
Field(html, Strings.AdminRepeat, "p2", "", 16, password: true);
Submit(html, Strings.AdminSave);
html.Raw("</TABLE></FORM>\n<P><FONT SIZE=\"-1\">").Text(Strings.AdminAllowHelp).Raw("</FONT></P>\n");
return AdminEnd(html);
}
private HtmlBuilder AdminSharePage(bool classic, ShareConfig share, string? originalName, string? error)
{
var html = AdminStart(classic, originalName is null ? Strings.AdminNewShare : Strings.AdminEditShare(originalName), error, error is not null);
FormStart(html, "share-save");
Hidden(html, "orig", originalName ?? "");
html.Raw("<TABLE BORDER=0 CELLPADDING=2>\n");
Field(html, Strings.FieldName, "name", share.Name);
Field(html, Strings.FieldLocation, "location", share.Location);
Field(html, Strings.FieldPath, "path", share.Path, 50);
Field(html, Strings.FieldIndex, "index", share.Index);
Check(html, Strings.OptEnabled, "enabled", share.Enabled);
Check(html, Strings.OptListing, "listing", share.Listing);
Check(html, Strings.OptUpload, "upload", share.Upload);
Check(html, Strings.OptOverwrite, "overwrite", share.Overwrite);
Check(html, Strings.OptRename, "rename", share.Rename);
Check(html, Strings.OptDelete, "delete", share.Delete);
Check(html, Strings.OptZip, "zip", share.Zip);
Check(html, "FTP", "ftp", share.Ftp);
Check(html, "WebDAV", "webdav", share.WebDav);
Check(html, Strings.AdminMac, "mac", share.Mac);
Check(html, Strings.AdminShortNames, "shortnames", share.ShortNames);
var users = host!.Config.Users;
if (users.Count > 0)
{
html.Raw("<TR><TD VALIGN=TOP>").Raw(DirectoryPage.Font).Text(Strings.AdminAllowedUsers).Raw(DirectoryPage.EndFont).Raw("</TD><TD>");
foreach (var u in users)
html.Raw(DirectoryPage.Font).Raw("<INPUT TYPE=CHECKBOX NAME=\"u.").Text(u.Name).Raw("\" VALUE=\"1\"")
.Raw(share.Users.Contains(u.Name, StringComparer.OrdinalIgnoreCase) ? " CHECKED" : "").Raw("> ").Text(u.Name)
.Raw(DirectoryPage.EndFont).Raw("<BR>\n");
html.Raw("</TD></TR>\n");
}
Submit(html, Strings.AdminSave);
html.Raw("</TABLE></FORM>\n");
return AdminEnd(html);
}
private HtmlBuilder AdminLogPage(bool classic)
{
var html = AdminStart(classic, Strings.AppLog, null, false);
html.Raw("<PRE>");
foreach (var e in host!.Log.Recent().Reverse().Take(300))
html.Text($"{e.Time:yyyy-MM-dd HH:mm:ss} {(e.Level >= LogLevel.Warning ? e.Level.ToString().ToUpperInvariant() + " " : "")}{e.Message}\n");
html.Raw("</PRE>\n");
return AdminEnd(html);
}
// ------------------------------------------------------------------ cambios
private async Task AdminPostAsync(HttpContext ctx, bool classic)
{
var ms = new MemoryStream();
var buffer = new byte[8192];
int n;
while ((n = await ctx.Request.Body.ReadAsync(buffer, ctx.Aborted)) > 0)
{
ms.Write(buffer, 0, n);
if (ms.Length > MaxFormBytes)
throw new InvalidDataException("Form too large");
}
var f = HttpRequest.ParseQuery(Encoding.Latin1.GetString(ms.ToArray()));
string action = f.GetValueOrDefault("a") ?? "";
if (!_tokens.Validate(f.GetValueOrDefault("t"), action, AdminKey))
{
await SendAdminPageAsync(ctx, AdminMainPage(classic, Strings.AdminExpired, true));
return;
}
var config = host!.Config.Clone();
string Get(string key) => (f.GetValueOrDefault(key) ?? "").Trim();
bool On(string key) => f.ContainsKey(key);
string? error = null;
switch (action)
{
case "server":
config.Server.Name = Get("name").Length > 0 ? Get("name") : config.Server.Name;
config.Server.RootMenu = On("rootmenu");
config.Server.Ftp = On("ftp");
if (int.TryParse(Get("ftpport"), NumberStyles.None, CultureInfo.InvariantCulture, out int port) && port is >= 1024 and <= 65535)
config.Server.FtpPort = port;
else
error = Strings.AdminBadPort;
break;
case "share-save":
{
string orig = Get("orig");
var existing = orig.Length > 0 ? config.Shares.FirstOrDefault(s => s.Name.Equals(orig, StringComparison.OrdinalIgnoreCase)) : null;
var share = existing ?? new ShareConfig();
var edited = new ShareConfig
{
Name = Get("name"), Location = Get("location"), Path = Get("path"), Index = Get("index"),
Enabled = On("enabled"), Listing = On("listing"), Upload = On("upload"), Overwrite = On("overwrite"),
Rename = On("rename"), Delete = On("delete"), Zip = On("zip"), Ftp = On("ftp"), WebDav = On("webdav"),
Mac = On("mac"), ShortNames = On("shortnames"),
Users = config.Users.Where(u => On("u." + u.Name)).Select(u => u.Name).ToList(),
};
if (ShareSettings.Validate(config, edited, existing?.Name) is { } problem)
{
await SendAdminPageAsync(ctx, AdminSharePage(classic, edited, existing?.Name, problem));
return;
}
if (existing is null)
config.Shares.Add(edited);
else
config.Shares[config.Shares.IndexOf(share)] = edited;
break;
}
case "share-del":
if (config.Shares.RemoveAll(s => s.Name.Equals(Get("n"), StringComparison.OrdinalIgnoreCase)) == 0)
error = Strings.ErrorBadName(Get("n"));
break;
case "user-add":
{
string name = Get("name");
if (!ShareSettings.IsValidName(name) || name.Equals(AdminUser, StringComparison.OrdinalIgnoreCase))
error = Strings.ErrorBadName(name);
else if (config.Users.Any(u => u.Name.Equals(name, StringComparison.OrdinalIgnoreCase)))
error = Strings.ErrorNameInUse(name);
else if (CheckNewPassword(f) is { } bad)
error = bad;
else
config.Users.Add(new UserConfig { Name = name, PasswordHash = Passwords.Hash(f["p1"]) });
break;
}
case "user-pass":
{
var user = config.Users.FirstOrDefault(u => u.Name == Get("name"));
if (user is null)
error = Strings.ErrorBadName(Get("name"));
else if (CheckNewPassword(f) is { } bad)
error = bad;
else
user.PasswordHash = Passwords.Hash(f["p1"]);
break;
}
case "user-del":
{
string name = Get("name");
if (config.Users.RemoveAll(u => u.Name == name) == 0)
error = Strings.ErrorBadName(name);
foreach (var s in config.Shares)
s.Users.RemoveAll(u => u.Equals(name, StringComparison.OrdinalIgnoreCase));
break;
}
case "admin":
{
// Cambiar algo del panel exige la contrasena actual.
if (!Passwords.Verify(f.GetValueOrDefault("cur") ?? "", config.Admin.PasswordHash))
{
error = Strings.AdminWrongCurrent;
break;
}
var rules = Get("allow").Split([',', ' ', ';'], StringSplitOptions.RemoveEmptyEntries).ToList();
var invalid = rules.FirstOrDefault(r => !AddressRules.IsValid(r));
if (rules.Count == 0 || invalid is not null)
{
error = Strings.AdminBadAllow(invalid ?? "");
break;
}
if (!AddressRules.Matches(rules, ctx.Request.RemoteEndPoint?.Address))
{
error = Strings.AdminLockout; // no dejar fuera a quien lo esta cambiando
break;
}
config.Admin.AllowFrom = rules;
if ((f.GetValueOrDefault("p1") ?? "").Length > 0)
{
if (CheckNewPassword(f) is { } bad)
{
error = bad;
break;
}
config.Admin.PasswordHash = Passwords.Hash(f["p1"]);
}
break;
}
default:
error = Strings.AdminExpired;
break;
}
if (error is null)
{
if (!await host.SaveAndApplyAsync(config))
error = Strings.AdminApplyFailed;
else
log.Info($"{ctx.Request.RemoteEndPoint?.Address} admin {action}");
}
if (error is not null)
{
await SendAdminPageAsync(ctx, AdminMainPage(classic, error, true));
return;
}
string location = BaseUrl(ctx.Request) + AdminBase + "?ok";
ctx.Response.StatusCode = ctx.Request.IsHttp11OrLater ? 303 : 302;
ctx.Response.Headers["Location"] = location;
await ctx.Response.SendAsync(Encoding.ASCII.GetBytes($"<HTML><BODY><A HREF=\"{location}\">{location}</A></BODY></HTML>"), "text/html", ctx.Aborted);
}
private static string? CheckNewPassword(IReadOnlyDictionary<string, string> f)
{
string p1 = f.GetValueOrDefault("p1") ?? "", p2 = f.GetValueOrDefault("p2") ?? "";
if (p1.Length == 0)
return Strings.ErrorPasswordEmpty;
return p1 == p2 ? null : Strings.AdminPasswordMismatch;
}
}