Files
SocialPub/PrivaPub/Infrastructure/Cli/AdminCommands.cs
T
thepraandClaude Opus 5.5 8156c1927c
Build / Build (push) Successful in 10m49s
Deploy / privapub.thepra.dev (push) Successful in 14m0s
First-time setup: the first login made while no administrator exists becomes it
Sign-up stays closed (invitations only), so a new server's first login could only be made over SSH with admin
create-root, and an invitation needs a group, which needs a login. While no administrator exists, GET
/clientapi/user/setup says so and POST takes a SetupForm (login, password, first persona): the login is made the
administrator with its persona, and setup closes for good (owner decision 2026-10-16: first come, first served, no setup
code).

ServerSetup is one document with a fixed id, so the first request to insert it wins and two setups at once make one
administrator. A setup that fails before its login is made gives the claim back; one that named an administrator never
does, whoever is demoted or deleted later, and the CLI stays the way back. admin promote and create-root --admin seal it
too, migration _018 seals a server that already had an administrator, and no backup holds it nor does a restore drop it,
since a restore tombstones an administrator made after the backup. Public sign-up, NodeInfo and the instance API are
unchanged. RootUser.SetAdmin is the one place an administrator is made (with isModerator, which IsAdminPolicy asks for),
and the invitation endpoints share InsertPersona with setup.

Tests: ServerSetupTests on the invitations-only host (setup, the race, failed setups, sign-up still closed, the CLI's
seal) and the migration; the whole suite passes (962). In the pasture, decePub's setup and invitation pages pass end to
end.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 23:21:41 +02:00

289 lines
13 KiB
C#

