Refactor SAML configuration deployment and enhance Authentik integration

- Removed SAML configuration deployment calls from PostInstanceInitService.
- Updated DeploySamlConfigurationAsync to improve template fetching logic from Git and local directories.
- Added Authentik flow and keypair models for better representation in the UI.
- Enhanced SettingsViewModel to include Authentik settings with save and test functionality.
- Updated UI to support Authentik configuration, including dropdowns for flows and keypairs.
- Changed default CMS server name template to "app.ots-signs.com" across various files.
- Improved password handling in SshDockerCliService for secure shell command execution.
- Added new template file for settings-custom.php in the project structure.
This commit is contained in:
Matt Batchelder
2026-02-27 22:15:24 -05:00
parent 2aaa0442b2
commit 56d48b6062
22 changed files with 1245 additions and 172 deletions

View File

@@ -41,6 +41,70 @@ public class SshDockerCliService : IDockerCliService
public SshHost? CurrentHost => _currentHost;
private void EnsureHost()
{
if (_currentHost == null)
throw new InvalidOperationException("No SSH host configured. Call SetHost() before using Docker commands.");
}
/// <summary>
/// Escape password for safe use in shell scripts with proper quoting.
/// Uses printf-safe escaping to avoid newline injection and special character issues.
/// </summary>
private string EscapePasswordForShell(string password)
{
if (string.IsNullOrEmpty(password))
{
_logger.LogWarning("Password is null or empty");
return string.Empty;
}
_logger.LogDebug("Original password length: {Length} characters", password.Length);
// Use printf-safe format: escape single quotes and other problematic characters
// Replace ' with '\'' (close quote, escaped quote, open quote)
var escaped = password.Replace("'", "'\\''");
_logger.LogDebug("Escaped password length: {Length} characters (added {Extra} chars for escaping)",
escaped.Length, escaped.Length - password.Length);
_logger.LogDebug("Password first char: '{FirstChar}', last char: '{LastChar}'",
password.Length > 0 ? password[0].ToString() : "N/A",
password.Length > 0 ? password[^1].ToString() : "N/A");
return escaped;
}
/// <summary>
/// Test if the current host's password works with sudo by running a no-op sudo command.
/// </summary>
private async Task<(bool Success, string? Error)> TestSudoPasswordAsync()
{
EnsureHost();
if (string.IsNullOrEmpty(_currentHost!.Password))
{
return (false, "No password configured for SSH host");
}
var escapedPassword = EscapePasswordForShell(_currentHost!.Password);
var testCmd = $"printf '%s\\n' '{escapedPassword}' | sudo -S -v 2>&1";
_logger.LogInformation("Testing sudo password for host {Host} user {User}...",
_currentHost!.Label, _currentHost!.Username);
var (exitCode, stdout, stderr) = await _ssh.RunCommandAsync(_currentHost!, testCmd, TimeSpan.FromSeconds(10));
if (exitCode == 0)
{
_logger.LogInformation("Sudo password test PASSED for {Host}", _currentHost!.Label);
return (true, null);
}
var error = (stderr ?? stdout ?? "unknown error").Trim();
_logger.LogWarning("Sudo password test FAILED for {Host}: {Error}", _currentHost!.Label, error);
return (false, error);
}
public async Task<DeploymentResultDto> DeployStackAsync(string stackName, string composeYaml, bool resolveImage = false)
{
EnsureHost();
@@ -184,12 +248,22 @@ public class SshDockerCliService : IDockerCliService
// Single SSH command: create temp dir, mount NFS, mkdir -p all folders, unmount, cleanup
// Use addr= to pin the server IP — avoids "Server address does not match proto= option"
// errors when the hostname resolves to IPv6 but proto=tcp implies IPv4.
// Properly escape password for shell use (handle special characters like single quotes)
var escapedPassword = EscapePasswordForShell(_currentHost!.Password ?? string.Empty);
if (string.IsNullOrEmpty(escapedPassword))
{
_logger.LogWarning(
"No password configured for SSH host {Host}. NFS operations may fail if sudo requires a password.",
_currentHost!.Label);
}
var script = $"""
set -e
MNT=$(mktemp -d)
sudo mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
sudo mkdir -p {mkdirTargets}
sudo umount "$MNT"
printf '%s\n' '{escapedPassword}' | sudo -S mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
printf '%s\n' '{escapedPassword}' | sudo -S mkdir -p {mkdirTargets}
printf '%s\n' '{escapedPassword}' | sudo -S umount "$MNT"
rmdir "$MNT"
""";
@@ -229,12 +303,22 @@ public class SshDockerCliService : IDockerCliService
var folderList = folderNames.Select(f => $"\"$MNT{subPath}/{f}\"").ToList();
var mkdirTargets = string.Join(" ", folderList);
// Properly escape password for shell use (handle special characters like single quotes)
var escapedPassword = EscapePasswordForShell(_currentHost!.Password ?? string.Empty);
if (string.IsNullOrEmpty(escapedPassword))
{
_logger.LogWarning(
"No password configured for SSH host {Host}. NFS operations may fail if sudo requires a password.",
_currentHost!.Label);
}
var script = $"""
set -e
MNT=$(mktemp -d)
sudo mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
sudo mkdir -p {mkdirTargets}
sudo umount "$MNT"
printf '%s\n' '{escapedPassword}' | sudo -S mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
printf '%s\n' '{escapedPassword}' | sudo -S mkdir -p {mkdirTargets}
printf '%s\n' '{escapedPassword}' | sudo -S umount "$MNT"
rmdir "$MNT"
""";
@@ -271,22 +355,44 @@ public class SshDockerCliService : IDockerCliService
var subFolder = (nfsExportFolder ?? string.Empty).Trim('/');
var subPath = string.IsNullOrEmpty(subFolder) ? string.Empty : $"/{subFolder}";
// Ensure parent directory exists, then write content via heredoc
// Ensure parent directory exists
var targetPath = $"$MNT{subPath}/{relativePath.TrimStart('/')}";
var parentDir = $"$(dirname \"{targetPath}\")";
// Escape content for heredoc (replace any literal EOF that might appear in content)
var safeContent = content.Replace("'", "'\\''");
// Properly escape password for shell use (handle special characters like single quotes)
var escapedPassword = EscapePasswordForShell(_currentHost!.Password ?? string.Empty);
_logger.LogInformation("NFS WriteFile: Host={Host}, User={User}, HasPassword={HasPw}, PwLen={PwLen}",
_currentHost!.Label, _currentHost!.Username,
!string.IsNullOrEmpty(_currentHost!.Password), _currentHost!.Password?.Length ?? 0);
if (string.IsNullOrEmpty(escapedPassword))
{
_logger.LogWarning(
"No password configured for SSH host {Host}. NFS operations may fail if sudo requires a password.",
_currentHost!.Label);
return (false, "No password configured for SSH host");
}
// Base64-encode the file content to avoid heredoc/stdin conflicts with sudo -S.
// The heredoc approach fails because the shell's heredoc redirects stdin for the
// entire pipeline, so sudo -S reads the PHP content instead of the password.
var base64Content = Convert.ToBase64String(System.Text.Encoding.UTF8.GetBytes(content));
// Strategy: base64-decode content to a temp file (no sudo needed), then use
// printf | sudo -S for each privileged command — matching the proven pattern
// in EnsureNfsFoldersAsync. We avoid sudo -v timestamp caching because SSH
// exec channels have no TTY and timestamps may not persist between commands.
var script = $"""
set -e
TMPFILE=$(mktemp)
echo '{base64Content}' | base64 -d > "$TMPFILE"
MNT=$(mktemp -d)
sudo mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
sudo mkdir -p {parentDir}
sudo tee "{targetPath}" > /dev/null << 'OTSSIGNS_EOF'
{content}
OTSSIGNS_EOF
sudo umount "$MNT"
printf '%s\n' '{escapedPassword}' | sudo -S mount -t nfs -o addr={nfsServer},nfsvers=4,proto=tcp,soft,timeo=50,retrans=2 {nfsServer}:/{exportPath} "$MNT"
printf '%s\n' '{escapedPassword}' | sudo -S mkdir -p {parentDir}
printf '%s\n' '{escapedPassword}' | sudo -S cp "$TMPFILE" "{targetPath}"
rm -f "$TMPFILE"
printf '%s\n' '{escapedPassword}' | sudo -S umount "$MNT"
rmdir "$MNT"
""";
@@ -609,12 +715,6 @@ public class SshDockerCliService : IDockerCliService
return serviceName.StartsWith(prefix) ? serviceName[prefix.Length..] : serviceName;
}
private void EnsureHost()
{
if (_currentHost == null)
throw new InvalidOperationException("No SSH host configured. Call SetHost() before using Docker commands.");
}
public async Task<bool> RemoveStackVolumesAsync(string stackName)
{
EnsureHost();