"}`;
+
+ const toggleGroup = (group: string) => {
+ setSelectedGroups((current) =>
+ current.includes(group)
+ ? current.filter((item) => item !== group)
+ : current.length < 16
+ ? [...current, group]
+ : current,
+ );
+ };
+
+ const beginEdit = (user: ServerSystemUser) => {
+ if (user.isRoot) return;
+ if (editingUsername === user.username) {
+ setEditingUsername(null);
+ return;
+ }
+
+ setEditingUsername(user.username);
+ setEditDraftGroups(user.groups);
+ setEditDraftShell(user.shell ?? "/bin/bash");
+ };
+
+ const toggleEditGroup = (group: string, sourceUser: ServerSystemUser) => {
+ setEditDraftGroups((current) => {
+ if (current.includes(group)) {
+ const isPrimaryGroup = group === sourceUser.primaryGroup;
+ const isProtectedSshGroup =
+ sourceUser.isSshUser && sourceUser.groups.includes(group);
+ if (isPrimaryGroup || isProtectedSshGroup) {
+ return current;
+ }
+ return current.filter((item) => item !== group);
+ }
+ return current.length < 16 ? [...current, group] : current;
+ });
+ };
+
+ const requestEditConfirmation = (user: ServerSystemUser) => {
+ setEditingUsername(null);
+ onRequestUpdateUserConfirm(
+ user,
+ editDraftGroups,
+ user.isSshUser ? user.shell ?? editDraftShell : editDraftShell,
+ );
+ };
return (
+
+
+
+
+ User & Group Management
+
+
+ Create passwordless host accounts, create groups, and assign
+ supplementary group access without opening a terminal.
+
+
+
+
+
+
+
+
+ {[
+ { key: "user", label: "Add User", icon: UserPlus },
+ { key: "group", label: "Add Group", icon: Group },
+ ].map((item) => {
+ const Icon = item.icon;
+ const active = previewMode === item.key;
+ return (
+ setPreviewMode(item.key as "user" | "group")}
+ style={{
+ height: 40,
+ minHeight: 40,
+ padding: "0 12px",
+ justifyContent: "center",
+ borderColor: active ? "var(--accent)" : "var(--border)",
+ background: active
+ ? "rgba(59,130,246,0.12)"
+ : "var(--bg-input)",
+ color: active ? "var(--accent)" : "var(--text-primary)",
+ }}
+ >
+
+ {item.label}
+
+ );
+ })}
+
+
+ {previewMode === "user" ? (
+ <>
+
+
+ Username
+
+
+ setPreviewUsername(event.target.value)
+ }
+ placeholder="deploy"
+ autoComplete="off"
+ aria-invalid={Boolean(usernameError)}
+ />
+ {usernameError ? (
+
+ {usernameError}
+
+ ) : null}
+
+
+
+ Initial groups ({selectedGroups.length}/16)
+
+
+ {quickGroupOptions.map((group) => {
+ const active = selectedGroups.includes(group);
+ const sensitive = sensitiveGroups.has(group);
+ return (
+ toggleGroup(group)}
+ style={{
+ padding: "5px 10px",
+ fontSize: 12,
+ borderColor: active
+ ? sensitive
+ ? "rgba(245,158,11,0.38)"
+ : "rgba(59,130,246,0.38)"
+ : "var(--border)",
+ background: active
+ ? sensitive
+ ? "rgba(245,158,11,0.1)"
+ : "rgba(59,130,246,0.1)"
+ : "var(--bg-input)",
+ color: active
+ ? sensitive
+ ? "#f59e0b"
+ : "var(--accent)"
+ : "var(--text-secondary)",
+ }}
+ >
+ {group}
+
+ );
+ })}
+
+ {groupOptions.some(
+ (group) => !selectedGroups.includes(group),
+ ) ? (
+
!selectedGroups.includes(group))
+ .map((group) => ({
+ value: group,
+ label: group,
+ description: sensitiveGroups.has(group)
+ ? "Privileged"
+ : undefined,
+ }))}
+ onChange={(value) => {
+ if (value) toggleGroup(value);
+ }}
+ placeholder="Select another existing group..."
+ searchPlaceholder="Search group..."
+ emptyText="No matching group found"
+ />
+ ) : null}
+
+ >
+ ) : (
+
+
+ Group name
+
+ setPreviewGroupName(event.target.value)}
+ placeholder="docker"
+ autoComplete="off"
+ aria-invalid={Boolean(groupNameError)}
+ />
+ {groupNameError ? (
+
+ {groupNameError}
+
+ ) : null}
+
+ )}
+
+
+
+
+
+
+ Planned host change
+
+ {privilegedSelection ? (
+
+ ) : (
+
+ )}
+
+
+ {previewCommand}
+
+ {previewMode === "user" ? (
+
+ No password is set. Configure an SSH public key separately
+ before using this account for remote login.
+
+ ) : null}
+
+
+ {privilegedSelection ? (
+
+
+
+ {previewMode === "user"
+ ? `Membership in ${selectedSensitiveGroups.join(", ")} can grant administrative or root-equivalent access.`
+ : `The ${normalizedGroupName} group is treated as privileged.`}{" "}
+ An explicit confirmation and audit event are required.
+
+
+ ) : null}
+
+
{
+ if (previewMode === "user") {
+ onRequestCreateUserConfirm(normalizedUsername, selectedGroups);
+ } else {
+ onRequestCreateGroupConfirm(normalizedGroupName);
+ }
+ }}
+ style={{
+ justifyContent: "center",
+ opacity:
+ formInvalid || actionRunning || !canManageSystemAccounts
+ ? 0.65
+ : 1,
+ }}
+ title={
+ canManageSystemAccounts
+ ? undefined
+ : "Operator or Super Admin role required"
+ }
+ >
+ {actionRunning ? (
+
+ ) : (
+
+ )}
+ {previewMode === "user" ? "Create User" : "Create Group"}
+
+ {!canManageSystemAccounts ? (
+
+ Operator or Super Admin role is required for host account
+ changes.
+
+ ) : null}
+
+
+
{users.length > 0 ? (
- {users.map((user) => (
-
- ))}
+ {users.map((user) => {
+ const sourceUser = user;
+ const isEditing = editingUsername === user.username;
+ const updateActionKey = getSystemUserUpdateActionKey(user.username);
+ const updateRunning = isActionRunning(updateActionKey);
+ const editGroupOptions = Array.from(
+ new Set([...editDraftGroups, ...groupOptions.slice(0, 10)]),
+ );
+ const selectedPrivilegedEditGroups = editDraftGroups.filter(
+ (group) =>
+ sensitiveGroups.has(group) && !sourceUser.groups.includes(group),
+ );
+ const groupChanges =
+ editDraftGroups.length !== sourceUser.groups.length ||
+ editDraftGroups.some(
+ (group) => !sourceUser.groups.includes(group),
+ );
+ const shellChanges =
+ !sourceUser.isSshUser && editDraftShell !== sourceUser.shell;
+ const hasChanges = groupChanges || shellChanges;
+
+ return (
+
beginEdit(sourceUser)}
+ >
+ {isEditing ? (
+
+
+
+ {sourceUser.isSshUser
+ ? "Edit SSH user access"
+ : "Edit user access"}
+
+
+
+
+ {sourceUser.isSshUser ? (
+
+ The active SSH login keeps its current shell and groups.
+ You can only add supplementary groups. Changes apply to
+ new SSH sessions after confirmation.
+
+ ) : null}
+
+
+
+ Login shell
+
+
+ setEditDraftShell(event.target.value)
+ }
+ >
+ {Array.from(
+ new Set([
+ sourceUser.shell ?? "/bin/bash",
+ "/bin/bash",
+ "/bin/sh",
+ "/usr/sbin/nologin",
+ ]),
+ ).map((shell) => (
+
+ {shell}
+
+ ))}
+
+
+
+
+
+ Group memberships ({editDraftGroups.length}/16)
+
+
+ {editGroupOptions.map((group) => {
+ const selected = editDraftGroups.includes(group);
+ const locked =
+ group === sourceUser.primaryGroup ||
+ (sourceUser.isSshUser &&
+ sourceUser.groups.includes(group));
+ const privileged = sensitiveGroups.has(group);
+ return (
+
+ toggleEditGroup(group, sourceUser)
+ }
+ title={
+ locked
+ ? group === sourceUser.primaryGroup
+ ? "The primary group cannot be removed"
+ : "Current SSH login groups cannot be removed"
+ : undefined
+ }
+ style={{
+ padding: "4px 9px",
+ fontSize: 12,
+ color: selected
+ ? privileged
+ ? "#f59e0b"
+ : "#3b82f6"
+ : "var(--text-secondary)",
+ borderColor: selected
+ ? privileged
+ ? "rgba(245,158,11,0.35)"
+ : "rgba(59,130,246,0.35)"
+ : "var(--border)",
+ background: selected
+ ? privileged
+ ? "rgba(245,158,11,0.08)"
+ : "rgba(59,130,246,0.08)"
+ : "var(--bg-input)",
+ display: "inline-flex",
+ alignItems: "center",
+ gap: 5,
+ }}
+ >
+ {locked ? : null}
+ {group}
+
+ );
+ })}
+
+ {groupOptions.some(
+ (group) => !editDraftGroups.includes(group),
+ ) ? (
+
!editDraftGroups.includes(group),
+ )
+ .map((group) => ({
+ value: group,
+ label: group,
+ description: sensitiveGroups.has(group)
+ ? "Privileged"
+ : undefined,
+ }))}
+ onChange={(value) => {
+ if (value) {
+ toggleEditGroup(value, sourceUser);
+ }
+ }}
+ placeholder="Add another existing group..."
+ searchPlaceholder="Search group..."
+ emptyText="No matching group found"
+ />
+ ) : null}
+
+
+ {selectedPrivilegedEditGroups.length > 0 ? (
+
+ New privileged access selected:{" "}
+ {selectedPrivilegedEditGroups.join(", ")}. The live
+ action requires explicit confirmation and is audited.
+
+ ) : null}
+
+
+ setEditingUsername(null)}
+ >
+ Cancel
+
+ requestEditConfirmation(sourceUser)}
+ style={{ opacity: !hasChanges || updateRunning ? 0.65 : 1 }}
+ >
+ {updateRunning ? (
+
+ ) : null}
+ Save Changes
+
+
+
+ ) : null}
+
+ );
+ })}
) : (
get<{ success: boolean; data: Server[] }>("/servers", { timeoutMs: 10000 }),
@@ -1485,6 +1515,32 @@ export const servers = {
`/servers/${id}/config`,
{ timeoutMs: 45000 },
),
+ createSystemUser: (id: string, body: ServerSystemUserCreateBody) =>
+ post<{ success: boolean; message: string }>(
+ `/servers/${id}/system-users`,
+ body,
+ { timeoutMs: 30000 },
+ ),
+ createSystemGroup: (id: string, body: ServerSystemGroupCreateBody) =>
+ post<{ success: boolean; message: string }>(
+ `/servers/${id}/system-groups`,
+ body,
+ { timeoutMs: 30000 },
+ ),
+ updateSystemUser: (
+ id: string,
+ username: string,
+ body: ServerSystemUserUpdateBody,
+ ) =>
+ patch<{
+ success: boolean;
+ data: ServerSystemUserUpdateResult;
+ message: string;
+ }>(
+ `/servers/${id}/system-users/${encodeURIComponent(username)}`,
+ body,
+ { timeoutMs: 30000 },
+ ),
reset: (id: string, confirmation: string) =>
post<{ success: boolean; message: string }>(
`/servers/${id}/reset`,
diff --git a/src/server/routes/servers.ts b/src/server/routes/servers.ts
index f158a5e..7a7cf84 100644
--- a/src/server/routes/servers.ts
+++ b/src/server/routes/servers.ts
@@ -2,7 +2,11 @@ import { FastifyInstance } from "fastify";
import { z } from "zod";
import prisma from "../lib/prisma";
import { encrypt } from "../lib/crypto";
-import { authenticate, requireApiKeyPermission } from "../middleware/auth";
+import {
+ authenticate,
+ requireApiKeyPermission,
+ requireRole,
+} from "../middleware/auth";
import { auditLog } from "../services/audit.service";
import { dispatchRuntimeNotification } from "../services/notification.service";
import {
@@ -12,6 +16,14 @@ import {
} from "../services/server-access.service";
import * as ssh from "../services/ssh.service";
import { closeTerminalSessionsForServer } from "./terminal";
+import {
+ getPrivilegedSystemGroups,
+ hasRequiredPrivilegeAcknowledgement,
+ LINUX_ACCOUNT_NAME_PATTERN,
+ PRIVILEGED_SYSTEM_GROUPS,
+ SystemAccountConflictError,
+ SystemAccountInputError,
+} from "../services/ssh-services/internal/system-account-security";
function serializeMetric(
metric: {
@@ -95,6 +107,74 @@ const ServerDeleteSchema = z.object({
const ServerResetSchema = z.object({
confirmation: z.literal("DELETE"),
});
+const LinuxAccountNameSchema = z
+ .string()
+ .trim()
+ .regex(
+ LINUX_ACCOUNT_NAME_PATTERN,
+ "Must start with a lowercase letter or underscore and contain only lowercase letters, numbers, underscores, or hyphens (max 32 characters)",
+ );
+const ServerSystemUserCreateSchema = z
+ .object({
+ username: LinuxAccountNameSchema.refine((value) => value !== "root", {
+ message: "The root account cannot be created or replaced",
+ }),
+ groups: z
+ .array(LinuxAccountNameSchema)
+ .max(16, "A user can be assigned to at most 16 groups in one action")
+ .default([])
+ .refine((groups) => new Set(groups).size === groups.length, {
+ message: "Group names must be unique",
+ }),
+ acknowledgePrivilegedGroups: z.boolean().default(false),
+ })
+ .superRefine((value, context) => {
+ const selectedPrivilegedGroups = getPrivilegedSystemGroups(value.groups);
+ if (!hasRequiredPrivilegeAcknowledgement(
+ value.groups,
+ value.acknowledgePrivilegedGroups,
+ )) {
+ context.addIssue({
+ code: "custom",
+ path: ["acknowledgePrivilegedGroups"],
+ message: `Explicit confirmation is required for privileged groups: ${selectedPrivilegedGroups.join(", ")}`,
+ });
+ }
+ });
+const ServerSystemUserUpdateSchema = z.object({
+ groups: z
+ .array(LinuxAccountNameSchema)
+ .max(16, "A user can have at most 16 group memberships")
+ .refine((groups) => new Set(groups).size === groups.length, {
+ message: "Group names must be unique",
+ }),
+ shell: z.string().trim().min(1).max(128),
+ expectedGroups: z
+ .array(LinuxAccountNameSchema)
+ .max(16, "The expected group list is too large")
+ .refine((groups) => new Set(groups).size === groups.length, {
+ message: "Expected group names must be unique",
+ }),
+ expectedShell: z.string().trim().min(1).max(128),
+ acknowledgePrivilegedGroups: z.boolean().default(false),
+});
+const ServerSystemGroupCreateSchema = z
+ .object({
+ groupName: LinuxAccountNameSchema,
+ acknowledgePrivilegedGroup: z.boolean().default(false),
+ })
+ .superRefine((value, context) => {
+ if (
+ PRIVILEGED_SYSTEM_GROUPS.has(value.groupName) &&
+ !value.acknowledgePrivilegedGroup
+ ) {
+ context.addIssue({
+ code: "custom",
+ path: ["acknowledgePrivilegedGroup"],
+ message: `Explicit confirmation is required for privileged group ${value.groupName}`,
+ });
+ }
+ });
const DockerPruneSchema = z.object({
options: z
.object({
@@ -154,6 +234,10 @@ export async function serverRoutes(app: FastifyInstance) {
authenticate,
requireApiKeyPermission("write:servers"),
];
+ const serverSystemAccountWriteAccess = [
+ ...serverWriteAccess,
+ requireRole("OPERATOR"),
+ ];
const serverContainerReadAccess = [
authenticate,
requireApiKeyPermission("read:containers"),
@@ -581,6 +665,217 @@ export async function serverRoutes(app: FastifyInstance) {
},
);
+ app.post(
+ "/:id/system-users",
+ { preHandler: serverSystemAccountWriteAccess },
+ async (req, reply) => {
+ const { id } = req.params as { id: string };
+ const body = ServerSystemUserCreateSchema.safeParse(req.body ?? {});
+ if (!body.success) {
+ return reply
+ .status(400)
+ .send({ success: false, error: body.error.flatten() });
+ }
+
+ const server = await getAccessibleServer(
+ req.userId,
+ id,
+ req.organizationId,
+ );
+ if (!server) {
+ return reply.status(403).send({
+ success: false,
+ error: "Forbidden — you do not have access to this server",
+ });
+ }
+
+ const privilegedGroups = getPrivilegedSystemGroups(body.data.groups);
+ try {
+ await ssh.createServerSystemUser(server, body.data);
+ const message = `System user ${body.data.username} created on ${server.name}`;
+ await auditLog({
+ userId: req.userId,
+ organizationId: req.organizationId,
+ serverId: id,
+ action: "SERVER_SYSTEM_USER_CREATE",
+ category: "SERVER",
+ level: privilegedGroups.length > 0 ? "WARNING" : "SUCCESS",
+ message,
+ meta: {
+ username: body.data.username,
+ groups: body.data.groups,
+ privilegedGroups,
+ },
+ });
+ return reply.status(201).send({ success: true, message });
+ } catch (err: unknown) {
+ const message =
+ err instanceof SystemAccountInputError
+ ? err.message
+ : "The host could not create the system user. Verify sudo access and Linux account-management tools, then try again.";
+ await auditLog({
+ userId: req.userId,
+ organizationId: req.organizationId,
+ serverId: id,
+ action: "SERVER_SYSTEM_USER_CREATE_FAILED",
+ category: "SERVER",
+ level: "ERROR",
+ message: `Failed to create system user ${body.data.username} on ${server.name}`,
+ meta: { username: body.data.username, groups: body.data.groups },
+ });
+ return reply.status(400).send({ success: false, error: message });
+ }
+ },
+ );
+
+ app.post(
+ "/:id/system-groups",
+ { preHandler: serverSystemAccountWriteAccess },
+ async (req, reply) => {
+ const { id } = req.params as { id: string };
+ const body = ServerSystemGroupCreateSchema.safeParse(req.body ?? {});
+ if (!body.success) {
+ return reply
+ .status(400)
+ .send({ success: false, error: body.error.flatten() });
+ }
+
+ const server = await getAccessibleServer(
+ req.userId,
+ id,
+ req.organizationId,
+ );
+ if (!server) {
+ return reply.status(403).send({
+ success: false,
+ error: "Forbidden — you do not have access to this server",
+ });
+ }
+
+ const privileged = PRIVILEGED_SYSTEM_GROUPS.has(body.data.groupName);
+ try {
+ await ssh.createServerSystemGroup(server, body.data.groupName);
+ const message = `System group ${body.data.groupName} created on ${server.name}`;
+ await auditLog({
+ userId: req.userId,
+ organizationId: req.organizationId,
+ serverId: id,
+ action: "SERVER_SYSTEM_GROUP_CREATE",
+ category: "SERVER",
+ level: privileged ? "WARNING" : "SUCCESS",
+ message,
+ meta: { groupName: body.data.groupName, privileged },
+ });
+ return reply.status(201).send({ success: true, message });
+ } catch (err: unknown) {
+ const message =
+ err instanceof SystemAccountInputError
+ ? err.message
+ : "The host could not create the system group. Verify sudo access and Linux account-management tools, then try again.";
+ await auditLog({
+ userId: req.userId,
+ organizationId: req.organizationId,
+ serverId: id,
+ action: "SERVER_SYSTEM_GROUP_CREATE_FAILED",
+ category: "SERVER",
+ level: "ERROR",
+ message: `Failed to create system group ${body.data.groupName} on ${server.name}`,
+ meta: { groupName: body.data.groupName },
+ });
+ return reply.status(400).send({ success: false, error: message });
+ }
+ },
+ );
+
+ app.patch(
+ "/:id/system-users/:username",
+ { preHandler: serverSystemAccountWriteAccess },
+ async (req, reply) => {
+ const { id, username } = req.params as {
+ id: string;
+ username: string;
+ };
+ const parsedUsername = LinuxAccountNameSchema.safeParse(username);
+ const body = ServerSystemUserUpdateSchema.safeParse(req.body ?? {});
+ if (!parsedUsername.success) {
+ return reply.status(400).send({
+ success: false,
+ error: parsedUsername.error.flatten(),
+ });
+ }
+ if (!body.success) {
+ return reply.status(400).send({
+ success: false,
+ error: body.error.flatten(),
+ });
+ }
+
+ const server = await getAccessibleServer(
+ req.userId,
+ id,
+ req.organizationId,
+ );
+ if (!server) {
+ return reply.status(403).send({
+ success: false,
+ error: "Forbidden — you do not have access to this server",
+ });
+ }
+
+ try {
+ const result = await ssh.updateServerSystemUser(server, {
+ username: parsedUsername.data,
+ ...body.data,
+ });
+ const privilegedGroups = getPrivilegedSystemGroups(
+ result.addedGroups,
+ );
+ const message = `System user ${result.username} updated on ${server.name}`;
+ await auditLog({
+ userId: req.userId,
+ organizationId: req.organizationId,
+ serverId: id,
+ action: "SERVER_SYSTEM_USER_UPDATE",
+ category: "SERVER",
+ level: privilegedGroups.length > 0 ? "WARNING" : "SUCCESS",
+ message,
+ meta: {
+ username: result.username,
+ isSshUser: result.isSshUser,
+ addedGroups: result.addedGroups,
+ removedGroups: result.removedGroups,
+ privilegedGroups,
+ previousShell: result.previousShell,
+ shell: result.shell,
+ },
+ });
+ return reply.send({ success: true, data: result, message });
+ } catch (err: unknown) {
+ const isConflict = err instanceof SystemAccountConflictError;
+ const message =
+ err instanceof SystemAccountInputError
+ ? err.message
+ : "The host could not update the system user. Verify sudo access and Linux account-management tools, then try again.";
+ await auditLog({
+ userId: req.userId,
+ organizationId: req.organizationId,
+ serverId: id,
+ action: "SERVER_SYSTEM_USER_UPDATE_FAILED",
+ category: "SERVER",
+ level: isConflict ? "WARNING" : "ERROR",
+ message: `Failed to update system user ${parsedUsername.data} on ${server.name}`,
+ meta: {
+ username: parsedUsername.data,
+ conflict: isConflict,
+ },
+ });
+ return reply
+ .status(isConflict ? 409 : 400)
+ .send({ success: false, error: message });
+ }
+ },
+ );
+
app.post(
"/:id/reset",
{ preHandler: serverWriteAccess },
diff --git a/src/server/services/ssh-services/internal/privilege.ts b/src/server/services/ssh-services/internal/privilege.ts
index 4dfc1aa..c07a146 100644
--- a/src/server/services/ssh-services/internal/privilege.ts
+++ b/src/server/services/ssh-services/internal/privilege.ts
@@ -3,3 +3,10 @@ import { Server } from "@prisma/client";
export function privilegedCommand(server: Server, command: string): string {
return server.username === "root" ? command : `sudo ${command}`;
}
+
+export function nonInteractivePrivilegedCommand(
+ server: Server,
+ command: string,
+): string {
+ return server.username === "root" ? command : `sudo -n ${command}`;
+}
diff --git a/src/server/services/ssh-services/internal/system-account-security.ts b/src/server/services/ssh-services/internal/system-account-security.ts
new file mode 100644
index 0000000..dd693e7
--- /dev/null
+++ b/src/server/services/ssh-services/internal/system-account-security.ts
@@ -0,0 +1,148 @@
+export const LINUX_ACCOUNT_NAME_PATTERN = /^[a-z_][a-z0-9_-]{0,31}$/;
+
+export const PRIVILEGED_SYSTEM_GROUPS = new Set([
+ "root",
+ "docker",
+ "sudo",
+ "wheel",
+]);
+
+export const ALLOWED_SYSTEM_USER_SHELLS = new Set([
+ "/bin/bash",
+ "/bin/sh",
+ "/bin/zsh",
+ "/usr/bin/bash",
+ "/usr/bin/zsh",
+ "/usr/sbin/nologin",
+ "/sbin/nologin",
+]);
+
+export class SystemAccountInputError extends Error {
+ constructor(message: string) {
+ super(message);
+ this.name = "SystemAccountInputError";
+ }
+}
+
+export class SystemAccountConflictError extends SystemAccountInputError {
+ constructor(message: string) {
+ super(message);
+ this.name = "SystemAccountConflictError";
+ }
+}
+
+export function assertLinuxAccountName(
+ value: string,
+ label: "User" | "Group",
+) {
+ if (!LINUX_ACCOUNT_NAME_PATTERN.test(value)) {
+ throw new SystemAccountInputError(
+ `${label} name must start with a lowercase letter or underscore and contain only lowercase letters, numbers, underscores, or hyphens (max 32 characters)`,
+ );
+ }
+}
+
+export function getPrivilegedSystemGroups(groups: string[]): string[] {
+ return groups.filter((group) => PRIVILEGED_SYSTEM_GROUPS.has(group));
+}
+
+export function assertAllowedSystemUserShell(shell: string): void {
+ if (!ALLOWED_SYSTEM_USER_SHELLS.has(shell)) {
+ throw new SystemAccountInputError("The selected login shell is not allowed");
+ }
+}
+
+export function haveSameStringMembers(
+ left: string[],
+ right: string[],
+): boolean {
+ const leftSet = new Set(left);
+ const rightSet = new Set(right);
+ return (
+ leftSet.size === rightSet.size &&
+ [...leftSet].every((value) => rightSet.has(value))
+ );
+}
+
+export interface SystemUserUpdatePolicyResult {
+ isSshUser: boolean;
+ addedGroups: string[];
+ removedGroups: string[];
+ shellChanged: boolean;
+}
+
+export function validateSystemUserUpdatePolicy(options: {
+ username: string;
+ uid: number;
+ serverUsername: string;
+ currentGroups: string[];
+ primaryGroup: string;
+ currentShell: string;
+ desiredGroups: string[];
+ desiredShell: string;
+ expectedGroups: string[];
+ expectedShell: string;
+ acknowledgePrivilegedGroups: boolean;
+}): SystemUserUpdatePolicyResult {
+ if (options.uid === 0 || options.username === "root") {
+ throw new SystemAccountInputError("The root account is protected");
+ }
+ if (
+ options.currentShell !== options.expectedShell ||
+ !haveSameStringMembers(options.currentGroups, options.expectedGroups)
+ ) {
+ throw new SystemAccountConflictError(
+ "This user changed on the host after the snapshot was loaded. Refresh Server Config before editing again.",
+ );
+ }
+ if (!options.desiredGroups.includes(options.primaryGroup)) {
+ throw new SystemAccountInputError(
+ `Primary group ${options.primaryGroup} cannot be removed`,
+ );
+ }
+
+ const isSshUser = options.username === options.serverUsername;
+ const addedGroups = options.desiredGroups.filter(
+ (group) => !options.currentGroups.includes(group),
+ );
+ const removedGroups = options.currentGroups.filter(
+ (group) => !options.desiredGroups.includes(group),
+ );
+ const shellChanged = options.desiredShell !== options.currentShell;
+
+ if (isSshUser && shellChanged) {
+ throw new SystemAccountInputError(
+ "The active SSH login shell cannot be changed from Server Config",
+ );
+ }
+ if (isSshUser && removedGroups.length > 0) {
+ throw new SystemAccountInputError(
+ "Existing groups cannot be removed from the active SSH login",
+ );
+ }
+ if (shellChanged) {
+ assertAllowedSystemUserShell(options.desiredShell);
+ }
+
+ const addedPrivilegedGroups = getPrivilegedSystemGroups(addedGroups);
+ if (
+ addedPrivilegedGroups.length > 0 &&
+ !options.acknowledgePrivilegedGroups
+ ) {
+ throw new SystemAccountInputError(
+ `Explicit confirmation is required for privileged groups: ${addedPrivilegedGroups.join(", ")}`,
+ );
+ }
+ if (!shellChanged && addedGroups.length === 0 && removedGroups.length === 0) {
+ throw new SystemAccountInputError("No user changes were selected");
+ }
+
+ return { isSshUser, addedGroups, removedGroups, shellChanged };
+}
+
+export function hasRequiredPrivilegeAcknowledgement(
+ groups: string[],
+ acknowledged: boolean,
+): boolean {
+ return getPrivilegedSystemGroups(groups).length === 0 || acknowledged;
+}
diff --git a/src/server/services/ssh-services/server-config.ts b/src/server/services/ssh-services/server-config.ts
index b2cf7d4..e0df20e 100644
--- a/src/server/services/ssh-services/server-config.ts
+++ b/src/server/services/ssh-services/server-config.ts
@@ -15,7 +15,15 @@ import {
inspectWebServerCapability,
} from "./web-stack";
import { escapeShellArg } from "./internal/shell";
-import { privilegedCommand } from "./internal/privilege";
+import {
+ nonInteractivePrivilegedCommand,
+ privilegedCommand,
+} from "./internal/privilege";
+import {
+ assertLinuxAccountName,
+ SystemAccountInputError,
+ validateSystemUserUpdatePolicy,
+} from "./internal/system-account-security";
const CONFIG_FAST_TIMEOUT_MS = 8_000;
const CONFIG_LIST_TIMEOUT_MS = 12_000;
@@ -33,6 +41,7 @@ export interface ServerSystemUser {
home: string | null;
shell: string | null;
groups: string[];
+ primaryGroup: string | null;
isRoot: boolean;
isSshUser: boolean;
}
@@ -63,6 +72,7 @@ export interface ServerConfigSnapshot {
users: ServerSystemUser[];
rootUser: ServerSystemUser | null;
nonRootUsers: ServerSystemUser[];
+ systemGroups: string[];
hasRootUser: boolean;
sudoNonInteractive: boolean;
docker: DockerRuntimeStatus;
@@ -80,10 +90,12 @@ export async function getServerConfigSnapshot(
"while IFS=: read -r user _ uid gid _ home shell; do",
' if [ "$uid" -eq 0 ] || [ "$uid" -ge 1000 ]; then',
' groups=$(id -nG "$user" 2>/dev/null || true)',
- ' printf \'%s|%s|%s|%s|%s|%s\\n\' "$user" "$uid" "$gid" "$home" "$shell" "$groups"',
+ ' primary_group=$(id -gn "$user" 2>/dev/null || true)',
+ ' printf \'%s|%s|%s|%s|%s|%s|%s\\n\' "$user" "$uid" "$gid" "$home" "$shell" "$groups" "$primary_group"',
" fi",
"done < /etc/passwd",
].join("\n");
+ const groupsScript = "cut -d: -f1 /etc/group 2>/dev/null | sort -u";
const platformResult = await Promise.allSettled([
detectServerPlatform(server, configCommandTimeout(CONFIG_FAST_TIMEOUT_MS)),
@@ -98,6 +110,7 @@ export async function getServerConfigSnapshot(
kernelResult,
currentUserResult,
usersResult,
+ groupsResult,
lastBootResult,
servicesResult,
diskMountsResult,
@@ -123,6 +136,11 @@ export async function getServerConfigSnapshot(
`bash -lc ${escapeShellArg(usersScript)}`,
configCommandTimeout(CONFIG_LIST_TIMEOUT_MS),
),
+ exec(
+ server,
+ `bash -lc ${escapeShellArg(groupsScript)}`,
+ configCommandTimeout(CONFIG_LIST_TIMEOUT_MS),
+ ),
exec(
server,
"bash -lc \"uptime -s 2>/dev/null || who -b 2>/dev/null | sed 's/.*system boot[[:space:]]*//'\"",
@@ -170,7 +188,15 @@ export async function getServerConfigSnapshot(
.map((line) => line.trim())
.filter(Boolean)
.map((line) => {
- const [username, uidRaw, gidRaw, homeRaw, shellRaw, groupsRaw] =
+ const [
+ username,
+ uidRaw,
+ gidRaw,
+ homeRaw,
+ shellRaw,
+ groupsRaw,
+ primaryGroupRaw,
+ ] =
line.split("|");
const uid = Number(uidRaw);
const gid = Number(gidRaw);
@@ -185,6 +211,7 @@ export async function getServerConfigSnapshot(
.split(/\s+/)
.map((group) => group.trim())
.filter(Boolean),
+ primaryGroup: primaryGroupRaw || null,
isRoot: uid === 0 || username === "root",
isSshUser: username === server.username,
} satisfies ServerSystemUser;
@@ -197,6 +224,13 @@ export async function getServerConfigSnapshot(
const rootUser = users.find((user) => user.isRoot) ?? null;
const nonRootUsers = users.filter((user) => !user.isRoot);
+ const systemGroups =
+ groupsResult.status === "fulfilled"
+ ? groupsResult.value.stdout
+ .split("\n")
+ .map((group) => group.trim())
+ .filter(Boolean)
+ : [];
return {
hostname:
@@ -216,6 +250,7 @@ export async function getServerConfigSnapshot(
users,
rootUser,
nonRootUsers,
+ systemGroups,
hasRootUser: Boolean(rootUser),
sudoNonInteractive: docker.platform.sudoNonInteractive,
docker,
@@ -230,6 +265,260 @@ export async function getServerConfigSnapshot(
};
}
+async function accountExists(
+ server: Server,
+ database: "passwd" | "group",
+ name: string,
+): Promise {
+ const result = await exec(
+ server,
+ `getent ${database} ${escapeShellArg(name)} >/dev/null 2>&1`,
+ configCommandTimeout(CONFIG_FAST_TIMEOUT_MS),
+ );
+ return result.code === 0;
+}
+
+export async function createServerSystemGroup(
+ server: Server,
+ groupName: string,
+): Promise {
+ assertLinuxAccountName(groupName, "Group");
+ if (await accountExists(server, "group", groupName)) {
+ throw new SystemAccountInputError(`Group ${groupName} already exists`);
+ }
+
+ await execStrict(
+ server,
+ nonInteractivePrivilegedCommand(
+ server,
+ `groupadd ${escapeShellArg(groupName)}`,
+ ),
+ configCommandTimeout(CONFIG_LIST_TIMEOUT_MS),
+ );
+}
+
+export async function createServerSystemUser(
+ server: Server,
+ options: { username: string; groups: string[] },
+): Promise {
+ assertLinuxAccountName(options.username, "User");
+ if (options.username === "root") {
+ throw new SystemAccountInputError(
+ "The root account cannot be created or replaced",
+ );
+ }
+
+ const groups = Array.from(new Set(options.groups));
+ groups.forEach((group) => assertLinuxAccountName(group, "Group"));
+
+ if (await accountExists(server, "passwd", options.username)) {
+ throw new SystemAccountInputError(
+ `User ${options.username} already exists`,
+ );
+ }
+
+ const missingGroups: string[] = [];
+ for (const group of groups) {
+ if (!(await accountExists(server, "group", group))) {
+ missingGroups.push(group);
+ }
+ }
+ if (missingGroups.length > 0) {
+ throw new SystemAccountInputError(
+ `Group not found: ${missingGroups.join(", ")}`,
+ );
+ }
+
+ const groupArgs =
+ groups.length > 0 ? ` -G ${escapeShellArg(groups.join(","))}` : "";
+ await execStrict(
+ server,
+ nonInteractivePrivilegedCommand(
+ server,
+ `useradd -m -s /bin/bash${groupArgs} ${escapeShellArg(options.username)}`,
+ ),
+ configCommandTimeout(CONFIG_LIST_TIMEOUT_MS),
+ );
+}
+
+interface CurrentServerSystemUser {
+ username: string;
+ uid: number;
+ gid: number;
+ home: string;
+ shell: string;
+ groups: string[];
+ primaryGroup: string;
+}
+
+export interface ServerSystemUserUpdateResult {
+ username: string;
+ isSshUser: boolean;
+ addedGroups: string[];
+ removedGroups: string[];
+ previousShell: string;
+ shell: string;
+}
+
+async function getCurrentServerSystemUser(
+ server: Server,
+ username: string,
+): Promise {
+ const script = [
+ `TARGET=${escapeShellArg(username)}`,
+ 'entry=$(getent passwd "$TARGET") || exit 3',
+ 'IFS=: read -r account _ uid gid _ home shell <<< "$entry"',
+ 'groups=$(id -nG "$TARGET" 2>/dev/null) || exit 3',
+ 'primary_group=$(id -gn "$TARGET" 2>/dev/null) || exit 3',
+ 'printf \'%s|%s|%s|%s|%s|%s|%s\\n\' "$account" "$uid" "$gid" "$home" "$shell" "$groups" "$primary_group"',
+ ].join("\n");
+ const result = await exec(
+ server,
+ `bash -lc ${escapeShellArg(script)}`,
+ configCommandTimeout(CONFIG_FAST_TIMEOUT_MS),
+ );
+
+ if (result.code === 3) {
+ throw new SystemAccountInputError(`User ${username} was not found`);
+ }
+ if (result.code !== 0 || !result.stdout.trim()) {
+ throw new Error("Failed to inspect the current system user state");
+ }
+
+ const [
+ account,
+ uidRaw,
+ gidRaw,
+ home,
+ shell,
+ groupsRaw,
+ primaryGroup,
+ ] = result.stdout.trim().split("|");
+ const uid = Number(uidRaw);
+ const gid = Number(gidRaw);
+ if (
+ !account ||
+ !Number.isInteger(uid) ||
+ !Number.isInteger(gid) ||
+ !shell ||
+ !primaryGroup
+ ) {
+ throw new Error("The host returned an invalid system user record");
+ }
+
+ return {
+ username: account,
+ uid,
+ gid,
+ home,
+ shell,
+ groups: groupsRaw.split(/\s+/).filter(Boolean),
+ primaryGroup,
+ };
+}
+
+export async function updateServerSystemUser(
+ server: Server,
+ options: {
+ username: string;
+ groups: string[];
+ shell: string;
+ expectedGroups: string[];
+ expectedShell: string;
+ acknowledgePrivilegedGroups: boolean;
+ },
+): Promise {
+ assertLinuxAccountName(options.username, "User");
+
+ const groups = Array.from(new Set(options.groups));
+ const expectedGroups = Array.from(new Set(options.expectedGroups));
+ if (groups.length > 16 || expectedGroups.length > 16) {
+ throw new SystemAccountInputError(
+ "A user can have at most 16 group memberships in one action",
+ );
+ }
+ groups.forEach((group) => assertLinuxAccountName(group, "Group"));
+ expectedGroups.forEach((group) => assertLinuxAccountName(group, "Group"));
+
+ const current = await getCurrentServerSystemUser(server, options.username);
+ const policy = validateSystemUserUpdatePolicy({
+ username: current.username,
+ uid: current.uid,
+ serverUsername: server.username,
+ currentGroups: current.groups,
+ primaryGroup: current.primaryGroup,
+ currentShell: current.shell,
+ desiredGroups: groups,
+ desiredShell: options.shell,
+ expectedGroups,
+ expectedShell: options.expectedShell,
+ acknowledgePrivilegedGroups: options.acknowledgePrivilegedGroups,
+ });
+ const { isSshUser, addedGroups, removedGroups, shellChanged } = policy;
+
+ if (shellChanged) {
+ const shellCheck = await exec(
+ server,
+ `test -x ${escapeShellArg(options.shell)}`,
+ configCommandTimeout(CONFIG_FAST_TIMEOUT_MS),
+ );
+ if (shellCheck.code !== 0) {
+ throw new SystemAccountInputError(
+ `Login shell ${options.shell} is not available on the host`,
+ );
+ }
+ }
+
+ const missingGroups: string[] = [];
+ for (const group of groups) {
+ if (!(await accountExists(server, "group", group))) {
+ missingGroups.push(group);
+ }
+ }
+ if (missingGroups.length > 0) {
+ throw new SystemAccountInputError(
+ `Group not found: ${missingGroups.join(", ")}`,
+ );
+ }
+
+ let command: string;
+ if (isSshUser) {
+ const supplementaryAdditions = addedGroups.filter(
+ (group) => group !== current.primaryGroup,
+ );
+ if (supplementaryAdditions.length === 0) {
+ throw new SystemAccountInputError("No user changes were selected");
+ }
+ command = `usermod -a -G ${escapeShellArg(supplementaryAdditions.join(","))} ${escapeShellArg(current.username)}`;
+ } else {
+ const supplementaryGroups = groups.filter(
+ (group) => group !== current.primaryGroup,
+ );
+ const argumentsList = [
+ shellChanged ? `-s ${escapeShellArg(options.shell)}` : null,
+ addedGroups.length > 0 || removedGroups.length > 0
+ ? `-G ${escapeShellArg(supplementaryGroups.join(","))}`
+ : null,
+ ].filter((value): value is string => Boolean(value));
+ command = `usermod ${argumentsList.join(" ")} ${escapeShellArg(current.username)}`;
+ }
+
+ await execStrict(
+ server,
+ nonInteractivePrivilegedCommand(server, command),
+ configCommandTimeout(CONFIG_LIST_TIMEOUT_MS),
+ );
+
+ return {
+ username: current.username,
+ isSshUser,
+ addedGroups,
+ removedGroups,
+ previousShell: current.shell,
+ shell: options.shell,
+ };
+}
+
function createUnavailableWebCapability(
platform: ServerPlatformInfo,
reason: unknown,
diff --git a/tests/backend/system-account-security.test.ts b/tests/backend/system-account-security.test.ts
new file mode 100644
index 0000000..6ed41ae
--- /dev/null
+++ b/tests/backend/system-account-security.test.ts
@@ -0,0 +1,158 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import {
+ assertLinuxAccountName,
+ assertAllowedSystemUserShell,
+ getPrivilegedSystemGroups,
+ hasRequiredPrivilegeAcknowledgement,
+ SystemAccountConflictError,
+ SystemAccountInputError,
+ validateSystemUserUpdatePolicy,
+} from "../../src/server/services/ssh-services/internal/system-account-security";
+
+test("system account names accept conservative Linux-safe identifiers", () => {
+ for (const name of ["deploy", "www-data", "_service", "app_2026"]) {
+ assert.doesNotThrow(() => assertLinuxAccountName(name, "User"));
+ }
+});
+
+test("system account names reject shell syntax, traversal, uppercase, and oversized input", () => {
+ for (const name of [
+ "Deploy",
+ "-root",
+ "../root",
+ "user;reboot",
+ "user name",
+ "a".repeat(33),
+ ]) {
+ assert.throws(
+ () => assertLinuxAccountName(name, "User"),
+ SystemAccountInputError,
+ );
+ }
+});
+
+test("privileged group assignments require explicit acknowledgement", () => {
+ assert.deepEqual(getPrivilegedSystemGroups(["www-data", "docker", "sudo"]), [
+ "docker",
+ "sudo",
+ ]);
+ assert.equal(
+ hasRequiredPrivilegeAcknowledgement(["www-data"], false),
+ true,
+ );
+ assert.equal(
+ hasRequiredPrivilegeAcknowledgement(["docker"], false),
+ false,
+ );
+ assert.equal(
+ hasRequiredPrivilegeAcknowledgement(["docker", "root"], true),
+ true,
+ );
+});
+
+test("system user shell changes use a fixed allowlist", () => {
+ assert.doesNotThrow(() => assertAllowedSystemUserShell("/bin/bash"));
+ assert.doesNotThrow(() =>
+ assertAllowedSystemUserShell("/usr/sbin/nologin"),
+ );
+ assert.throws(
+ () => assertAllowedSystemUserShell("/tmp/custom-shell"),
+ SystemAccountInputError,
+ );
+});
+
+const regularUpdate = {
+ username: "deploy",
+ uid: 1001,
+ serverUsername: "operator",
+ currentGroups: ["deploy", "www-data"],
+ primaryGroup: "deploy",
+ currentShell: "/bin/bash",
+ desiredGroups: ["deploy", "www-data", "docker"],
+ desiredShell: "/bin/sh",
+ expectedGroups: ["www-data", "deploy"],
+ expectedShell: "/bin/bash",
+ acknowledgePrivilegedGroups: true,
+};
+
+test("regular system users can update shell and exact supplementary groups", () => {
+ assert.deepEqual(validateSystemUserUpdatePolicy(regularUpdate), {
+ isSshUser: false,
+ addedGroups: ["docker"],
+ removedGroups: [],
+ shellChanged: true,
+ });
+});
+
+test("system user updates reject root, primary-group removal, and stale snapshots", () => {
+ assert.throws(
+ () => validateSystemUserUpdatePolicy({ ...regularUpdate, uid: 0 }),
+ /root account is protected/i,
+ );
+ assert.throws(
+ () =>
+ validateSystemUserUpdatePolicy({
+ ...regularUpdate,
+ desiredGroups: ["www-data"],
+ }),
+ /Primary group deploy cannot be removed/,
+ );
+ assert.throws(
+ () =>
+ validateSystemUserUpdatePolicy({
+ ...regularUpdate,
+ expectedGroups: ["deploy"],
+ }),
+ SystemAccountConflictError,
+ );
+});
+
+test("active SSH login updates are add-only and cannot change shell", () => {
+ const sshUpdate = {
+ ...regularUpdate,
+ username: "operator",
+ serverUsername: "operator",
+ currentGroups: ["operator", "sudo"],
+ primaryGroup: "operator",
+ desiredGroups: ["operator", "sudo", "www-data"],
+ desiredShell: "/bin/bash",
+ expectedGroups: ["operator", "sudo"],
+ acknowledgePrivilegedGroups: false,
+ };
+
+ assert.deepEqual(validateSystemUserUpdatePolicy(sshUpdate), {
+ isSshUser: true,
+ addedGroups: ["www-data"],
+ removedGroups: [],
+ shellChanged: false,
+ });
+ assert.throws(
+ () =>
+ validateSystemUserUpdatePolicy({
+ ...sshUpdate,
+ desiredGroups: ["operator"],
+ }),
+ /Existing groups cannot be removed/,
+ );
+ assert.throws(
+ () =>
+ validateSystemUserUpdatePolicy({
+ ...sshUpdate,
+ desiredShell: "/bin/sh",
+ }),
+ /SSH login shell cannot be changed/,
+ );
+});
+
+test("new privileged memberships require acknowledgement during updates", () => {
+ assert.throws(
+ () =>
+ validateSystemUserUpdatePolicy({
+ ...regularUpdate,
+ acknowledgePrivilegedGroups: false,
+ }),
+ /Explicit confirmation is required for privileged groups: docker/,
+ );
+});