Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,10 @@ jobs:
- name: Add standalone launcher
run: |
cp standalone/start.sh ./publish/linux/start.sh
cp standalone/reset-password.sh ./publish/linux/reset-password.sh
cp standalone/README.md ./publish/linux/README.md
chmod +x ./publish/linux/start.sh
chmod +x ./publish/linux/reset-password.sh
chmod +x ./publish/linux/app/RustRconServerManager.Backend
echo "v${{ needs.check-release.outputs.version }}" > ./publish/linux/app/.version

Expand Down Expand Up @@ -128,6 +130,8 @@ jobs:
run: |
Copy-Item standalone/start.bat ./publish/windows/start.bat
Copy-Item standalone/start.ps1 ./publish/windows/start.ps1
Copy-Item standalone/reset-password.bat ./publish/windows/reset-password.bat
Copy-Item standalone/reset-password.ps1 ./publish/windows/reset-password.ps1
Copy-Item standalone/README.md ./publish/windows/README.md
Set-Content -Path ./publish/windows/app/.version -Value "v${{ needs.check-release.outputs.version }}" -NoNewline

Expand Down
144 changes: 144 additions & 0 deletions RustRconServerManager.Backend/Cli/ResetPasswordCli.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
using System.Text;
using Microsoft.AspNetCore.Identity;
using Microsoft.EntityFrameworkCore;
using RustRconServerManager.Backend.Database;
using RustRconServerManager.Backend.Models;

namespace RustRconServerManager.Backend.Cli;

// Invoked via `--reset-password` for admins who are locked out and have no working SMTP
// configuration for the email-code "forgot password" flow. Mirrors AuthController's
// ResetPassword action (RemovePasswordAsync/AddPasswordAsync + session revocation) but
// skips the email-code verification step entirely, since running this requires terminal
// access to the host/container the app itself runs on.
public static class ResetPasswordCli
{
public static async Task<int> RunAsync(IServiceProvider services)
{
using var scope = services.CreateScope();
var userManager = scope.ServiceProvider.GetRequiredService<UserManager<ApplicationUser>>();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDbContext>();

Console.WriteLine("RustRconServerManager - Password Reset");
Console.WriteLine();

Console.Write("Account email: ");
var email = Console.ReadLine()?.Trim();
if (string.IsNullOrWhiteSpace(email))
{
Console.WriteLine("No email entered. Aborting.");
return 1;
}

var user = await userManager.FindByEmailAsync(email);
if (user == null)
{
Console.WriteLine($"No account found for '{email}'.");
return 1;
}

string newPassword;
while (true)
{
Console.Write("New password: ");
var password = ReadMaskedLine();
Console.Write("Confirm new password: ");
var confirm = ReadMaskedLine();

if (password != confirm)
{
Console.WriteLine("Passwords do not match. Try again.");
Console.WriteLine();
continue;
}

if (string.IsNullOrEmpty(password))
{
Console.WriteLine("Password cannot be empty. Try again.");
Console.WriteLine();
continue;
}

newPassword = password;
break;
}

var removeResult = await userManager.RemovePasswordAsync(user);
if (!removeResult.Succeeded)
{
var errors = string.Join(", ", removeResult.Errors.Select(e => e.Description));
Console.WriteLine($"Failed to reset password: {errors}");
return 1;
}

var addResult = await userManager.AddPasswordAsync(user, newPassword);
if (!addResult.Succeeded)
{
var errors = string.Join(", ", addResult.Errors.Select(e => e.Description));
Console.WriteLine($"Password does not meet requirements: {errors}");
return 1;
}

user.PasswordResetCode = null;
user.PasswordResetCodeExpiry = null;
await userManager.UpdateAsync(user);

var userSessions = await dbContext.UserSessions
.Where(s => s.UserId == user.Id && !s.IsRevoked)
.ToListAsync();

foreach (var session in userSessions)
{
session.IsRevoked = true;
}

if (userSessions.Any())
{
dbContext.UserSessions.UpdateRange(userSessions);
await dbContext.SaveChangesAsync();
}

Console.WriteLine();
Console.WriteLine($"Password for '{email}' has been reset. Any existing sessions have been signed out.");
return 0;
}

private static string ReadMaskedLine()
{
// Console.ReadKey requires a real console - falls back to plain (unmasked)
// input when stdin isn't a TTY (e.g. piped input, `docker exec` without -it).
if (Console.IsInputRedirected)
{
return Console.ReadLine() ?? string.Empty;
}

var input = new StringBuilder();
while (true)
{
var key = Console.ReadKey(intercept: true);
if (key.Key == ConsoleKey.Enter)
{
Console.WriteLine();
break;
}

if (key.Key == ConsoleKey.Backspace)
{
if (input.Length > 0)
{
input.Remove(input.Length - 1, 1);
Console.Write("\b \b");
}
continue;
}

if (!char.IsControl(key.KeyChar))
{
input.Append(key.KeyChar);
Console.Write('*');
}
}

return input.ToString();
}
}
10 changes: 10 additions & 0 deletions RustRconServerManager.Backend/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,16 @@
throw;
}

// `--reset-password` runs an interactive terminal password reset instead of starting the
// web server - for admins locked out with no working SMTP configuration for the email-code
// "forgot password" flow. Exits here (before Kestrel would bind a port) so it can safely
// run as a second process alongside an already-running instance sharing the same database.
if (args.Contains("--reset-password"))
{
var exitCode = await RustRconServerManager.Backend.Cli.ResetPasswordCli.RunAsync(app.Services);
Environment.Exit(exitCode);
}

