feat(servers): Added features for adding host users and secure group management.
This commit is contained in:
1 parent
9486f68022
commit
e1d09fdf46
10 files changed
+2549
-546
No files matched your search
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user