Newer
Older
Sunfish / src / Sunfish.Core / Web / WebAdmin.cs
@Ivan Dominguez Ivan Dominguez 21 hours ago 21 KB 3.0
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;
    }
}