Merge pull request 'Fix SSH errors, add views, and UI improvements' (#2) from fix/ssh-errors-and-ui-improvements into master
Reviewed-on: #2 Reviewed-by: rafaga21 <rafaelminaya20@hotmail.com>
This commit was merged in pull request #2.
This commit is contained in:
@@ -52,6 +52,8 @@ $router->post('/servers/:id/nginx/site', [ServerController::class, 'nginxCreateS
|
||||
$router->delete('/servers/:id/nginx/site', [ServerController::class, 'nginxDeleteSite'], [AuthMiddleware::class, CSRFMiddleware::class]);
|
||||
$router->get('/servers/:id/ssl', [ServerController::class, 'ssl'], [AuthMiddleware::class]);
|
||||
$router->post('/servers/:id/ssl', [ServerController::class, 'sslAction'], [AuthMiddleware::class, CSRFMiddleware::class]);
|
||||
$router->get('/servers/:id/users', [ServerController::class, 'systemUsers'], [AuthMiddleware::class]);
|
||||
$router->post('/servers/:id/users', [ServerController::class, 'systemUsersAction'], [AuthMiddleware::class, CSRFMiddleware::class]);
|
||||
$router->get('/servers/:id/database', [ServerController::class, 'database'], [AuthMiddleware::class]);
|
||||
$router->post('/servers/:id/database', [ServerController::class, 'databaseAction'], [AuthMiddleware::class, CSRFMiddleware::class]);
|
||||
$router->post('/servers/:id/nginx', [ServerController::class, 'nginxAction'], [AuthMiddleware::class, CSRFMiddleware::class]);
|
||||
|
||||
@@ -1475,6 +1475,163 @@ class ServerController
|
||||
$this->view->json(['success' => true, 'data' => $result]);
|
||||
}
|
||||
|
||||
public function systemUsers(int $id): void
|
||||
{
|
||||
$server = $this->serverModel->findById($id);
|
||||
if (!$server) { $this->view->error(404); }
|
||||
|
||||
try {
|
||||
$ssh = new SSHService();
|
||||
if (!$ssh->connect($server)) {
|
||||
throw new \RuntimeException('Could not establish SSH connection');
|
||||
}
|
||||
$this->auditService->logCredentialAccess($id, 'View system users');
|
||||
|
||||
$data = [];
|
||||
|
||||
$usersResult = $ssh->exec("awk -F: '\$3>=1000||\$3==0{printf \"%s|%s|%s|%s\\n\",\$1,\$3,\$6,\$7}' /etc/passwd 2>/dev/null | sort -t: -k2 -n | head -100");
|
||||
$users = [];
|
||||
foreach (explode("\n", trim($usersResult['output'] ?? '')) as $line) {
|
||||
$parts = explode("|", $line);
|
||||
if (count($parts) >= 4 && is_numeric($parts[1])) {
|
||||
$users[] = [
|
||||
'username' => $parts[0],
|
||||
'uid' => (int) $parts[1],
|
||||
'home' => $parts[2],
|
||||
'shell' => $parts[3],
|
||||
];
|
||||
}
|
||||
}
|
||||
$data['users'] = $users;
|
||||
|
||||
$groupsResult = $ssh->exec("awk -F: '{print \$1}' /etc/group 2>/dev/null | sort | head -50");
|
||||
$data['groups'] = array_filter(explode("\n", trim($groupsResult['output'] ?? '')));
|
||||
|
||||
$totalResult = $ssh->exec("awk -F: '\$3>=1000||\$3==0{print \$1}' /etc/passwd 2>/dev/null | wc -l");
|
||||
$data['total_users'] = (int) trim($totalResult['output'] ?? '0');
|
||||
|
||||
$lastResult = $ssh->exec("last -5 -w 2>/dev/null | head -10 || echo 'No login history'");
|
||||
$data['last_logins'] = trim($lastResult['output'] ?? '');
|
||||
|
||||
$ssh->disconnect();
|
||||
|
||||
$this->view->display('servers.system_users', [
|
||||
'title' => 'System Users - ' . $server['name'] . ' - ServerManager',
|
||||
'server' => $server,
|
||||
'sysusers' => $data,
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
Session::setFlash('error', 'Failed to get users: ' . $e->getMessage());
|
||||
$this->view->redirect("/servers/{$id}");
|
||||
}
|
||||
}
|
||||
|
||||
public function systemUsersAction(int $id): void
|
||||
{
|
||||
$server = $this->serverModel->findById($id);
|
||||
if (!$server) {
|
||||
$this->view->json(['success' => false, 'message' => 'Server not found'], 404);
|
||||
}
|
||||
|
||||
$action = $_POST['action'] ?? '';
|
||||
$allowed = ['add', 'delete', 'lock', 'unlock', 'addgroup'];
|
||||
if (!in_array($action, $allowed, true)) {
|
||||
$this->view->json(['success' => false, 'message' => 'Invalid action']);
|
||||
}
|
||||
|
||||
try {
|
||||
$ssh = new SSHService();
|
||||
if (!$ssh->connect($server)) {
|
||||
throw new \RuntimeException('Could not establish SSH connection');
|
||||
}
|
||||
$this->auditService->logServerAction($id, "sysuser_{$action}", $action);
|
||||
|
||||
if ($action === 'add') {
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
$password = trim($_POST['password'] ?? '');
|
||||
$groups = trim($_POST['groups'] ?? '');
|
||||
$createHome = !empty($_POST['create_home']);
|
||||
|
||||
if (empty($username) || empty($password)) {
|
||||
$this->view->json(['success' => false, 'message' => 'Username and password required']);
|
||||
}
|
||||
if (!preg_match('/^[a-z_][a-z0-9_-]*$/', $username)) {
|
||||
$this->view->json(['success' => false, 'message' => 'Invalid username']);
|
||||
}
|
||||
|
||||
$homeFlag = $createHome ? '-m' : '-M';
|
||||
$groupsFlag = '';
|
||||
if (!empty($groups)) {
|
||||
$sanitized = preg_replace('/[^a-z0-9_,-]/', '', $groups);
|
||||
$groupsFlag = " -G {$sanitized}";
|
||||
}
|
||||
$cmd = "sudo useradd {$homeFlag}{$groupsFlag} {$username} 2>&1; echo EXIT:\$?";
|
||||
$result = $ssh->exec($cmd);
|
||||
$exitCode = (int) trim(explode('EXIT:', $result['output'] ?? 'EXIT:-1')[1] ?? '-1');
|
||||
if ($exitCode === 0) {
|
||||
$b64 = base64_encode("{$username}:{$password}");
|
||||
$ssh->exec("echo {$b64} | base64 -d | sudo chpasswd 2>&1");
|
||||
}
|
||||
|
||||
$result = $ssh->exec($cmd);
|
||||
$exitCode = (int) trim(explode('EXIT:', $result['output'] ?? 'EXIT:-1')[1] ?? '-1');
|
||||
$ssh->disconnect();
|
||||
|
||||
$this->view->json([
|
||||
'success' => $exitCode === 0,
|
||||
'message' => $exitCode === 0 ? "User '{$username}' created." : 'Failed to create user.',
|
||||
'output' => trim($result['output']),
|
||||
]);
|
||||
} elseif ($action === 'delete') {
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
$removeHome = !empty($_POST['remove_home']);
|
||||
if (empty($username)) {
|
||||
$this->view->json(['success' => false, 'message' => 'Username required']);
|
||||
}
|
||||
$rmFlag = $removeHome ? '-r' : '';
|
||||
$result = $ssh->exec("sudo userdel {$rmFlag} {$username} 2>&1; echo EXIT:\$?");
|
||||
$exitCode = (int) trim(explode('EXIT:', $result['output'] ?? 'EXIT:-1')[1] ?? '-1');
|
||||
$ssh->disconnect();
|
||||
$this->view->json([
|
||||
'success' => $exitCode === 0,
|
||||
'message' => $exitCode === 0 ? "User '{$username}' deleted." : 'Failed to delete user.',
|
||||
]);
|
||||
} elseif ($action === 'lock') {
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
if (empty($username)) $this->view->json(['success' => false, 'message' => 'Username required']);
|
||||
$result = $ssh->exec("sudo passwd -l {$username} 2>&1; echo EXIT:\$?");
|
||||
$exitCode = (int) trim(explode('EXIT:', $result['output'] ?? 'EXIT:-1')[1] ?? '-1');
|
||||
$ssh->disconnect();
|
||||
$this->view->json([
|
||||
'success' => $exitCode === 0,
|
||||
'message' => $exitCode === 0 ? "User '{$username}' locked." : 'Failed to lock user.',
|
||||
]);
|
||||
} elseif ($action === 'unlock') {
|
||||
$username = trim($_POST['username'] ?? '');
|
||||
if (empty($username)) $this->view->json(['success' => false, 'message' => 'Username required']);
|
||||
$result = $ssh->exec("sudo passwd -u {$username} 2>&1; echo EXIT:\$?");
|
||||
$exitCode = (int) trim(explode('EXIT:', $result['output'] ?? 'EXIT:-1')[1] ?? '-1');
|
||||
$ssh->disconnect();
|
||||
$this->view->json([
|
||||
'success' => $exitCode === 0,
|
||||
'message' => $exitCode === 0 ? "User '{$username}' unlocked." : 'Failed to unlock user.',
|
||||
]);
|
||||
} elseif ($action === 'addgroup') {
|
||||
$group = trim($_POST['group'] ?? '');
|
||||
if (empty($group)) $this->view->json(['success' => false, 'message' => 'Group name required']);
|
||||
$result = $ssh->exec("sudo groupadd -f {$group} 2>&1; echo EXIT:\$?");
|
||||
$exitCode = (int) trim(explode('EXIT:', $result['output'] ?? 'EXIT:-1')[1] ?? '-1');
|
||||
$ssh->disconnect();
|
||||
$this->view->json([
|
||||
'success' => $exitCode === 0,
|
||||
'message' => $exitCode === 0 ? "Group '{$group}' created." : 'Failed to create group.',
|
||||
]);
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
$this->view->json(['success' => false, 'message' => 'Failed: ' . $e->getMessage()]);
|
||||
}
|
||||
}
|
||||
|
||||
public function metrics(int $id): void
|
||||
{
|
||||
$monitoringService = new MonitoringService();
|
||||
|
||||
@@ -168,6 +168,10 @@ $historicalMetrics = $historicalMetrics ?? [];
|
||||
<i class="fas fa-database"></i>
|
||||
<span>Database Manager</span>
|
||||
</a>
|
||||
<a href="/servers/<?= $server['id'] ?>/users" class="action-card">
|
||||
<i class="fas fa-users-cog"></i>
|
||||
<span>System Users</span>
|
||||
</a>
|
||||
<button class="action-card" onclick="restartService()">
|
||||
<i class="fas fa-sync-alt"></i>
|
||||
<span>Restart Service</span>
|
||||
|
||||
166
views/servers/system_users.php
Normal file
166
views/servers/system_users.php
Normal file
@@ -0,0 +1,166 @@
|
||||
<?php
|
||||
$server = $server ?? [];
|
||||
$sysusers = $sysusers ?? [];
|
||||
$users = $sysusers['users'] ?? [];
|
||||
$totalUsers = $sysusers['total_users'] ?? 0;
|
||||
$groups = $sysusers['groups'] ?? [];
|
||||
$lastLogins = $sysusers['last_logins'] ?? '';
|
||||
?>
|
||||
|
||||
<div class="page-header">
|
||||
<div class="page-header-left">
|
||||
<div class="back-link"><a href="/servers/<?= $server['id'] ?>"><i class="fas fa-arrow-left"></i> Back to Server</a></div>
|
||||
<h1 class="page-title"><i class="fas fa-users-cog"></i> System Users</h1>
|
||||
<p class="page-subtitle"><?= htmlspecialchars($server['name']) ?> · <?= $totalUsers ?> users</p>
|
||||
</div>
|
||||
<div class="page-header-right">
|
||||
<button class="btn btn-secondary" onclick="location.reload()"><i class="fas fa-sync-alt"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="display:flex;gap:1rem;margin-bottom:1rem;flex-wrap:wrap">
|
||||
<div class="stat-card" style="flex:1;padding:1rem">
|
||||
<div class="stat-icon" style="width:40px;height:40px;background:var(--accent-light);color:var(--accent)"><i class="fas fa-users"></i></div>
|
||||
<div class="stat-info"><span class="stat-value" style="font-size:1.2rem"><?= $totalUsers ?></span><span class="stat-label">System Users</span></div>
|
||||
</div>
|
||||
<div class="stat-card" style="flex:1;padding:1rem;cursor:pointer" onclick="showAddUser()">
|
||||
<div class="stat-icon" style="width:40px;height:40px;background:var(--success-bg);color:var(--success)"><i class="fas fa-user-plus"></i></div>
|
||||
<div class="stat-info"><span class="stat-value" style="font-size:1rem;color:var(--success)">Add User</span><span class="stat-label">Create new system user</span></div>
|
||||
</div>
|
||||
<div class="stat-card" style="flex:1;padding:1rem;cursor:pointer" onclick="showAddGroup()">
|
||||
<div class="stat-icon" style="width:40px;height:40px;background:var(--info-bg);color:var(--info)"><i class="fas fa-layer-group"></i></div>
|
||||
<div class="stat-info"><span class="stat-value" style="font-size:1rem;color:var(--info)">Add Group</span><span class="stat-label">Create new group</span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header"><h2><i class="fas fa-list"></i> Users</h2><span class="badge badge-info"><?= count($users) ?></span></div>
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm">
|
||||
<thead><tr><th>Username</th><th>UID</th><th>Home</th><th>Shell</th><th>Actions</th></tr></thead>
|
||||
<tbody>
|
||||
<?php foreach ($users as $u): ?>
|
||||
<tr>
|
||||
<td><strong><?= htmlspecialchars($u['username']) ?></strong></td>
|
||||
<td class="font-mono"><?= $u['uid'] ?></td>
|
||||
<td><code><?= htmlspecialchars($u['home']) ?></code></td>
|
||||
<td><code><?= htmlspecialchars($u['shell']) ?></code></td>
|
||||
<td class="actions-cell">
|
||||
<button class="btn btn-xs btn-warning" onclick="lockUser('<?= htmlspecialchars($u['username']) ?>')" title="Lock"><i class="fas fa-lock"></i></button>
|
||||
<button class="btn btn-xs btn-success" onclick="unlockUser('<?= htmlspecialchars($u['username']) ?>')" title="Unlock"><i class="fas fa-lock-open"></i></button>
|
||||
<button class="btn btn-xs btn-danger" onclick="deleteUser('<?= htmlspecialchars($u['username']) ?>')" title="Delete"><i class="fas fa-trash"></i></button>
|
||||
</td>
|
||||
</tr>
|
||||
<?php endforeach; ?>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<?php if (!empty($groups)): ?>
|
||||
<div class="card">
|
||||
<div class="card-header"><h2><i class="fas fa-layer-group"></i> Groups</h2><span class="badge badge-info"><?= count($groups) ?></span></div>
|
||||
<div class="card-body">
|
||||
<?php foreach (array_chunk($groups, 10) as $chunk): ?>
|
||||
<div style="display:flex;gap:0.5rem;flex-wrap:wrap;margin-bottom:0.25rem">
|
||||
<?php foreach ($chunk as $g): ?>
|
||||
<code style="font-size:0.8rem"><?= htmlspecialchars($g) ?></code>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php if (!empty($lastLogins)): ?>
|
||||
<div class="card">
|
||||
<div class="card-header"><h2><i class="fas fa-clock"></i> Recent Logins</h2></div>
|
||||
<div class="card-body">
|
||||
<div class="nginx-config-viewer" style="max-height:200px">
|
||||
<pre><code><?= htmlspecialchars($lastLogins) ?></code></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
|
||||
<div id="addUserModal" class="modal">
|
||||
<div class="modal-dialog" style="max-width:500px">
|
||||
<div class="modal-header"><h3><i class="fas fa-user-plus text-success"></i> Add System User</h3><button class="modal-close" onclick="closeModal('addUserModal')">×</button></div>
|
||||
<form onsubmit="event.preventDefault();doAddUser()">
|
||||
<div class="modal-body">
|
||||
<div class="form-row">
|
||||
<div class="form-group"><label>Username</label><input type="text" id="suUser" class="form-input" required pattern="[a-z_][a-z0-9_-]*"></div>
|
||||
<div class="form-group"><label>Password</label><input type="password" id="suPass" class="form-input" required></div>
|
||||
</div>
|
||||
<div class="form-group"><label>Groups (comma separated)</label><input type="text" id="suGroups" class="form-input" placeholder="sudo,www-data"></div>
|
||||
<div class="form-group"><label class="checkbox-label"><input type="checkbox" id="suHome" checked> <span>Create home directory</span></label></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal('addUserModal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-success"><i class="fas fa-save"></i> Create User</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="addGroupModal" class="modal">
|
||||
<div class="modal-dialog" style="max-width:400px">
|
||||
<div class="modal-header"><h3><i class="fas fa-layer-group text-info"></i> Add Group</h3><button class="modal-close" onclick="closeModal('addGroupModal')">×</button></div>
|
||||
<form onsubmit="event.preventDefault();doAddGroup()">
|
||||
<div class="modal-body">
|
||||
<div class="form-group"><label>Group name</label><input type="text" id="sgGroup" class="form-input" required></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" onclick="closeModal('addGroupModal')">Cancel</button>
|
||||
<button type="submit" class="btn btn-info"><i class="fas fa-save"></i> Create Group</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function getCsrfToken() { return document.querySelector('meta[name="csrf-token"]')?.content || ''; }
|
||||
|
||||
function showAddUser() { document.getElementById('addUserModal').style.display = 'flex'; }
|
||||
function showAddGroup() { document.getElementById('addGroupModal').style.display = 'flex'; }
|
||||
function closeModal(id) { document.getElementById(id).style.display = 'none'; }
|
||||
|
||||
function doAddUser() {
|
||||
const data = 'action=add&username=' + encodeURIComponent(document.getElementById('suUser').value)
|
||||
+ '&password=' + encodeURIComponent(document.getElementById('suPass').value)
|
||||
+ '&groups=' + encodeURIComponent(document.getElementById('suGroups').value)
|
||||
+ '&create_home=' + (document.getElementById('suHome').checked ? '1' : '0')
|
||||
+ '&_csrf_token=' + encodeURIComponent(getCsrfToken());
|
||||
fetch('/servers/<?= $server['id'] ?>/users', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()}, body:data})
|
||||
.then(r=>r.json()).then(d=>{showToast(d.success?'success':'error',d.message);if(d.success)setTimeout(()=>location.reload(),800)});
|
||||
closeModal('addUserModal');
|
||||
}
|
||||
|
||||
function doAddGroup() {
|
||||
const data = 'action=addgroup&group=' + encodeURIComponent(document.getElementById('sgGroup').value) + '&_csrf_token=' + encodeURIComponent(getCsrfToken());
|
||||
fetch('/servers/<?= $server['id'] ?>/users', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()}, body:data})
|
||||
.then(r=>r.json()).then(d=>{showToast(d.success?'success':'error',d.message);if(d.success)setTimeout(()=>location.reload(),800)});
|
||||
closeModal('addGroupModal');
|
||||
}
|
||||
|
||||
function lockUser(u) {
|
||||
if(!confirm('Lock user \''+u+'\'?'))return;
|
||||
fetch('/servers/<?= $server['id'] ?>/users', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()}, body:'action=lock&username='+encodeURIComponent(u)+'&_csrf_token='+encodeURIComponent(getCsrfToken())})
|
||||
.then(r=>r.json()).then(d=>{showToast(d.success?'success':'error',d.message);if(d.success)setTimeout(()=>location.reload(),800)});
|
||||
}
|
||||
|
||||
function unlockUser(u) {
|
||||
fetch('/servers/<?= $server['id'] ?>/users', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()}, body:'action=unlock&username='+encodeURIComponent(u)+'&_csrf_token='+encodeURIComponent(getCsrfToken())})
|
||||
.then(r=>r.json()).then(d=>{showToast(d.success?'success':'error',d.message);if(d.success)setTimeout(()=>location.reload(),800)});
|
||||
}
|
||||
|
||||
function deleteUser(u) {
|
||||
if(!confirm('Delete user \''+u+'\'?\nThis cannot be undone.'))return;
|
||||
fetch('/servers/<?= $server['id'] ?>/users', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded','X-CSRF-Token':getCsrfToken()}, body:'action=delete&username='+encodeURIComponent(u)+'&_csrf_token='+encodeURIComponent(getCsrfToken())})
|
||||
.then(r=>r.json()).then(d=>{showToast(d.success?'success':'error',d.message);if(d.success)setTimeout(()=>location.reload(),800)});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown',function(e){if(e.key==='Escape'){closeModal('addUserModal');closeModal('addGroupModal')}});
|
||||
</script>
|
||||
Reference in New Issue
Block a user