feat(servers): Added features for adding host users and secure group management.

This commit is contained in:
KodekaTeam committed 2026-07-20 01:50:54 +07:00
1 parent 9486f68022
commit e1d09fdf46
10 files changed
+2549 -546

No files matched your search

+296 -1
View File
@@ -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 },
@@ -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}`;
}
@@ -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;
}
@@ -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<boolean> {
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<void> {
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<void> {
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<CurrentServerSystemUser> {
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<ServerSystemUserUpdateResult> {
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,