app.UseExceptionHandler(errorApp =>
{
errorApp.Run(async context =>
Expand Down
13 changes: 13 additions & 0 deletions docs/DOCKER_DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,19 @@ docker compose exec -T mariadb sh -c 'exec mysql -u root -p"$MARIADB_ROOT_PASSWO
docker compose exec mariadb mysql -u ${DB_USER} -p ${DB_NAME}
```

## Password Reset

If an admin is locked out and `SMTP_*` isn't configured (or you'd rather not depend on
email), reset the account's password directly from the terminal:

```bash
docker compose exec app /app/RustRconServerManager.Backend --reset-password
```

This prompts for the account's email address and a new password, then signs out any
existing sessions for that account. The container must already be running - the command
reuses its existing database connection rather than starting a second one.

## Running behind a reverse proxy

Set `PATHBASE=/panel` (or whatever prefix you want) in `.env` and configure your reverse proxy
Expand Down
14 changes: 14 additions & 0 deletions standalone/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,20 @@ Once it's running, open **http://localhost:5000** in your browser and create you
admin account - the first person to open the panel gets to do this, so don't expose
the port publicly until you've completed it.

## Password Reset

If an admin is locked out and doesn't have SMTP configured for the "forgot password"
email flow (or would rather not depend on it), reset the account's password directly
from the terminal instead:

**Windows:** double-click `reset-password.bat` (or run `reset-password.ps1`).
**Linux:** run `./reset-password.sh`

This prompts for the account's email address and a new password, then signs out any
existing sessions for that account. The instance must already be running (started via
`start.bat`/`start.sh`) since it reuses that same running database instead of starting
a second one.

## About the bundled database

The bundled MariaDB instance listens only on `127.0.0.1:3307` (loopback, custom port).
Expand Down
4 changes: 4 additions & 0 deletions standalone/reset-password.bat
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
@echo off
REM Double-click entry point - runs the real logic in reset-password.ps1.
powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0reset-password.ps1"
pause
36 changes: 36 additions & 0 deletions standalone/reset-password.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Resets an admin account's password from the terminal - for when you're locked out and
# either don't have SMTP configured for the "forgot password" email flow, or just prefer
# not to depend on it. Requires the instance to already be running (started via
# start.ps1/start.bat in another window) since it connects to that same running bundled
# MariaDB rather than starting a second one.

Set-Location -Path $PSScriptRoot

$EnvFile = Join-Path $PSScriptRoot "standalone.env"
if (-not (Test-Path $EnvFile)) {
Write-Host "standalone.env not found - has this instance been started at least once (start.bat)?"
exit 1
}

$envValues = @{}
Get-Content $EnvFile | ForEach-Object {
if ($_ -match '^([^=]+)=(.*)$') {
$envValues[$matches[1]] = $matches[2]
}
}

$MariaDbPort = 3307
$DbName = "rustrconservermanager"
$DbUser = "rustrconservermanager"

$env:ConnectionStrings__DefaultConnection = "Server=127.0.0.1;Port=$MariaDbPort;Database=$DbName;User=$DbUser;Password=$($envValues['DB_PASSWORD']);"
$env:Jwt__Key = $envValues["JWT_KEY"]
$env:Jwt__Issuer = "RustRconServerManager"
$env:Jwt__Audience = "RustRconServerManager"
$env:RconEncryption__Key = $envValues["RCON_ENCRYPTION_KEY"]
$env:ASPNETCORE_ENVIRONMENT = "Production"

# Run from inside app/ - same reason as start.ps1: some file lookups are relative to the
# current directory.
Set-Location -Path (Join-Path $PSScriptRoot "app")
& ".\RustRconServerManager.Backend.exe" --reset-password
34 changes: 34 additions & 0 deletions standalone/reset-password.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
#!/bin/bash
# Resets an admin account's password from the terminal - for when you're locked out and
# either don't have SMTP configured for the "forgot password" email flow, or just prefer
# not to depend on it. Requires the instance to already be running (started via
# ./start.sh in another terminal/session) since it connects to that same running bundled
# MariaDB rather than starting a second one.

set -e
cd "$(dirname "$0")"

ENV_FILE="$(pwd)/standalone.env"
if [ ! -f "$ENV_FILE" ]; then
echo "standalone.env not found - has this instance been started at least once (./start.sh)?"
exit 1
fi

# shellcheck disable=SC1090
source "$ENV_FILE"

MARIADB_PORT=3307
DB_NAME="rustrconservermanager"
DB_USER="rustrconservermanager"

export ConnectionStrings__DefaultConnection="Server=127.0.0.1;Port=${MARIADB_PORT};Database=${DB_NAME};User=${DB_USER};Password=${DB_PASSWORD};"
export Jwt__Key="${JWT_KEY}"
export Jwt__Issuer="RustRconServerManager"
export Jwt__Audience="RustRconServerManager"
export RconEncryption__Key="${RCON_ENCRYPTION_KEY}"
export ASPNETCORE_ENVIRONMENT="Production"

# Run from inside app/ - same reason as start.sh: some file lookups are relative to the
# current directory.
cd app
./RustRconServerManager.Backend --reset-password
Loading