using MongoDB.Entities;
using PrivaPub.ClientModels;
using PrivaPub.ClientModels.User;
using PrivaPub.ClientModels.User.Avatar;
using PrivaPub.Infrastructure.Backup;
using PrivaPub.Models;
using PrivaPub.Models.User;
using PrivaPub.Services;
using PrivaPub.Services.ClientToServer.Private;
using PrivaPub.StaticServices;
using System.ComponentModel.DataAnnotations;
using System.Security.Cryptography;
namespace PrivaPub.Infrastructure.Cli
{
// `PrivaPub admin ...` runs with the whole server built but not started: no Kestrel, no hosted services.
public static class AdminCommands
{
public const string SmokeLogin = "deploy-smoke";
const string Usage = """
usage: PrivaPub admin promote|demote <root>
PrivaPub admin create-root <login> [--admin] the password is read from standard input
PrivaPub admin smoke <persona> prints "<login> <password>" for the deploy's signed-in check
PrivaPub admin media audit [--fix] media files against what holds them; --fix (as www-data) trashes
what nothing holds and gives today's pictures their rows
PrivaPub admin backup [--kind manual|pre-deploy] [--db-only]
backs the server up; --db-only lists the media without
linking them (the deploy's, which can't link www-data's files)
PrivaPub admin backups the backups kept, newest first
PrivaPub admin backup verify <id> whether a backup's files are what its manifest says
PrivaPub admin restore <id> restores a backup: the service stops within seconds and
restores it when systemd starts it again; everyone signs
in again, and what was made since is lost, except what
protects (blocks, deletions, ...)
PrivaPub admin restore --status the restore waiting, and the last one done
""";
/// <summary>Whether a command runs before migrations: backups are of the database as it was.</summary>
public static bool BeforeMigrations(string[] args) => args is ["backup", ..] or ["backups"] or ["restore", ..];
public static async Task<int> Run(string[] args, IServiceProvider services, TextReader input = default, TextWriter output = default)
{
input ??= Console.In;
output ??= Console.Out;
switch (args)
{
case [("promote" or "demote") and var verb, var userName]:
return await Promote(verb == "promote", userName, output);
case ["create-root", var login, .. var flags] when flags.All(f => f == "--admin"):
return await CreateRoot(services, login, input.ReadLine(), flags.Contains("--admin"), output);
case ["smoke", var persona]:
return await Smoke(services, persona, output);
case ["media", "audit", .. var flags] when flags.All(f => f == "--fix"):
return await AuditMedia(services, flags.Contains("--fix"), output);
case ["backup", "verify", var id]:
return await VerifyBackup(services, id, output);
case ["backup", .. var flags] when BackupFlags(flags, out var kind, out var dbOnly):
return await BackUp(services, kind, dbOnly, output);
case ["backups"]:
return ListBackups(services, output);
case ["restore", "--status"]:
return await RestoreStatus(services, output);
case ["restore", var id] when !id.StartsWith('-'):
return await Restore(services, id, output);
default:
Console.Error.WriteLine(Usage);
return 2;
}
}
static readonly string[] CommandKinds = ["manual", "pre-deploy"];
static bool BackupFlags(string[] flags, out string kind, out bool dbOnly)
{
kind = "manual";
dbOnly = false;
for (var i = 0; i < flags.Length; i++)
switch (flags[i])
{
case "--db-only":
dbOnly = true;
break;
case "--kind" when i + 1 < flags.Length && CommandKinds.Contains(flags[i + 1]):
kind = flags[++i];
break;
default:
return false;
}
return true;
}
static async Task<int> BackUp(IServiceProvider services, string kind, bool dbOnly, TextWriter output)
{
var (made, error) = await services.GetRequiredService<Backups>().Create(kind, dbOnly, CancellationToken.None);
if (made == default)
{
Console.Error.WriteLine(error);
return 1;
}
var manifest = made.Manifest;
output.WriteLine($"{made.Id}: {manifest.Collections.Count} collections, {manifest.Collections.Sum(c => c.Count)} documents, {made.Bytes / 1024} KiB");
output.WriteLine(dbOnly
? $"{manifest.Media.List.Count} media files listed, not linked"
: $"{manifest.Media.Files} media files, {manifest.Media.Bytes / 1024 / 1024} MiB, {manifest.Media.Missing} missing");
if (!manifest.Consistent)
output.WriteLine("not read at one instant: Mongo is not a replica set");
return 0;
}
static int ListBackups(IServiceProvider services, TextWriter output)
{
foreach (var backup in services.GetRequiredService<Backups>().List())
output.WriteLine($"{backup.Id}\t{backup.Kind}\t{backup.CreatedAt:u}\t{backup.Bytes / 1024} KiB\t{backup.Manifest?.Media.Files ?? 0} media{(backup.Manifest?.DbOnly == true ? " (listed)" : string.Empty)}");
return 0;
}
static async Task<int> VerifyBackup(IServiceProvider services, string id, TextWriter output)
{
var problems = await services.GetRequiredService<Backups>().Verify(id, CancellationToken.None);
foreach (var problem in problems)
output.WriteLine(problem);
if (problems.Count > 0)
return 1;
output.WriteLine($"{id}: whole");
return 0;
}
static async Task<int> Restore(IServiceProvider services, string id, TextWriter output)
{
var backups = services.GetRequiredService<Backups>();
var refused = await ServerRestore.Request(backups.Context(), id, "cli", CancellationToken.None);
if (refused != default)
{
Console.Error.WriteLine(refused);
return 1;
}
output.WriteLine($"{id} will be restored: the running service stops within seconds and restores it when it starts again");
output.WriteLine("(systemd starts it again; follow it with journalctl -u privapub, then PrivaPub admin restore --status)");
return 0;
}
static async Task<int> RestoreStatus(IServiceProvider services, TextWriter output)
{
var waiting = RestoreMarker.Read(services.GetRequiredService<Backups>().Root);
if (waiting != default)
output.WriteLine($"waiting: {waiting.Backup}, {waiting.State}, attempt {waiting.Attempts} of {RestoreMarker.MaxAttempts}, asked by {waiting.RequestedBy} at {waiting.RequestedAt:u}{(waiting.Error == default ? string.Empty : $": {waiting.Error}")}");
var last = await DB.Default.Find<RestoreRecord>().Sort(r => r.RestoredAt, Order.Descending).ExecuteFirstAsync();
if (last == default)
{
if (waiting == default)
output.WriteLine("no restore asked for, none done");
return 0;
}
if (last.Abandoned)
{
output.WriteLine($"last: {last.Backup} abandoned at {last.RestoredAt:u}, nothing changed: {last.Error}");
return 0;
}
var report = last.Report;
output.WriteLine($"last: {last.Backup} (made {last.BackupCreatedAt:u}) restored at {last.RestoredAt:u}, asked by {last.RequestedBy}; before it: {last.PreRestore}");
output.WriteLine($" {report.Documents} documents in {report.Collections} collections, {report.MediaRestored} media files brought back, {report.MediaMissing} missing");
output.WriteLine($" deleted again: {report.RootsDeleted} roots, {report.PersonasDeleted} personas, {report.GroupsDeleted} groups, {report.PostsDeleted} posts");
output.WriteLine($" made since, now deleted: roots {Names(report.RootsTombstoned)}, personas {Names(report.PersonasTombstoned)}, groups {Names(report.GroupsTombstoned)}; {report.PostsGone} posts gone");
output.WriteLine($" {report.ProtectiveKept} protective rows kept, {report.MediaTrashed} media trashed, {report.SessionsEnded} sessions ended");
return 0;
}
static string Names(List<string> names) => names.Count == 0 ? "none" : string.Join(", ", names);
static async Task<int> AuditMedia(IServiceProvider services, bool fix, TextWriter output)
{
var report = await Domain.Media.MediaAudit.Run(services.GetRequiredService<Domain.Media.IMediaService>(), fix, CancellationToken.None);
output.WriteLine($"{report.Files} files served, {report.Held} rows holding media");
output.WriteLine($"{report.Adopted} pictures personas show without a row{(fix ? ": given one" : string.Empty)}");
output.WriteLine($"{report.OfDeleted} media of deleted posts or personas{(fix ? ": trashed" : string.Empty)}");
output.WriteLine($"{report.MissingFiles} rows whose files are missing{(fix ? ": trashed" : string.Empty)}");
output.WriteLine($"{report.Unheld} files nothing holds{(fix ? ": trashed" : string.Empty)}");
foreach (var example in report.Examples)
output.WriteLine($" {example}");
if (!fix && report.Adopted + report.OfDeleted + report.MissingFiles + report.Unheld > 0)
output.WriteLine("run again with --fix, as www-data, to apply this; trashed files are deleted after a day");
return 0;
}
static async Task<int> Promote(bool promote, string userName, TextWriter output)
{
userName = userName.ToLowerInvariant();
var user = await DB.Default.Find<RootUser>().Match(u => u.UserName == userName).ExecuteFirstAsync();
if (user == default)
{
Console.Error.WriteLine($"no root user '{userName}'");
return 1;
}
user.SetAdmin(promote);
await DB.Default.SaveAsync(user);
if (promote)
await ServerSetup.Seal(user.ID, default);//an administrator exists: the client's setup is closed
output.WriteLine($"{userName}: {string.Join(", ", user.Policies)}");
return 0;
}
// With sign-up closed, this or the client's setup (while no administrator exists) makes the first root; group invitations
// make the rest.
static async Task<int> CreateRoot(IServiceProvider services, string login, string password, bool admin, TextWriter output)
{
var form = new LoginForm { UserName = login, Password = password?.Trim() };
var problems = new List<ValidationResult>();
if (!Validator.TryValidateObject(form, new ValidationContext(form), problems, validateAllProperties: true))
{
Console.Error.WriteLine(string.Join(Environment.NewLine, problems.Select(p => p.ErrorMessage)));
return 1;
}
var created = await services.GetRequiredService<IRootUsersService>().SignUpAsync(form);
if (!created.IsValid)
{
Console.Error.WriteLine(created.ErrorMessage);
return 1;
}
return admin ? await Promote(true, login, output) : await Report(login, output);
}
static Task<int> Report(string login, TextWriter output)
{
output.WriteLine($"{login.ToLowerInvariant()}: created");
return Task.FromResult(0);
}
// The deploy's signed-in smoke check: a root nobody signs in to by hand, owning one undiscoverable persona. Every run
// sets a new password and prints it, so no secret is kept anywhere and nothing waits on a person.
static async Task<int> Smoke(IServiceProvider services, string personaName, TextWriter output)
{
personaName = personaName.ToLowerInvariant();
var password = "Smoke-x" + Convert.ToHexStringLower(RandomNumberGenerator.GetBytes(24));
var root = await DB.Default.Find<RootUser>().Match(u => u.UserName == SmokeLogin).ExecuteFirstAsync();
if (root == default)
{
var created = await services.GetRequiredService<IRootUsersService>().SignUpAsync(new LoginForm { UserName = SmokeLogin, Password = password });
if (!created.IsValid)
{
Console.Error.WriteLine(created.ErrorMessage);
return 1;
}
root = await DB.Default.Find<RootUser>().Match(u => u.UserName == SmokeLogin).ExecuteFirstAsync();
}
else if (root.DeletedAt.HasValue || root.IsBanned)
{
Console.Error.WriteLine($"the root '{SmokeLogin}' is deleted or banned");
return 1;
}
else
await DB.Default.Update<RootUser>().MatchID(root.ID)
.Modify(u => u.HashedPassword, services.GetRequiredService<IPasswordHasher>().Hash(password))
.Modify(u => u.UpdatedAt, DateTime.UtcNow)
.ExecuteAsync();
var owned = (await DB.Default.Find<RootToAvatar>().Match(ra => ra.RootId == root.ID).ExecuteAsync()).Select(ra => ra.AvatarId).ToList();
var persona = await DB.Default.Find<Avatar>().Match(a => owned.Contains(a.ID) && a.UserName == personaName && !a.DeletionAt.HasValue).ExecuteFirstAsync();
if (persona == default)
{
var inserted = await services.GetRequiredService<IPrivateAvatarUsersService>().InsertAvatar(new InsertAvatarForm
{
RootId = root.ID,
UserName = personaName,
Name = personaName,
Biography = "The deploy signs in here to check that the Mastodon API works for a signed-in persona."
});
if (!inserted.IsValid)
{
Console.Error.WriteLine(inserted.ErrorMessage);
return 1;
}
persona = await DB.Default.Find<Avatar>().MatchID(((ViewAvatar)inserted.Data).Id).ExecuteFirstAsync();
}
await DB.Default.Update<Avatar>().MatchID(persona.ID)
.Modify(a => a.Settings.IsDiscoverable, false)
.Modify(a => a.Settings.IsIndexable, false)
.ExecuteAsync();
output.WriteLine($"{SmokeLogin} {password}");
return 0;
}
}
}