feat(servers): add secure user, group, and SSH access management
This commit is contained in:
1 parent
f8dfe812ff
commit
7ef17105ad
15 files changed
+6043
-52
No files matched your search
@@ -84,6 +84,15 @@ const app = Fastify({
|
||||
trustProxy: process.env.TRUST_PROXY === "true",
|
||||
logger: {
|
||||
level: process.env.NODE_ENV === "production" ? "warn" : "info",
|
||||
redact: {
|
||||
paths: [
|
||||
"req.body.password",
|
||||
"req.body.credential.password",
|
||||
"request.body.password",
|
||||
"request.body.credential.password",
|
||||
],
|
||||
censor: "[REDACTED]",
|
||||
},
|
||||
transport:
|
||||
process.env.NODE_ENV !== "production"
|
||||
? {
|
||||
|
||||
@@ -127,6 +127,20 @@ const ServerSystemUserCreateSchema = z
|
||||
message: "Group names must be unique",
|
||||
}),
|
||||
acknowledgePrivilegedGroups: z.boolean().default(false),
|
||||
remoteLogin: z.boolean(),
|
||||
credential: z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("none") }),
|
||||
z.object({
|
||||
type: z.literal("password"),
|
||||
password: z.string().min(12).max(128),
|
||||
requireChange: z.boolean().default(true),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("ssh-key"),
|
||||
publicKey: z.string().trim().min(32).max(16_384),
|
||||
label: z.string().trim().min(1).max(64).optional(),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
const selectedPrivilegedGroups = getPrivilegedSystemGroups(value.groups);
|
||||
@@ -140,7 +154,39 @@ const ServerSystemUserCreateSchema = z
|
||||
message: `Explicit confirmation is required for privileged groups: ${selectedPrivilegedGroups.join(", ")}`,
|
||||
});
|
||||
}
|
||||
if (!value.remoteLogin && value.credential.type !== "none") {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["credential"],
|
||||
message: "Users without remote login cannot receive SSH credentials",
|
||||
});
|
||||
}
|
||||
if (value.remoteLogin && value.credential.type === "none") {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["credential"],
|
||||
message: "SSH login users require an initial password or public key",
|
||||
});
|
||||
}
|
||||
});
|
||||
const ServerSystemUserPasswordSchema = z.object({
|
||||
password: z.string().min(12).max(128),
|
||||
requireChange: z.boolean().default(true),
|
||||
});
|
||||
const AuthorizedKeysRevisionSchema = z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/, "Invalid authorized_keys revision");
|
||||
const ServerSystemUserSshKeyCreateSchema = z.object({
|
||||
publicKey: z.string().trim().min(32).max(16_384),
|
||||
label: z.string().trim().min(1).max(64).optional(),
|
||||
expectedRevision: AuthorizedKeysRevisionSchema,
|
||||
});
|
||||
const ServerSystemUserSshKeyRevokeSchema = z.object({
|
||||
expectedRevision: AuthorizedKeysRevisionSchema,
|
||||
});
|
||||
const SshKeyFingerprintSchema = z
|
||||
.string()
|
||||
.regex(/^SHA256:[A-Za-z0-9+/]{43}$/, "Invalid SSH key fingerprint");
|
||||
const ServerSystemUserUpdateSchema = z.object({
|
||||
groups: z
|
||||
.array(LinuxAccountNameSchema)
|
||||
@@ -158,6 +204,46 @@ const ServerSystemUserUpdateSchema = z.object({
|
||||
expectedShell: z.string().trim().min(1).max(128),
|
||||
acknowledgePrivilegedGroups: z.boolean().default(false),
|
||||
});
|
||||
const ServerSystemUserDeleteSchema = z.object({
|
||||
expectedUid: z.number().int().nonnegative(),
|
||||
expectedGid: z.number().int().nonnegative(),
|
||||
expectedHome: z.string().min(1).max(4096),
|
||||
expectedShell: z.string().min(1).max(4096),
|
||||
confirmation: LinuxAccountNameSchema,
|
||||
removeHome: z.boolean().default(false),
|
||||
});
|
||||
const ServerSystemGroupDeleteSchema = z.object({
|
||||
expectedGid: z.number().int().nonnegative(),
|
||||
expectedMembers: z
|
||||
.array(z.string().min(1).max(256).regex(/^[^,\r\n|]+$/))
|
||||
.max(1024)
|
||||
.refine((members) => new Set(members).size === members.length, {
|
||||
message: "Expected group members must be unique",
|
||||
}),
|
||||
expectedPrimaryUsers: z
|
||||
.array(z.string().min(1).max(256).regex(/^[^,\r\n|]+$/))
|
||||
.max(1024)
|
||||
.refine((members) => new Set(members).size === members.length, {
|
||||
message: "Expected primary users must be unique",
|
||||
}),
|
||||
confirmation: LinuxAccountNameSchema,
|
||||
});
|
||||
const ServerSshAccessUpdateSchema = z.object({
|
||||
expectedRevision: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/, "Invalid SSH configuration revision"),
|
||||
pubkeyAuthentication: z.boolean(),
|
||||
passwordAuthentication: z.boolean(),
|
||||
permitRootLogin: z.enum(["no", "prohibit-password"]),
|
||||
permitEmptyPasswords: z.literal(false),
|
||||
temporaryMinutes: z.union([
|
||||
z.literal(15),
|
||||
z.literal(30),
|
||||
z.literal(60),
|
||||
z.literal(240),
|
||||
z.null(),
|
||||
]),
|
||||
});
|
||||
const ServerSystemGroupCreateSchema = z
|
||||
.object({
|
||||
groupName: LinuxAccountNameSchema,
|
||||
@@ -665,6 +751,84 @@ export async function serverRoutes(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
"/:id/ssh-access",
|
||||
{ preHandler: serverSystemAccountWriteAccess },
|
||||
async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = ServerSshAccessUpdateSchema.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",
|
||||
});
|
||||
}
|
||||
try {
|
||||
const result = await ssh.applyServerSshAccessPolicy(server, body.data);
|
||||
const message = body.data.temporaryMinutes
|
||||
? `Temporary SSH password access enabled for ${body.data.temporaryMinutes} minutes on ${server.name}`
|
||||
: `SSH access policy updated on ${server.name}`;
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SSH_ACCESS_UPDATE",
|
||||
category: "SERVER",
|
||||
level:
|
||||
body.data.passwordAuthentication ||
|
||||
body.data.permitRootLogin !== "no"
|
||||
? "WARNING"
|
||||
: "SUCCESS",
|
||||
message,
|
||||
meta: {
|
||||
pubkeyAuthentication: body.data.pubkeyAuthentication,
|
||||
passwordAuthentication: body.data.passwordAuthentication,
|
||||
permitRootLogin: body.data.permitRootLogin,
|
||||
permitEmptyPasswords: false,
|
||||
temporaryMinutes: body.data.temporaryMinutes,
|
||||
},
|
||||
});
|
||||
return reply.send({ success: true, data: result, message });
|
||||
} catch (err: unknown) {
|
||||
const isConflict = err instanceof SystemAccountConflictError;
|
||||
const message =
|
||||
err instanceof SystemAccountInputError
|
||||
? err.message
|
||||
: "The SSH access policy could not be applied safely. The previous managed policy was restored when possible.";
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SSH_ACCESS_UPDATE_FAILED",
|
||||
category: "SERVER",
|
||||
level: isConflict ? "WARNING" : "ERROR",
|
||||
message: `Failed to update SSH access policy on ${server.name}`,
|
||||
meta: {
|
||||
pubkeyAuthentication: body.data.pubkeyAuthentication,
|
||||
passwordAuthentication: body.data.passwordAuthentication,
|
||||
permitRootLogin: body.data.permitRootLogin,
|
||||
temporaryMinutes: body.data.temporaryMinutes,
|
||||
conflict: isConflict,
|
||||
},
|
||||
});
|
||||
return reply
|
||||
.status(isConflict ? 409 : 400)
|
||||
.send({ success: false, error: message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/:id/system-users",
|
||||
{ preHandler: serverSystemAccountWriteAccess },
|
||||
@@ -705,6 +869,12 @@ export async function serverRoutes(app: FastifyInstance) {
|
||||
username: body.data.username,
|
||||
groups: body.data.groups,
|
||||
privilegedGroups,
|
||||
remoteLogin: body.data.remoteLogin,
|
||||
credentialType: body.data.credential.type,
|
||||
requirePasswordChange:
|
||||
body.data.credential.type === "password"
|
||||
? body.data.credential.requireChange
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
return reply.status(201).send({ success: true, message });
|
||||
@@ -721,7 +891,12 @@ export async function serverRoutes(app: FastifyInstance) {
|
||||
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 },
|
||||
meta: {
|
||||
username: body.data.username,
|
||||
groups: body.data.groups,
|
||||
remoteLogin: body.data.remoteLogin,
|
||||
credentialType: body.data.credential.type,
|
||||
},
|
||||
});
|
||||
return reply.status(400).send({ success: false, error: message });
|
||||
}
|
||||
@@ -787,6 +962,85 @@ export async function serverRoutes(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/:id/system-groups/:groupName",
|
||||
{ preHandler: serverSystemAccountWriteAccess },
|
||||
async (req, reply) => {
|
||||
const { id, groupName } = req.params as {
|
||||
id: string;
|
||||
groupName: string;
|
||||
};
|
||||
const parsedGroupName = LinuxAccountNameSchema.safeParse(groupName);
|
||||
const body = ServerSystemGroupDeleteSchema.safeParse(req.body ?? {});
|
||||
if (!parsedGroupName.success) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: parsedGroupName.error.flatten(),
|
||||
});
|
||||
}
|
||||
if (!body.success) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: body.error.flatten(),
|
||||
});
|
||||
}
|
||||
if (body.data.confirmation !== parsedGroupName.data) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: "Type the exact group name to confirm deletion",
|
||||
});
|
||||
}
|
||||
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.deleteServerSystemGroup(server, {
|
||||
groupName: parsedGroupName.data,
|
||||
...body.data,
|
||||
});
|
||||
const message = `System group ${result.groupName} deleted from ${server.name}`;
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SYSTEM_GROUP_DELETE",
|
||||
category: "SERVER",
|
||||
level: "WARNING",
|
||||
message,
|
||||
meta: { groupName: result.groupName, gid: result.gid },
|
||||
});
|
||||
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 delete the system group. Verify sudo access and group usage, then try again.";
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SYSTEM_GROUP_DELETE_FAILED",
|
||||
category: "SERVER",
|
||||
level: isConflict ? "WARNING" : "ERROR",
|
||||
message: `Failed to delete system group ${parsedGroupName.data} from ${server.name}`,
|
||||
meta: { groupName: parsedGroupName.data, conflict: isConflict },
|
||||
});
|
||||
return reply
|
||||
.status(isConflict ? 409 : 400)
|
||||
.send({ success: false, error: message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/:id/system-users/:username",
|
||||
{ preHandler: serverSystemAccountWriteAccess },
|
||||
@@ -876,6 +1130,364 @@ export async function serverRoutes(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/: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 = ServerSystemUserDeleteSchema.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(),
|
||||
});
|
||||
}
|
||||
if (body.data.confirmation !== parsedUsername.data) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: "Type the exact username to confirm deletion",
|
||||
});
|
||||
}
|
||||
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.deleteServerSystemUser(server, {
|
||||
username: parsedUsername.data,
|
||||
...body.data,
|
||||
});
|
||||
const message = `System user ${result.username} deleted from ${server.name}`;
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SYSTEM_USER_DELETE",
|
||||
category: "SERVER",
|
||||
level: "WARNING",
|
||||
message,
|
||||
meta: {
|
||||
username: result.username,
|
||||
uid: result.uid,
|
||||
home: result.home,
|
||||
homeRemoved: result.homeRemoved,
|
||||
},
|
||||
});
|
||||
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 delete the system user. Verify sudo access and active processes, then try again.";
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SYSTEM_USER_DELETE_FAILED",
|
||||
category: "SERVER",
|
||||
level: isConflict ? "WARNING" : "ERROR",
|
||||
message: `Failed to delete system user ${parsedUsername.data} from ${server.name}`,
|
||||
meta: {
|
||||
username: parsedUsername.data,
|
||||
removeHome: body.data.removeHome,
|
||||
conflict: isConflict,
|
||||
},
|
||||
});
|
||||
return reply
|
||||
.status(isConflict ? 409 : 400)
|
||||
.send({ success: false, error: message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
"/:id/system-users/:username/password",
|
||||
{ preHandler: serverSystemAccountWriteAccess },
|
||||
async (req, reply) => {
|
||||
const { id, username } = req.params as { id: string; username: string };
|
||||
const parsedUsername = LinuxAccountNameSchema.safeParse(username);
|
||||
const body = ServerSystemUserPasswordSchema.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" });
|
||||
}
|
||||
try {
|
||||
const result = await ssh.setServerSystemUserPassword(server, {
|
||||
username: parsedUsername.data,
|
||||
...body.data,
|
||||
});
|
||||
const message = `Password updated for system user ${result.username} on ${server.name}`;
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SYSTEM_USER_PASSWORD_SET",
|
||||
category: "SERVER",
|
||||
level: "WARNING",
|
||||
message,
|
||||
meta: {
|
||||
username: result.username,
|
||||
requireChange: body.data.requireChange,
|
||||
},
|
||||
});
|
||||
return reply.send({ success: true, data: result, message });
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof SystemAccountInputError
|
||||
? err.message
|
||||
: "The host could not update this password. Verify sudo and password-management support, then try again.";
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SYSTEM_USER_PASSWORD_SET_FAILED",
|
||||
category: "SERVER",
|
||||
level: "ERROR",
|
||||
message: `Failed to update password for ${parsedUsername.data} on ${server.name}`,
|
||||
meta: { username: parsedUsername.data },
|
||||
});
|
||||
return reply.status(400).send({ success: false, error: message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/:id/system-users/:username/password",
|
||||
{ preHandler: serverSystemAccountWriteAccess },
|
||||
async (req, reply) => {
|
||||
const { id, username } = req.params as { id: string; username: string };
|
||||
const parsedUsername = LinuxAccountNameSchema.safeParse(username);
|
||||
if (!parsedUsername.success) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: parsedUsername.error.flatten(),
|
||||
});
|
||||
}
|
||||
const server = await getAccessibleServer(
|
||||
req.userId,
|
||||
id,
|
||||
req.organizationId,
|
||||
);
|
||||
if (!server) {
|
||||
return reply.status(403).send({ success: false, error: "Forbidden" });
|
||||
}
|
||||
try {
|
||||
const result = await ssh.disableServerSystemUserPassword(
|
||||
server,
|
||||
parsedUsername.data,
|
||||
);
|
||||
const message = `Password login disabled for system user ${result.username} on ${server.name}`;
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SYSTEM_USER_PASSWORD_DISABLE",
|
||||
category: "SERVER",
|
||||
level: "WARNING",
|
||||
message,
|
||||
meta: { username: result.username },
|
||||
});
|
||||
return reply.send({ success: true, data: result, message });
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof SystemAccountInputError
|
||||
? err.message
|
||||
: "The host could not disable this password. Verify sudo access, then try again.";
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SYSTEM_USER_PASSWORD_DISABLE_FAILED",
|
||||
category: "SERVER",
|
||||
level: "ERROR",
|
||||
message: `Failed to disable password for ${parsedUsername.data} on ${server.name}`,
|
||||
meta: { username: parsedUsername.data },
|
||||
});
|
||||
return reply.status(400).send({ success: false, error: message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/:id/system-users/:username/ssh-keys",
|
||||
{ preHandler: serverSystemAccountWriteAccess },
|
||||
async (req, reply) => {
|
||||
const { id, username } = req.params as { id: string; username: string };
|
||||
const parsedUsername = LinuxAccountNameSchema.safeParse(username);
|
||||
const body = ServerSystemUserSshKeyCreateSchema.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" });
|
||||
}
|
||||
try {
|
||||
const result = await ssh.addServerSystemUserSshKey(server, {
|
||||
username: parsedUsername.data,
|
||||
...body.data,
|
||||
});
|
||||
const addedKey = result.sshKeys?.at(-1);
|
||||
const message = `SSH public key added for system user ${result.username} on ${server.name}`;
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SYSTEM_USER_SSH_KEY_ADD",
|
||||
category: "SERVER",
|
||||
level: "WARNING",
|
||||
message,
|
||||
meta: {
|
||||
username: result.username,
|
||||
fingerprint: addedKey?.fingerprint,
|
||||
keyType: addedKey?.keyType,
|
||||
label: addedKey?.comment,
|
||||
},
|
||||
});
|
||||
return reply.status(201).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 add this SSH public key. Verify sudo access and home-directory permissions, then try again.";
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SYSTEM_USER_SSH_KEY_ADD_FAILED",
|
||||
category: "SERVER",
|
||||
level: isConflict ? "WARNING" : "ERROR",
|
||||
message: `Failed to add SSH key for ${parsedUsername.data} on ${server.name}`,
|
||||
meta: { username: parsedUsername.data, conflict: isConflict },
|
||||
});
|
||||
return reply
|
||||
.status(isConflict ? 409 : 400)
|
||||
.send({ success: false, error: message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/:id/system-users/:username/ssh-keys",
|
||||
{ preHandler: serverSystemAccountWriteAccess },
|
||||
async (req, reply) => {
|
||||
const { id, username } = req.params as { id: string; username: string };
|
||||
const parsedUsername = LinuxAccountNameSchema.safeParse(username);
|
||||
const rawBody = req.body as Record<string, unknown> | undefined;
|
||||
const fingerprint = SshKeyFingerprintSchema.safeParse(
|
||||
rawBody?.fingerprint,
|
||||
);
|
||||
const body = ServerSystemUserSshKeyRevokeSchema.safeParse(req.body ?? {});
|
||||
if (!parsedUsername.success || !fingerprint.success || !body.success) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: "Invalid username, fingerprint, or authorized_keys revision",
|
||||
});
|
||||
}
|
||||
const server = await getAccessibleServer(
|
||||
req.userId,
|
||||
id,
|
||||
req.organizationId,
|
||||
);
|
||||
if (!server) {
|
||||
return reply.status(403).send({ success: false, error: "Forbidden" });
|
||||
}
|
||||
try {
|
||||
const result = await ssh.revokeServerSystemUserSshKey(server, {
|
||||
username: parsedUsername.data,
|
||||
fingerprint: fingerprint.data,
|
||||
expectedRevision: body.data.expectedRevision,
|
||||
});
|
||||
const message = `SSH public key revoked for system user ${result.username} on ${server.name}`;
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SYSTEM_USER_SSH_KEY_REVOKE",
|
||||
category: "SERVER",
|
||||
level: "WARNING",
|
||||
message,
|
||||
meta: {
|
||||
username: result.username,
|
||||
fingerprint: fingerprint.data,
|
||||
},
|
||||
});
|
||||
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 revoke this SSH public key. Verify sudo access and home-directory permissions, then try again.";
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SYSTEM_USER_SSH_KEY_REVOKE_FAILED",
|
||||
category: "SERVER",
|
||||
level: isConflict ? "WARNING" : "ERROR",
|
||||
message: `Failed to revoke SSH key for ${parsedUsername.data} on ${server.name}`,
|
||||
meta: {
|
||||
username: parsedUsername.data,
|
||||
fingerprint: fingerprint.data,
|
||||
conflict: isConflict,
|
||||
},
|
||||
});
|
||||
return reply
|
||||
.status(isConflict ? 409 : 400)
|
||||
.send({ success: false, error: message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/:id/reset",
|
||||
{ preHandler: serverWriteAccess },
|
||||
|
||||
@@ -20,6 +20,7 @@ const commandRunContext = new AsyncLocalStorage<{
|
||||
type ExecOptions = {
|
||||
timeoutMs?: number;
|
||||
queueTimeoutMs?: number;
|
||||
stdin?: string;
|
||||
};
|
||||
|
||||
const REMOTE_JOB_DIR = "/tmp/doktainer-process-jobs";
|
||||
@@ -278,7 +279,7 @@ export async function exec(
|
||||
options: ExecOptions = {},
|
||||
): Promise<SSHExecCommandResponse> {
|
||||
const logSink = commandLogSink.getStore();
|
||||
if (logSink) {
|
||||
if (logSink && options.stdin === undefined) {
|
||||
let stdout = "";
|
||||
let code = 0;
|
||||
|
||||
@@ -309,7 +310,11 @@ export async function exec(
|
||||
try {
|
||||
const ssh = await getConnection(server);
|
||||
return await withCommandTimeout(
|
||||
() => ssh.execCommand(command),
|
||||
() =>
|
||||
ssh.execCommand(
|
||||
command,
|
||||
options.stdin === undefined ? undefined : { stdin: options.stdin },
|
||||
),
|
||||
options.timeoutMs,
|
||||
() => {
|
||||
logCommandTimeout(server, command, options.timeoutMs!);
|
||||
@@ -327,7 +332,11 @@ export async function exec(
|
||||
closeConnection(server.id);
|
||||
const ssh = await getConnection(server);
|
||||
return withCommandTimeout(
|
||||
() => ssh.execCommand(command),
|
||||
() =>
|
||||
ssh.execCommand(
|
||||
command,
|
||||
options.stdin === undefined ? undefined : { stdin: options.stdin },
|
||||
),
|
||||
options.timeoutMs,
|
||||
() => {
|
||||
logCommandTimeout(server, command, options.timeoutMs!);
|
||||
|
||||
@@ -3,6 +3,7 @@ export * from "./commands";
|
||||
export * from "./metrics";
|
||||
export * from "./platform";
|
||||
export * from "./server-config";
|
||||
export * from "./ssh-access";
|
||||
export * from "./web-stack";
|
||||
export * from "./docker-engine";
|
||||
export * from "./docker-networks";
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import {
|
||||
SystemAccountConflictError,
|
||||
SystemAccountInputError,
|
||||
} from "./system-account-security";
|
||||
|
||||
export type SshPermitRootLogin = "no" | "prohibit-password";
|
||||
|
||||
export interface SshAuthenticationPolicy {
|
||||
pubkeyAuthentication: boolean;
|
||||
passwordAuthentication: boolean;
|
||||
permitRootLogin: SshPermitRootLogin;
|
||||
permitEmptyPasswords: false;
|
||||
}
|
||||
|
||||
export interface EffectiveSshAuthenticationPolicy {
|
||||
pubkeyAuthentication: boolean;
|
||||
passwordAuthentication: boolean;
|
||||
permitRootLogin: SshPermitRootLogin | "yes";
|
||||
permitEmptyPasswords: boolean;
|
||||
}
|
||||
|
||||
export function getSshConfigurationRevision(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
export function validateSshAccessPolicy(options: {
|
||||
serverUsername: string;
|
||||
serverAuthType: "PASSWORD" | "SSH_KEY";
|
||||
currentRevision: string;
|
||||
expectedRevision: string;
|
||||
currentPasswordAuthentication: boolean;
|
||||
desired: SshAuthenticationPolicy;
|
||||
temporaryMinutes: 15 | 30 | 60 | 240 | null;
|
||||
}): void {
|
||||
if (options.currentRevision !== options.expectedRevision) {
|
||||
throw new SystemAccountConflictError(
|
||||
"SSH configuration changed on the host after the snapshot was loaded. Refresh Server Config before applying changes.",
|
||||
);
|
||||
}
|
||||
if (options.desired.permitEmptyPasswords !== false) {
|
||||
throw new SystemAccountInputError(
|
||||
"PermitEmptyPasswords must remain disabled",
|
||||
);
|
||||
}
|
||||
if (
|
||||
!options.desired.pubkeyAuthentication &&
|
||||
!options.desired.passwordAuthentication
|
||||
) {
|
||||
throw new SystemAccountInputError(
|
||||
"At least one SSH authentication method must remain enabled",
|
||||
);
|
||||
}
|
||||
if (
|
||||
options.serverAuthType === "PASSWORD" &&
|
||||
!options.desired.passwordAuthentication
|
||||
) {
|
||||
throw new SystemAccountInputError(
|
||||
"PasswordAuthentication cannot be disabled while Doktainer uses a password for this server",
|
||||
);
|
||||
}
|
||||
if (
|
||||
options.serverAuthType === "SSH_KEY" &&
|
||||
!options.desired.pubkeyAuthentication
|
||||
) {
|
||||
throw new SystemAccountInputError(
|
||||
"PubkeyAuthentication cannot be disabled while Doktainer uses an SSH key for this server",
|
||||
);
|
||||
}
|
||||
if (options.serverUsername === "root") {
|
||||
if (options.desired.permitRootLogin === "no") {
|
||||
throw new SystemAccountInputError(
|
||||
"PermitRootLogin cannot be disabled while Doktainer still connects as root",
|
||||
);
|
||||
}
|
||||
if (options.serverAuthType === "PASSWORD") {
|
||||
throw new SystemAccountInputError(
|
||||
"A root password connection cannot be preserved by the managed safe policy. Change the Doktainer server account before applying SSH Access settings.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (options.temporaryMinutes != null) {
|
||||
if (!options.desired.passwordAuthentication) {
|
||||
throw new SystemAccountInputError(
|
||||
"Temporary password access requires PasswordAuthentication",
|
||||
);
|
||||
}
|
||||
if (options.serverAuthType !== "SSH_KEY") {
|
||||
throw new SystemAccountInputError(
|
||||
"Temporary password access requires Doktainer to use an SSH key so automatic rollback cannot lock out the active connection",
|
||||
);
|
||||
}
|
||||
if (options.currentPasswordAuthentication) {
|
||||
throw new SystemAccountInputError(
|
||||
"Password authentication is already enabled. Select a permanent policy instead of scheduling a temporary rollback.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseEffectiveSshPolicy(
|
||||
output: string,
|
||||
): EffectiveSshAuthenticationPolicy | null {
|
||||
const values = new Map<string, string>();
|
||||
for (const rawLine of output.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
const separator = line.indexOf(" ");
|
||||
if (separator < 1) continue;
|
||||
values.set(line.slice(0, separator).toLowerCase(), line.slice(separator + 1).trim());
|
||||
}
|
||||
const pubkey = values.get("pubkeyauthentication");
|
||||
const password = values.get("passwordauthentication");
|
||||
const root = values.get("permitrootlogin");
|
||||
const empty = values.get("permitemptypasswords");
|
||||
if (
|
||||
!["yes", "no"].includes(pubkey ?? "") ||
|
||||
!["yes", "no"].includes(password ?? "") ||
|
||||
!["yes", "no"].includes(empty ?? "") ||
|
||||
!root
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const permitRootLogin =
|
||||
root === "no"
|
||||
? "no"
|
||||
: root === "prohibit-password" || root === "without-password"
|
||||
? "prohibit-password"
|
||||
: root === "yes"
|
||||
? "yes"
|
||||
: null;
|
||||
if (!permitRootLogin) return null;
|
||||
return {
|
||||
pubkeyAuthentication: pubkey === "yes",
|
||||
passwordAuthentication: password === "yes",
|
||||
permitRootLogin,
|
||||
permitEmptyPasswords: empty === "yes",
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export const LINUX_ACCOUNT_NAME_PATTERN = /^[a-z_][a-z0-9_-]{0,31}$/;
|
||||
|
||||
export const PRIVILEGED_SYSTEM_GROUPS = new Set([
|
||||
@@ -7,6 +9,14 @@ export const PRIVILEGED_SYSTEM_GROUPS = new Set([
|
||||
"wheel",
|
||||
]);
|
||||
|
||||
export const PROTECTED_SYSTEM_GROUPS = new Set([
|
||||
...PRIVILEGED_SYSTEM_GROUPS,
|
||||
"adm",
|
||||
"systemd-journal",
|
||||
"ssh",
|
||||
"sshd",
|
||||
]);
|
||||
|
||||
export const ALLOWED_SYSTEM_USER_SHELLS = new Set([
|
||||
"/bin/bash",
|
||||
"/bin/sh",
|
||||
@@ -17,6 +27,29 @@ export const ALLOWED_SYSTEM_USER_SHELLS = new Set([
|
||||
"/sbin/nologin",
|
||||
]);
|
||||
|
||||
export const SUPPORTED_OPENSSH_PUBLIC_KEY_TYPES = new Set([
|
||||
"ssh-ed25519",
|
||||
"ssh-rsa",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"sk-ssh-ed25519@openssh.com",
|
||||
"sk-ecdsa-sha2-nistp256@openssh.com",
|
||||
]);
|
||||
|
||||
export type SystemUserPasswordStatus =
|
||||
| "set"
|
||||
| "locked"
|
||||
| "not-set"
|
||||
| "unknown";
|
||||
|
||||
export interface ParsedAuthorizedKey {
|
||||
keyType: string;
|
||||
fingerprint: string;
|
||||
comment: string;
|
||||
line: string;
|
||||
}
|
||||
|
||||
export class SystemAccountInputError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
@@ -52,6 +85,192 @@ export function assertAllowedSystemUserShell(shell: string): void {
|
||||
}
|
||||
}
|
||||
|
||||
export function assertSystemUserPassword(
|
||||
username: string,
|
||||
password: string,
|
||||
): void {
|
||||
if (password.length < 12 || password.length > 128) {
|
||||
throw new SystemAccountInputError(
|
||||
"Password must contain between 12 and 128 characters",
|
||||
);
|
||||
}
|
||||
if (/[\u0000-\u001f\u007f]/.test(password)) {
|
||||
throw new SystemAccountInputError(
|
||||
"Password cannot contain control characters or line breaks",
|
||||
);
|
||||
}
|
||||
if (password.toLowerCase().includes(username.toLowerCase())) {
|
||||
throw new SystemAccountInputError(
|
||||
"Password cannot contain the username",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function decodePublicKeyBlob(encoded: string): Buffer {
|
||||
if (!/^[A-Za-z0-9+/]+={0,3}$/.test(encoded)) {
|
||||
throw new SystemAccountInputError("SSH public key payload is not valid Base64");
|
||||
}
|
||||
const decoded = Buffer.from(encoded, "base64");
|
||||
if (decoded.length < 16 || decoded.length > 16_384) {
|
||||
throw new SystemAccountInputError("SSH public key payload has an invalid size");
|
||||
}
|
||||
const normalizedInput = encoded.replace(/=+$/, "");
|
||||
if (decoded.toString("base64").replace(/=+$/, "") !== normalizedInput) {
|
||||
throw new SystemAccountInputError("SSH public key payload is malformed");
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function assertPublicKeyBlobType(decoded: Buffer, expectedType: string): void {
|
||||
if (decoded.length < 8) {
|
||||
throw new SystemAccountInputError("SSH public key payload is malformed");
|
||||
}
|
||||
const typeLength = decoded.readUInt32BE(0);
|
||||
if (typeLength < 1 || typeLength > 128 || 4 + typeLength > decoded.length) {
|
||||
throw new SystemAccountInputError("SSH public key payload is malformed");
|
||||
}
|
||||
const embeddedType = decoded.subarray(4, 4 + typeLength).toString("utf8");
|
||||
if (embeddedType !== expectedType) {
|
||||
throw new SystemAccountInputError(
|
||||
"SSH public key type does not match its encoded payload",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function getOpenSshPublicKeyFingerprint(encoded: string): string {
|
||||
const decoded = decodePublicKeyBlob(encoded);
|
||||
return `SHA256:${createHash("sha256")
|
||||
.update(decoded)
|
||||
.digest("base64")
|
||||
.replace(/=+$/, "")}`;
|
||||
}
|
||||
|
||||
export function normalizeOpenSshPublicKey(
|
||||
value: string,
|
||||
label?: string,
|
||||
): ParsedAuthorizedKey {
|
||||
const normalized = value.trim().replace(/[\t ]+/g, " ");
|
||||
if (
|
||||
!normalized ||
|
||||
normalized.length > 16_384 ||
|
||||
normalized.includes("\n") ||
|
||||
normalized.includes("\r")
|
||||
) {
|
||||
throw new SystemAccountInputError(
|
||||
"SSH public key must contain exactly one supported OpenSSH key",
|
||||
);
|
||||
}
|
||||
const [keyType, encoded, ...commentParts] = normalized.split(" ");
|
||||
if (!SUPPORTED_OPENSSH_PUBLIC_KEY_TYPES.has(keyType)) {
|
||||
throw new SystemAccountInputError("SSH public key type is not supported");
|
||||
}
|
||||
if (!encoded) {
|
||||
throw new SystemAccountInputError("SSH public key payload is missing");
|
||||
}
|
||||
const normalizedLabel = label?.trim();
|
||||
if (
|
||||
normalizedLabel &&
|
||||
(normalizedLabel.length > 64 || /[\u0000-\u001f\u007f]/.test(normalizedLabel))
|
||||
) {
|
||||
throw new SystemAccountInputError(
|
||||
"SSH key label must be at most 64 characters without control characters",
|
||||
);
|
||||
}
|
||||
const comment = (normalizedLabel || commentParts.join(" ").slice(0, 256))
|
||||
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
||||
.trim();
|
||||
const decoded = decodePublicKeyBlob(encoded);
|
||||
assertPublicKeyBlobType(decoded, keyType);
|
||||
const fingerprint = getOpenSshPublicKeyFingerprint(encoded);
|
||||
return {
|
||||
keyType,
|
||||
fingerprint,
|
||||
comment,
|
||||
line: [keyType, encoded, comment].filter(Boolean).join(" "),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseAuthorizedKeyLine(
|
||||
value: string,
|
||||
): ParsedAuthorizedKey | null {
|
||||
const line = value.trim();
|
||||
if (!line || line.startsWith("#")) return null;
|
||||
const parts = line.split(/\s+/);
|
||||
const keyTypeIndex = parts.findIndex((part) =>
|
||||
SUPPORTED_OPENSSH_PUBLIC_KEY_TYPES.has(part),
|
||||
);
|
||||
if (keyTypeIndex < 0 || !parts[keyTypeIndex + 1]) return null;
|
||||
try {
|
||||
const decoded = decodePublicKeyBlob(parts[keyTypeIndex + 1]);
|
||||
assertPublicKeyBlobType(decoded, parts[keyTypeIndex]);
|
||||
return {
|
||||
keyType: parts[keyTypeIndex],
|
||||
fingerprint: getOpenSshPublicKeyFingerprint(parts[keyTypeIndex + 1]),
|
||||
comment: parts
|
||||
.slice(keyTypeIndex + 2)
|
||||
.join(" ")
|
||||
.slice(0, 256)
|
||||
.replace(/[\u0000-\u001f\u007f]/g, " ")
|
||||
.trim(),
|
||||
line,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function getAuthorizedKeysRevision(content: string): string {
|
||||
return createHash("sha256").update(content).digest("hex");
|
||||
}
|
||||
|
||||
export function parseSystemUserPasswordStatus(
|
||||
status: string | undefined,
|
||||
): SystemUserPasswordStatus {
|
||||
switch (status?.toUpperCase()) {
|
||||
case "P":
|
||||
case "PS":
|
||||
return "set";
|
||||
case "L":
|
||||
case "LK":
|
||||
return "locked";
|
||||
case "NP":
|
||||
case "NL":
|
||||
return "not-set";
|
||||
default:
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function assertCredentialMutationAllowed(options: {
|
||||
username: string;
|
||||
uid: number;
|
||||
serverUsername: string;
|
||||
serverAuthType: "PASSWORD" | "SSH_KEY";
|
||||
action: "password-set" | "password-disable" | "key-add" | "key-revoke";
|
||||
}): void {
|
||||
if (options.uid === 0 || options.username === "root") {
|
||||
throw new SystemAccountInputError("The root account is protected");
|
||||
}
|
||||
if (options.username !== options.serverUsername) return;
|
||||
if (
|
||||
options.serverAuthType === "PASSWORD" &&
|
||||
(options.action === "password-set" ||
|
||||
options.action === "password-disable")
|
||||
) {
|
||||
throw new SystemAccountInputError(
|
||||
"The password used by the active Doktainer SSH connection cannot be changed or disabled here",
|
||||
);
|
||||
}
|
||||
if (
|
||||
options.serverAuthType === "SSH_KEY" &&
|
||||
options.action === "key-revoke"
|
||||
) {
|
||||
throw new SystemAccountInputError(
|
||||
"SSH keys cannot be revoked from the active Doktainer SSH login without reconnect verification",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function haveSameStringMembers(
|
||||
left: string[],
|
||||
right: string[],
|
||||
@@ -64,6 +283,129 @@ export function haveSameStringMembers(
|
||||
);
|
||||
}
|
||||
|
||||
export interface SystemUserDeletePolicyResult {
|
||||
username: string;
|
||||
uid: number;
|
||||
home: string;
|
||||
removeHome: boolean;
|
||||
}
|
||||
|
||||
export function validateSystemUserDeletePolicy(options: {
|
||||
username: string;
|
||||
uid: number;
|
||||
gid: number;
|
||||
home: string;
|
||||
shell: string;
|
||||
uidMin: number;
|
||||
serverUsername: string;
|
||||
expectedUid: number;
|
||||
expectedGid: number;
|
||||
expectedHome: string;
|
||||
expectedShell: string;
|
||||
confirmation: string;
|
||||
activeProcessCount: number;
|
||||
removeHome: boolean;
|
||||
}): SystemUserDeletePolicyResult {
|
||||
if (options.confirmation !== options.username) {
|
||||
throw new SystemAccountInputError(
|
||||
"Type the exact username to confirm deletion",
|
||||
);
|
||||
}
|
||||
if (options.uid === 0 || options.username === "root") {
|
||||
throw new SystemAccountInputError("The root account is protected");
|
||||
}
|
||||
if (options.username === options.serverUsername) {
|
||||
throw new SystemAccountInputError(
|
||||
"The account used by the active Doktainer SSH connection cannot be deleted",
|
||||
);
|
||||
}
|
||||
if (!Number.isInteger(options.uidMin) || options.uidMin < 1) {
|
||||
throw new SystemAccountInputError("The host UID_MIN policy is unavailable");
|
||||
}
|
||||
if (options.uid < options.uidMin) {
|
||||
throw new SystemAccountInputError(
|
||||
`System users with UID below ${options.uidMin} cannot be deleted`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
options.uid !== options.expectedUid ||
|
||||
options.gid !== options.expectedGid ||
|
||||
options.home !== options.expectedHome ||
|
||||
options.shell !== options.expectedShell
|
||||
) {
|
||||
throw new SystemAccountConflictError(
|
||||
"This user changed on the host after the snapshot was loaded. Refresh Server Config before deleting it.",
|
||||
);
|
||||
}
|
||||
if (options.activeProcessCount > 0) {
|
||||
throw new SystemAccountInputError(
|
||||
`This user still owns ${options.activeProcessCount} active process${options.activeProcessCount === 1 ? "" : "es"}. Stop them before deleting the account.`,
|
||||
);
|
||||
}
|
||||
if (options.removeHome && options.home !== `/home/${options.username}`) {
|
||||
throw new SystemAccountInputError(
|
||||
"Automatic home removal is only allowed for the account's standard /home/<username> directory",
|
||||
);
|
||||
}
|
||||
return {
|
||||
username: options.username,
|
||||
uid: options.uid,
|
||||
home: options.home,
|
||||
removeHome: options.removeHome,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateSystemGroupDeletePolicy(options: {
|
||||
groupName: string;
|
||||
gid: number;
|
||||
gidMin: number;
|
||||
members: string[];
|
||||
primaryUsers: string[];
|
||||
expectedGid: number;
|
||||
expectedMembers: string[];
|
||||
expectedPrimaryUsers: string[];
|
||||
confirmation: string;
|
||||
}): void {
|
||||
if (options.confirmation !== options.groupName) {
|
||||
throw new SystemAccountInputError(
|
||||
"Type the exact group name to confirm deletion",
|
||||
);
|
||||
}
|
||||
if (!Number.isInteger(options.gidMin) || options.gidMin < 1) {
|
||||
throw new SystemAccountInputError("The host GID_MIN policy is unavailable");
|
||||
}
|
||||
if (
|
||||
options.gid < options.gidMin ||
|
||||
PROTECTED_SYSTEM_GROUPS.has(options.groupName)
|
||||
) {
|
||||
throw new SystemAccountInputError(
|
||||
"System and privileged groups cannot be deleted",
|
||||
);
|
||||
}
|
||||
if (
|
||||
options.gid !== options.expectedGid ||
|
||||
!haveSameStringMembers(options.members, options.expectedMembers) ||
|
||||
!haveSameStringMembers(
|
||||
options.primaryUsers,
|
||||
options.expectedPrimaryUsers,
|
||||
)
|
||||
) {
|
||||
throw new SystemAccountConflictError(
|
||||
"This group changed on the host after the snapshot was loaded. Refresh Server Config before deleting it.",
|
||||
);
|
||||
}
|
||||
if (options.primaryUsers.length > 0) {
|
||||
throw new SystemAccountInputError(
|
||||
`This group is primary for: ${options.primaryUsers.join(", ")}`,
|
||||
);
|
||||
}
|
||||
if (options.members.length > 0) {
|
||||
throw new SystemAccountInputError(
|
||||
`Remove group members first: ${options.members.join(", ")}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export interface SystemUserUpdatePolicyResult {
|
||||
isSshUser: boolean;
|
||||
addedGroups: string[];
|
||||
|
||||
@@ -14,6 +14,10 @@ import {
|
||||
getInspectableHostServices,
|
||||
inspectWebServerCapability,
|
||||
} from "./web-stack";
|
||||
import {
|
||||
getServerSshAccessStatus,
|
||||
type ServerSshAccessStatus,
|
||||
} from "./ssh-access";
|
||||
import { escapeShellArg } from "./internal/shell";
|
||||
import {
|
||||
nonInteractivePrivilegedCommand,
|
||||
@@ -21,12 +25,23 @@ import {
|
||||
} from "./internal/privilege";
|
||||
import {
|
||||
assertLinuxAccountName,
|
||||
assertCredentialMutationAllowed,
|
||||
assertSystemUserPassword,
|
||||
getAuthorizedKeysRevision,
|
||||
normalizeOpenSshPublicKey,
|
||||
parseAuthorizedKeyLine,
|
||||
parseSystemUserPasswordStatus,
|
||||
type SystemUserPasswordStatus,
|
||||
SystemAccountInputError,
|
||||
SystemAccountConflictError,
|
||||
validateSystemGroupDeletePolicy,
|
||||
validateSystemUserDeletePolicy,
|
||||
validateSystemUserUpdatePolicy,
|
||||
} from "./internal/system-account-security";
|
||||
|
||||
const CONFIG_FAST_TIMEOUT_MS = 8_000;
|
||||
const CONFIG_LIST_TIMEOUT_MS = 12_000;
|
||||
const CONFIG_ACCOUNT_DELETE_TIMEOUT_MS = 120_000;
|
||||
const CONFIG_DOCKER_TIMEOUT_MS = 18_000;
|
||||
const CONFIG_WEB_STACK_TIMEOUT_MS = 18_000;
|
||||
|
||||
@@ -44,6 +59,22 @@ export interface ServerSystemUser {
|
||||
primaryGroup: string | null;
|
||||
isRoot: boolean;
|
||||
isSshUser: boolean;
|
||||
passwordStatus: SystemUserPasswordStatus;
|
||||
sshKeys: ServerSystemSshKey[];
|
||||
authorizedKeysRevision: string | null;
|
||||
}
|
||||
|
||||
export interface ServerSystemSshKey {
|
||||
fingerprint: string;
|
||||
keyType: string;
|
||||
comment: string;
|
||||
}
|
||||
|
||||
export interface ServerSystemGroup {
|
||||
name: string;
|
||||
gid: number;
|
||||
members: string[];
|
||||
primaryUsers: string[];
|
||||
}
|
||||
|
||||
export interface ServerServiceStatus {
|
||||
@@ -73,6 +104,10 @@ export interface ServerConfigSnapshot {
|
||||
rootUser: ServerSystemUser | null;
|
||||
nonRootUsers: ServerSystemUser[];
|
||||
systemGroups: string[];
|
||||
systemGroupDetails: ServerSystemGroup[];
|
||||
uidMin: number | null;
|
||||
gidMin: number | null;
|
||||
sshAccess: ServerSshAccessStatus;
|
||||
hasRootUser: boolean;
|
||||
sudoNonInteractive: boolean;
|
||||
docker: DockerRuntimeStatus;
|
||||
@@ -86,16 +121,50 @@ export interface ServerConfigSnapshot {
|
||||
export async function getServerConfigSnapshot(
|
||||
server: Server,
|
||||
): Promise<ServerConfigSnapshot> {
|
||||
const accountPolicyScript = [
|
||||
"uid_min=$(awk '$1 == \"UID_MIN\" { print $2; exit }' /etc/login.defs 2>/dev/null)",
|
||||
"gid_min=$(awk '$1 == \"GID_MIN\" { print $2; exit }' /etc/login.defs 2>/dev/null)",
|
||||
'printf \'%s|%s\\n\' "${uid_min:-}" "${gid_min:-}"',
|
||||
].join("\n");
|
||||
const usersScript = [
|
||||
"uid_min=$(awk '$1 == \"UID_MIN\" { print $2; exit }' /etc/login.defs 2>/dev/null)",
|
||||
'uid_min=${uid_min:-1000}',
|
||||
"while IFS=: read -r user _ uid gid _ home shell; do",
|
||||
' if [ "$uid" -eq 0 ] || [ "$uid" -ge 1000 ]; then',
|
||||
' if [ "$uid" -eq 0 ] || [ "$uid" -ge "$uid_min" ]; then',
|
||||
' groups=$(id -nG "$user" 2>/dev/null || true)',
|
||||
' 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 groupsScript = [
|
||||
"while IFS=: read -r group _ gid members; do",
|
||||
" primary_users=$(awk -F: -v target_gid=\"$gid\" '$4 == target_gid { if (found++) printf \",\"; printf \"%s\", $1 }' /etc/passwd)",
|
||||
' printf \'%s|%s|%s|%s\\n\' "$group" "$gid" "$members" "$primary_users"',
|
||||
"done < /etc/group",
|
||||
].join("\n");
|
||||
const credentialScript = [
|
||||
"set -u",
|
||||
"uid_min=$(awk '$1 == \"UID_MIN\" { print $2; exit }' /etc/login.defs 2>/dev/null)",
|
||||
'uid_min=${uid_min:-1000}',
|
||||
"while IFS=: read -r user _ uid _ _ home _; do",
|
||||
' if [ "$uid" -eq 0 ] || [ "$uid" -ge "$uid_min" ]; then',
|
||||
' password_state=$(passwd -S "$user" 2>/dev/null | awk \'{print $2}\' || true)',
|
||||
' auth_file="$home/.ssh/authorized_keys"',
|
||||
' revision=""',
|
||||
' if [ -f "$auth_file" ]; then',
|
||||
' revision=$(sha256sum "$auth_file" 2>/dev/null | awk \'{print $1}\' || true)',
|
||||
" fi",
|
||||
' printf \'U|%s|%s|%s\\n\' "$user" "$password_state" "$revision"',
|
||||
' if [ -r "$auth_file" ]; then',
|
||||
' while IFS= read -r key_line || [ -n "$key_line" ]; do',
|
||||
' encoded=$(printf \'%s\' "$key_line" | base64 | tr -d \'\\r\\n\')',
|
||||
' printf \'K|%s|%s\\n\' "$user" "$encoded"',
|
||||
' done < "$auth_file"',
|
||||
" fi",
|
||||
" fi",
|
||||
"done < /etc/passwd",
|
||||
].join("\n");
|
||||
|
||||
const platformResult = await Promise.allSettled([
|
||||
detectServerPlatform(server, configCommandTimeout(CONFIG_FAST_TIMEOUT_MS)),
|
||||
@@ -109,12 +178,15 @@ export async function getServerConfigSnapshot(
|
||||
hostnameResult,
|
||||
kernelResult,
|
||||
currentUserResult,
|
||||
accountPolicyResult,
|
||||
usersResult,
|
||||
credentialsResult,
|
||||
groupsResult,
|
||||
lastBootResult,
|
||||
servicesResult,
|
||||
diskMountsResult,
|
||||
dockerResult,
|
||||
sshAccessResult,
|
||||
] = await Promise.allSettled([
|
||||
exec(
|
||||
server,
|
||||
@@ -131,11 +203,24 @@ export async function getServerConfigSnapshot(
|
||||
'bash -lc "whoami 2>/dev/null"',
|
||||
configCommandTimeout(CONFIG_FAST_TIMEOUT_MS),
|
||||
),
|
||||
exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(accountPolicyScript)}`,
|
||||
configCommandTimeout(CONFIG_FAST_TIMEOUT_MS),
|
||||
),
|
||||
exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(usersScript)}`,
|
||||
configCommandTimeout(CONFIG_LIST_TIMEOUT_MS),
|
||||
),
|
||||
exec(
|
||||
server,
|
||||
nonInteractivePrivilegedCommand(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(credentialScript)}`,
|
||||
),
|
||||
configCommandTimeout(CONFIG_LIST_TIMEOUT_MS),
|
||||
),
|
||||
exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(groupsScript)}`,
|
||||
@@ -152,6 +237,7 @@ export async function getServerConfigSnapshot(
|
||||
...configCommandTimeout(CONFIG_DOCKER_TIMEOUT_MS),
|
||||
platform,
|
||||
}),
|
||||
getServerSshAccessStatus(server),
|
||||
]);
|
||||
|
||||
const services =
|
||||
@@ -167,6 +253,24 @@ export async function getServerConfigSnapshot(
|
||||
: "Docker status probe failed while loading server config",
|
||||
platform,
|
||||
);
|
||||
const sshAccess =
|
||||
sshAccessResult.status === "fulfilled"
|
||||
? sshAccessResult.value
|
||||
: {
|
||||
available: false,
|
||||
pubkeyAuthentication: null,
|
||||
passwordAuthentication: null,
|
||||
keyboardInteractiveAuthentication: null,
|
||||
permitRootLogin: null,
|
||||
permitEmptyPasswords: null,
|
||||
revision: null,
|
||||
managed: false,
|
||||
temporaryRollbackScheduled: false,
|
||||
error:
|
||||
sshAccessResult.reason instanceof Error
|
||||
? sshAccessResult.reason.message
|
||||
: "SSH access policy probe failed",
|
||||
} satisfies ServerSshAccessStatus;
|
||||
|
||||
const webServerResult = await Promise.allSettled([
|
||||
inspectWebServerCapability(
|
||||
@@ -183,6 +287,55 @@ export async function getServerConfigSnapshot(
|
||||
|
||||
const usersOutput =
|
||||
usersResult.status === "fulfilled" ? usersResult.value.stdout : "";
|
||||
const credentialOutput =
|
||||
credentialsResult.status === "fulfilled"
|
||||
? credentialsResult.value.stdout
|
||||
: "";
|
||||
const credentialsByUser = new Map<
|
||||
string,
|
||||
{
|
||||
passwordStatus: SystemUserPasswordStatus;
|
||||
sshKeys: ServerSystemSshKey[];
|
||||
authorizedKeysRevision: string | null;
|
||||
}
|
||||
>();
|
||||
for (const outputLine of credentialOutput.split("\n")) {
|
||||
const line = outputLine.trim();
|
||||
if (!line) continue;
|
||||
const [kind, username, payload, revision] = line.split("|");
|
||||
if (!username) continue;
|
||||
if (kind === "U") {
|
||||
credentialsByUser.set(username, {
|
||||
passwordStatus: parseSystemUserPasswordStatus(payload),
|
||||
sshKeys: [],
|
||||
authorizedKeysRevision: revision || getAuthorizedKeysRevision(""),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (kind !== "K" || !payload) continue;
|
||||
const current = credentialsByUser.get(username);
|
||||
if (!current) continue;
|
||||
try {
|
||||
const parsed = parseAuthorizedKeyLine(
|
||||
Buffer.from(payload, "base64").toString("utf8"),
|
||||
);
|
||||
if (
|
||||
parsed &&
|
||||
!current.sshKeys.some(
|
||||
(key) => key.fingerprint === parsed.fingerprint,
|
||||
)
|
||||
) {
|
||||
current.sshKeys.push({
|
||||
fingerprint: parsed.fingerprint,
|
||||
keyType: parsed.keyType,
|
||||
comment: parsed.comment,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed host key lines while preserving the rest of the snapshot.
|
||||
}
|
||||
}
|
||||
|
||||
const users = usersOutput
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
@@ -201,6 +354,7 @@ export async function getServerConfigSnapshot(
|
||||
const uid = Number(uidRaw);
|
||||
const gid = Number(gidRaw);
|
||||
|
||||
const credentials = credentialsByUser.get(username);
|
||||
return {
|
||||
username,
|
||||
uid: Number.isFinite(uid) ? uid : null,
|
||||
@@ -214,6 +368,9 @@ export async function getServerConfigSnapshot(
|
||||
primaryGroup: primaryGroupRaw || null,
|
||||
isRoot: uid === 0 || username === "root",
|
||||
isSshUser: username === server.username,
|
||||
passwordStatus: credentials?.passwordStatus ?? "unknown",
|
||||
sshKeys: credentials?.sshKeys ?? [],
|
||||
authorizedKeysRevision: credentials?.authorizedKeysRevision ?? null,
|
||||
} satisfies ServerSystemUser;
|
||||
})
|
||||
.sort((left, right) => {
|
||||
@@ -224,13 +381,38 @@ export async function getServerConfigSnapshot(
|
||||
|
||||
const rootUser = users.find((user) => user.isRoot) ?? null;
|
||||
const nonRootUsers = users.filter((user) => !user.isRoot);
|
||||
const systemGroups =
|
||||
const accountPolicy =
|
||||
accountPolicyResult.status === "fulfilled"
|
||||
? accountPolicyResult.value.stdout.trim().split("|")
|
||||
: [];
|
||||
const parsedUidMin = Number(accountPolicy[0]);
|
||||
const parsedGidMin = Number(accountPolicy[1]);
|
||||
const uidMin = Number.isInteger(parsedUidMin) && parsedUidMin > 0
|
||||
? parsedUidMin
|
||||
: null;
|
||||
const gidMin = Number.isInteger(parsedGidMin) && parsedGidMin > 0
|
||||
? parsedGidMin
|
||||
: null;
|
||||
const systemGroupDetails =
|
||||
groupsResult.status === "fulfilled"
|
||||
? groupsResult.value.stdout
|
||||
.split("\n")
|
||||
.map((group) => group.trim())
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const [name, gidRaw, membersRaw, primaryUsersRaw] = line.split("|");
|
||||
const gid = Number(gidRaw);
|
||||
if (!name || !Number.isInteger(gid)) return null;
|
||||
return {
|
||||
name,
|
||||
gid,
|
||||
members: (membersRaw || "").split(",").filter(Boolean),
|
||||
primaryUsers: (primaryUsersRaw || "").split(",").filter(Boolean),
|
||||
} satisfies ServerSystemGroup;
|
||||
})
|
||||
.filter((group): group is ServerSystemGroup => group !== null)
|
||||
: [];
|
||||
const systemGroups = systemGroupDetails.map((group) => group.name);
|
||||
|
||||
return {
|
||||
hostname:
|
||||
@@ -251,6 +433,10 @@ export async function getServerConfigSnapshot(
|
||||
rootUser,
|
||||
nonRootUsers,
|
||||
systemGroups,
|
||||
systemGroupDetails,
|
||||
uidMin,
|
||||
gidMin,
|
||||
sshAccess,
|
||||
hasRootUser: Boolean(rootUser),
|
||||
sudoNonInteractive: docker.platform.sudoNonInteractive,
|
||||
docker,
|
||||
@@ -299,7 +485,15 @@ export async function createServerSystemGroup(
|
||||
|
||||
export async function createServerSystemUser(
|
||||
server: Server,
|
||||
options: { username: string; groups: string[] },
|
||||
options: {
|
||||
username: string;
|
||||
groups: string[];
|
||||
remoteLogin: boolean;
|
||||
credential:
|
||||
| { type: "none" }
|
||||
| { type: "password"; password: string; requireChange: boolean }
|
||||
| { type: "ssh-key"; publicKey: string; label?: string };
|
||||
},
|
||||
): Promise<void> {
|
||||
assertLinuxAccountName(options.username, "User");
|
||||
if (options.username === "root") {
|
||||
@@ -310,6 +504,25 @@ export async function createServerSystemUser(
|
||||
|
||||
const groups = Array.from(new Set(options.groups));
|
||||
groups.forEach((group) => assertLinuxAccountName(group, "Group"));
|
||||
if (!options.remoteLogin && options.credential.type !== "none") {
|
||||
throw new SystemAccountInputError(
|
||||
"A user without remote login cannot receive an SSH credential",
|
||||
);
|
||||
}
|
||||
if (options.remoteLogin && options.credential.type === "none") {
|
||||
throw new SystemAccountInputError(
|
||||
"An SSH login user requires an initial password or public key",
|
||||
);
|
||||
}
|
||||
if (options.credential.type === "password") {
|
||||
assertSystemUserPassword(options.username, options.credential.password);
|
||||
}
|
||||
if (options.credential.type === "ssh-key") {
|
||||
normalizeOpenSshPublicKey(
|
||||
options.credential.publicKey,
|
||||
options.credential.label,
|
||||
);
|
||||
}
|
||||
|
||||
if (await accountExists(server, "passwd", options.username)) {
|
||||
throw new SystemAccountInputError(
|
||||
@@ -329,16 +542,67 @@ export async function createServerSystemUser(
|
||||
);
|
||||
}
|
||||
|
||||
let shell = "/bin/bash";
|
||||
if (!options.remoteLogin) {
|
||||
const shellProbe = await exec(
|
||||
server,
|
||||
"if [ -x /usr/sbin/nologin ]; then printf /usr/sbin/nologin; elif [ -x /sbin/nologin ]; then printf /sbin/nologin; else exit 1; fi",
|
||||
configCommandTimeout(CONFIG_FAST_TIMEOUT_MS),
|
||||
);
|
||||
if (shellProbe.code !== 0 || !shellProbe.stdout.trim()) {
|
||||
throw new SystemAccountInputError(
|
||||
"The host does not provide a supported nologin shell",
|
||||
);
|
||||
}
|
||||
shell = shellProbe.stdout.trim();
|
||||
}
|
||||
|
||||
const groupArgs =
|
||||
groups.length > 0 ? ` -G ${escapeShellArg(groups.join(","))}` : "";
|
||||
await execStrict(
|
||||
server,
|
||||
nonInteractivePrivilegedCommand(
|
||||
let userCreated = false;
|
||||
try {
|
||||
await execStrict(
|
||||
server,
|
||||
`useradd -m -s /bin/bash${groupArgs} ${escapeShellArg(options.username)}`,
|
||||
),
|
||||
configCommandTimeout(CONFIG_LIST_TIMEOUT_MS),
|
||||
);
|
||||
nonInteractivePrivilegedCommand(
|
||||
server,
|
||||
`useradd -m -s ${escapeShellArg(shell)}${groupArgs} ${escapeShellArg(options.username)}`,
|
||||
),
|
||||
configCommandTimeout(CONFIG_LIST_TIMEOUT_MS),
|
||||
);
|
||||
userCreated = true;
|
||||
if (options.credential.type === "password") {
|
||||
await setServerSystemUserPassword(server, {
|
||||
username: options.username,
|
||||
password: options.credential.password,
|
||||
requireChange: options.credential.requireChange,
|
||||
});
|
||||
} else if (options.credential.type === "ssh-key") {
|
||||
await addServerSystemUserSshKey(server, {
|
||||
username: options.username,
|
||||
publicKey: options.credential.publicKey,
|
||||
label: options.credential.label,
|
||||
expectedRevision: getAuthorizedKeysRevision(""),
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (userCreated && (await accountExists(server, "passwd", options.username))) {
|
||||
try {
|
||||
await execStrict(
|
||||
server,
|
||||
nonInteractivePrivilegedCommand(
|
||||
server,
|
||||
`userdel -r ${escapeShellArg(options.username)}`,
|
||||
),
|
||||
configCommandTimeout(CONFIG_LIST_TIMEOUT_MS),
|
||||
);
|
||||
} catch {
|
||||
throw new Error(
|
||||
`User creation was only partially completed and automatic rollback failed for ${options.username}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
interface CurrentServerSystemUser {
|
||||
@@ -417,6 +681,572 @@ async function getCurrentServerSystemUser(
|
||||
};
|
||||
}
|
||||
|
||||
interface CurrentServerSystemGroup {
|
||||
name: string;
|
||||
gid: number;
|
||||
members: string[];
|
||||
primaryUsers: string[];
|
||||
}
|
||||
|
||||
async function getCurrentServerSystemGroup(
|
||||
server: Server,
|
||||
groupName: string,
|
||||
): Promise<CurrentServerSystemGroup> {
|
||||
const script = [
|
||||
`TARGET=${escapeShellArg(groupName)}`,
|
||||
'entry=$(getent group "$TARGET") || exit 3',
|
||||
'IFS=: read -r name _ gid members <<< "$entry"',
|
||||
"primary_users=$(awk -F: -v target_gid=\"$gid\" '$4 == target_gid { if (found++) printf \",\"; printf \"%s\", $1 }' /etc/passwd)",
|
||||
'printf \'%s|%s|%s|%s\\n\' "$name" "$gid" "$members" "$primary_users"',
|
||||
].join("\n");
|
||||
const result = await exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(script)}`,
|
||||
configCommandTimeout(CONFIG_FAST_TIMEOUT_MS),
|
||||
);
|
||||
if (result.code === 3) {
|
||||
throw new SystemAccountInputError(`Group ${groupName} was not found`);
|
||||
}
|
||||
if (result.code !== 0 || !result.stdout.trim()) {
|
||||
throw new Error("Failed to inspect the current system group state");
|
||||
}
|
||||
const [name, gidRaw, membersRaw, primaryUsersRaw] = result.stdout
|
||||
.trim()
|
||||
.split("|");
|
||||
const gid = Number(gidRaw);
|
||||
if (!name || !Number.isInteger(gid)) {
|
||||
throw new Error("The host returned an invalid system group record");
|
||||
}
|
||||
return {
|
||||
name,
|
||||
gid,
|
||||
members: (membersRaw || "").split(",").filter(Boolean),
|
||||
primaryUsers: (primaryUsersRaw || "").split(",").filter(Boolean),
|
||||
};
|
||||
}
|
||||
|
||||
async function readHostAccountMinimum(
|
||||
server: Server,
|
||||
field: "UID_MIN" | "GID_MIN",
|
||||
): Promise<number> {
|
||||
const result = await exec(
|
||||
server,
|
||||
`awk '$1 == "${field}" { print $2; exit }' /etc/login.defs`,
|
||||
configCommandTimeout(CONFIG_FAST_TIMEOUT_MS),
|
||||
);
|
||||
const rawValue = result.stdout.trim();
|
||||
const value = Number(rawValue);
|
||||
if (!rawValue || result.code !== 0 || !Number.isInteger(value) || value < 1) {
|
||||
throw new SystemAccountInputError(`The host ${field} policy is unavailable`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
async function countUserProcesses(server: Server, uid: number): Promise<number> {
|
||||
const script = [
|
||||
`TARGET_UID=${escapeShellArg(String(uid))}`,
|
||||
'if command -v pgrep >/dev/null 2>&1; then',
|
||||
' pgrep -u "$TARGET_UID" 2>/dev/null | wc -l',
|
||||
"else",
|
||||
" ps -eo uid= 2>/dev/null | awk -v target_uid=\"$TARGET_UID\" '$1 == target_uid { count++ } END { print count + 0 }'",
|
||||
"fi",
|
||||
].join("\n");
|
||||
const result = await exec(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(script)}`,
|
||||
configCommandTimeout(CONFIG_FAST_TIMEOUT_MS),
|
||||
);
|
||||
const count = Number(result.stdout.trim());
|
||||
if (result.code !== 0 || !Number.isInteger(count) || count < 0) {
|
||||
throw new Error("Failed to inspect active processes for the system user");
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
export interface ServerSystemUserDeleteResult {
|
||||
username: string;
|
||||
uid: number;
|
||||
home: string;
|
||||
homeRemoved: boolean;
|
||||
}
|
||||
|
||||
export async function deleteServerSystemUser(
|
||||
server: Server,
|
||||
options: {
|
||||
username: string;
|
||||
expectedUid: number;
|
||||
expectedGid: number;
|
||||
expectedHome: string;
|
||||
expectedShell: string;
|
||||
confirmation: string;
|
||||
removeHome: boolean;
|
||||
},
|
||||
): Promise<ServerSystemUserDeleteResult> {
|
||||
assertLinuxAccountName(options.username, "User");
|
||||
const current = await getCurrentServerSystemUser(server, options.username);
|
||||
const uidMin = await readHostAccountMinimum(server, "UID_MIN");
|
||||
const activeProcessCount = await countUserProcesses(server, current.uid);
|
||||
validateSystemUserDeletePolicy({
|
||||
...current,
|
||||
uidMin,
|
||||
serverUsername: server.username,
|
||||
expectedUid: options.expectedUid,
|
||||
expectedGid: options.expectedGid,
|
||||
expectedHome: options.expectedHome,
|
||||
expectedShell: options.expectedShell,
|
||||
confirmation: options.confirmation,
|
||||
activeProcessCount,
|
||||
removeHome: options.removeHome,
|
||||
});
|
||||
|
||||
const deletionScript = [
|
||||
"set -u",
|
||||
`TARGET=${escapeShellArg(current.username)}`,
|
||||
`SERVER_USER=${escapeShellArg(server.username)}`,
|
||||
`EXPECTED_UID=${escapeShellArg(String(current.uid))}`,
|
||||
`EXPECTED_GID=${escapeShellArg(String(current.gid))}`,
|
||||
`EXPECTED_HOME=${escapeShellArg(current.home)}`,
|
||||
`EXPECTED_SHELL=${escapeShellArg(current.shell)}`,
|
||||
`REMOVE_HOME=${options.removeHome ? "1" : "0"}`,
|
||||
'entry=$(getent passwd "$TARGET") || exit 3',
|
||||
'IFS=: read -r account _ uid gid _ home shell <<< "$entry"',
|
||||
'uid_min=$(awk \'$1 == "UID_MIN" { print $2; exit }\' /etc/login.defs 2>/dev/null)',
|
||||
'[ -n "$uid_min" ] || exit 9',
|
||||
'[ "$uid" -ne 0 ] && [ "$TARGET" != "root" ] && [ "$TARGET" != "$SERVER_USER" ] && [ "$uid" -ge "$uid_min" ] || exit 4',
|
||||
'[ "$uid" = "$EXPECTED_UID" ] && [ "$gid" = "$EXPECTED_GID" ] && [ "$home" = "$EXPECTED_HOME" ] && [ "$shell" = "$EXPECTED_SHELL" ] || exit 5',
|
||||
'if command -v pgrep >/dev/null 2>&1; then process_count=$(pgrep -u "$uid" 2>/dev/null | wc -l); else process_count=$(ps -eo uid= 2>/dev/null | awk -v target_uid="$uid" \'$1 == target_uid { count++ } END { print count + 0 }\'); fi',
|
||||
'[ "$process_count" -eq 0 ] || exit 6',
|
||||
'if [ "$REMOVE_HOME" -eq 1 ]; then [ "$home" = "/home/$TARGET" ] || exit 7; fi',
|
||||
'command -v userdel >/dev/null 2>&1 || exit 8',
|
||||
'if [ "$REMOVE_HOME" -eq 1 ]; then userdel -r "$TARGET"; else userdel "$TARGET"; fi',
|
||||
].join("\n");
|
||||
const result = await exec(
|
||||
server,
|
||||
nonInteractivePrivilegedCommand(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(deletionScript)}`,
|
||||
),
|
||||
configCommandTimeout(CONFIG_ACCOUNT_DELETE_TIMEOUT_MS),
|
||||
);
|
||||
if (result.code !== 0) {
|
||||
if (result.code === 3) {
|
||||
throw new SystemAccountConflictError(
|
||||
"This user no longer exists. Refresh Server Config.",
|
||||
);
|
||||
}
|
||||
if (result.code === 4) {
|
||||
throw new SystemAccountInputError(
|
||||
"Root, system users, and the active Doktainer SSH user cannot be deleted",
|
||||
);
|
||||
}
|
||||
if (result.code === 5) {
|
||||
throw new SystemAccountConflictError(
|
||||
"This user changed immediately before deletion. Refresh Server Config.",
|
||||
);
|
||||
}
|
||||
if (result.code === 6) {
|
||||
throw new SystemAccountInputError(
|
||||
"This user started an active process before deletion. Stop it and try again.",
|
||||
);
|
||||
}
|
||||
if (result.code === 7) {
|
||||
throw new SystemAccountInputError(
|
||||
"The account home path is not safe for automatic removal",
|
||||
);
|
||||
}
|
||||
if (result.code === 8) {
|
||||
throw new SystemAccountInputError("The host does not provide userdel");
|
||||
}
|
||||
if (result.code === 9) {
|
||||
throw new SystemAccountInputError("The host UID_MIN policy is unavailable");
|
||||
}
|
||||
throw new Error("The host could not delete the system user");
|
||||
}
|
||||
return {
|
||||
username: current.username,
|
||||
uid: current.uid,
|
||||
home: current.home,
|
||||
homeRemoved: options.removeHome,
|
||||
};
|
||||
}
|
||||
|
||||
export interface ServerSystemGroupDeleteResult {
|
||||
groupName: string;
|
||||
gid: number;
|
||||
}
|
||||
|
||||
export async function deleteServerSystemGroup(
|
||||
server: Server,
|
||||
options: {
|
||||
groupName: string;
|
||||
expectedGid: number;
|
||||
expectedMembers: string[];
|
||||
expectedPrimaryUsers: string[];
|
||||
confirmation: string;
|
||||
},
|
||||
): Promise<ServerSystemGroupDeleteResult> {
|
||||
assertLinuxAccountName(options.groupName, "Group");
|
||||
const current = await getCurrentServerSystemGroup(server, options.groupName);
|
||||
const gidMin = await readHostAccountMinimum(server, "GID_MIN");
|
||||
validateSystemGroupDeletePolicy({
|
||||
groupName: current.name,
|
||||
gid: current.gid,
|
||||
gidMin,
|
||||
members: current.members,
|
||||
primaryUsers: current.primaryUsers,
|
||||
expectedGid: options.expectedGid,
|
||||
expectedMembers: options.expectedMembers,
|
||||
expectedPrimaryUsers: options.expectedPrimaryUsers,
|
||||
confirmation: options.confirmation,
|
||||
});
|
||||
|
||||
const deletionScript = [
|
||||
"set -u",
|
||||
`TARGET=${escapeShellArg(current.name)}`,
|
||||
`EXPECTED_GID=${escapeShellArg(String(current.gid))}`,
|
||||
`EXPECTED_MEMBERS=${escapeShellArg([...current.members].sort().join(","))}`,
|
||||
`EXPECTED_PRIMARY=${escapeShellArg([...current.primaryUsers].sort().join(","))}`,
|
||||
'entry=$(getent group "$TARGET") || exit 3',
|
||||
'IFS=: read -r name _ gid members <<< "$entry"',
|
||||
'gid_min=$(awk \'$1 == "GID_MIN" { print $2; exit }\' /etc/login.defs 2>/dev/null)',
|
||||
'[ -n "$gid_min" ] || exit 9',
|
||||
'case "$TARGET" in root|docker|sudo|wheel|adm|systemd-journal|ssh|sshd) exit 4 ;; esac',
|
||||
'[ "$gid" -ge "$gid_min" ] || exit 4',
|
||||
'members_sorted=$(printf \'%s\' "$members" | tr \, \'\\n\' | sed \'/^$/d\' | sort | paste -sd, -)',
|
||||
'primary_users=$(awk -F: -v target_gid="$gid" \'$4 == target_gid { print $1 }\' /etc/passwd | sort | paste -sd, -)',
|
||||
'[ "$gid" = "$EXPECTED_GID" ] && [ "$members_sorted" = "$EXPECTED_MEMBERS" ] && [ "$primary_users" = "$EXPECTED_PRIMARY" ] || exit 5',
|
||||
'[ -z "$members_sorted" ] || exit 6',
|
||||
'[ -z "$primary_users" ] || exit 7',
|
||||
'command -v groupdel >/dev/null 2>&1 || exit 8',
|
||||
'groupdel "$TARGET"',
|
||||
].join("\n");
|
||||
const result = await exec(
|
||||
server,
|
||||
nonInteractivePrivilegedCommand(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(deletionScript)}`,
|
||||
),
|
||||
configCommandTimeout(CONFIG_LIST_TIMEOUT_MS),
|
||||
);
|
||||
if (result.code !== 0) {
|
||||
if (result.code === 3 || result.code === 5) {
|
||||
throw new SystemAccountConflictError(
|
||||
"This group changed immediately before deletion. Refresh Server Config.",
|
||||
);
|
||||
}
|
||||
if (result.code === 4) {
|
||||
throw new SystemAccountInputError(
|
||||
"System and privileged groups cannot be deleted",
|
||||
);
|
||||
}
|
||||
if (result.code === 6 || result.code === 7) {
|
||||
throw new SystemAccountInputError(
|
||||
"This group is still in use and cannot be deleted",
|
||||
);
|
||||
}
|
||||
if (result.code === 8) {
|
||||
throw new SystemAccountInputError("The host does not provide groupdel");
|
||||
}
|
||||
if (result.code === 9) {
|
||||
throw new SystemAccountInputError("The host GID_MIN policy is unavailable");
|
||||
}
|
||||
throw new Error("The host could not delete the system group");
|
||||
}
|
||||
return { groupName: current.name, gid: current.gid };
|
||||
}
|
||||
|
||||
async function readAuthorizedKeys(
|
||||
server: Server,
|
||||
username: string,
|
||||
): Promise<{ content: string; revision: string }> {
|
||||
const script = [
|
||||
"set -euo pipefail",
|
||||
`TARGET=${escapeShellArg(username)}`,
|
||||
'entry=$(getent passwd "$TARGET") || exit 3',
|
||||
'IFS=: read -r _ _ uid _ _ home _ <<< "$entry"',
|
||||
'[ "$uid" -ne 0 ] || exit 4',
|
||||
'file="$home/.ssh/authorized_keys"',
|
||||
'if [ -f "$file" ]; then base64 < "$file" | tr -d \'\\r\\n\'; fi',
|
||||
].join("\n");
|
||||
const result = await exec(
|
||||
server,
|
||||
nonInteractivePrivilegedCommand(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(script)}`,
|
||||
),
|
||||
configCommandTimeout(CONFIG_LIST_TIMEOUT_MS),
|
||||
);
|
||||
if (result.code === 3) {
|
||||
throw new SystemAccountInputError(`User ${username} was not found`);
|
||||
}
|
||||
if (result.code === 4) {
|
||||
throw new SystemAccountInputError("The root account is protected");
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
throw new Error("Failed to read the user's authorized SSH keys");
|
||||
}
|
||||
const content = result.stdout.trim()
|
||||
? Buffer.from(result.stdout.trim(), "base64").toString("utf8")
|
||||
: "";
|
||||
return { content, revision: getAuthorizedKeysRevision(content) };
|
||||
}
|
||||
|
||||
async function writeAuthorizedKeys(
|
||||
server: Server,
|
||||
username: string,
|
||||
content: string,
|
||||
): Promise<void> {
|
||||
const script = [
|
||||
"set -euo pipefail",
|
||||
`TARGET=${escapeShellArg(username)}`,
|
||||
'entry=$(getent passwd "$TARGET") || exit 3',
|
||||
'IFS=: read -r _ _ uid gid _ home _ <<< "$entry"',
|
||||
'[ "$uid" -ne 0 ] || exit 4',
|
||||
'ssh_dir="$home/.ssh"',
|
||||
'auth_file="$ssh_dir/authorized_keys"',
|
||||
'install -d -m 700 -o "$uid" -g "$gid" "$ssh_dir"',
|
||||
'temp_file=$(mktemp "$ssh_dir/.authorized_keys.doktainer.XXXXXX")',
|
||||
'cleanup() { rm -f "$temp_file"; }',
|
||||
"trap cleanup EXIT",
|
||||
'cat > "$temp_file"',
|
||||
'chown "$uid:$gid" "$temp_file"',
|
||||
'chmod 600 "$temp_file"',
|
||||
'mv -f "$temp_file" "$auth_file"',
|
||||
"trap - EXIT",
|
||||
].join("\n");
|
||||
await execStrict(
|
||||
server,
|
||||
nonInteractivePrivilegedCommand(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(script)}`,
|
||||
),
|
||||
{
|
||||
...configCommandTimeout(CONFIG_LIST_TIMEOUT_MS),
|
||||
stdin: content,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
export interface ServerSystemCredentialResult {
|
||||
username: string;
|
||||
passwordStatus?: SystemUserPasswordStatus;
|
||||
sshKeys?: ServerSystemSshKey[];
|
||||
authorizedKeysRevision?: string;
|
||||
}
|
||||
|
||||
export async function setServerSystemUserPassword(
|
||||
server: Server,
|
||||
options: { username: string; password: string; requireChange: boolean },
|
||||
): Promise<ServerSystemCredentialResult> {
|
||||
assertLinuxAccountName(options.username, "User");
|
||||
assertSystemUserPassword(options.username, options.password);
|
||||
const current = await getCurrentServerSystemUser(server, options.username);
|
||||
assertCredentialMutationAllowed({
|
||||
username: current.username,
|
||||
uid: current.uid,
|
||||
serverUsername: server.username,
|
||||
serverAuthType: server.authType,
|
||||
action: "password-set",
|
||||
});
|
||||
const requiredCommands = options.requireChange
|
||||
? "command -v chpasswd >/dev/null && command -v chage >/dev/null"
|
||||
: "command -v chpasswd >/dev/null";
|
||||
const preflight = await exec(
|
||||
server,
|
||||
nonInteractivePrivilegedCommand(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(requiredCommands)}`,
|
||||
),
|
||||
configCommandTimeout(CONFIG_FAST_TIMEOUT_MS),
|
||||
);
|
||||
if (preflight.code !== 0) {
|
||||
throw new SystemAccountInputError(
|
||||
"The host does not provide the required password-management tools",
|
||||
);
|
||||
}
|
||||
await execStrict(
|
||||
server,
|
||||
nonInteractivePrivilegedCommand(server, "chpasswd"),
|
||||
{
|
||||
...configCommandTimeout(CONFIG_LIST_TIMEOUT_MS),
|
||||
stdin: `${current.username}:${options.password}\n`,
|
||||
},
|
||||
);
|
||||
if (options.requireChange) {
|
||||
const expireResult = await exec(
|
||||
server,
|
||||
nonInteractivePrivilegedCommand(
|
||||
server,
|
||||
`chage -d 0 ${escapeShellArg(current.username)}`,
|
||||
),
|
||||
configCommandTimeout(CONFIG_FAST_TIMEOUT_MS),
|
||||
);
|
||||
if (expireResult.code !== 0) {
|
||||
try {
|
||||
await execStrict(
|
||||
server,
|
||||
nonInteractivePrivilegedCommand(
|
||||
server,
|
||||
`passwd -l ${escapeShellArg(current.username)}`,
|
||||
),
|
||||
configCommandTimeout(CONFIG_FAST_TIMEOUT_MS),
|
||||
);
|
||||
} catch {
|
||||
throw new Error(
|
||||
"Password was changed, but the mandatory-change policy and safety lock both failed",
|
||||
);
|
||||
}
|
||||
throw new SystemAccountInputError(
|
||||
"The host could not require a password change, so the new password was locked for safety",
|
||||
);
|
||||
}
|
||||
}
|
||||
return { username: current.username, passwordStatus: "set" };
|
||||
}
|
||||
|
||||
export async function disableServerSystemUserPassword(
|
||||
server: Server,
|
||||
username: string,
|
||||
): Promise<ServerSystemCredentialResult> {
|
||||
assertLinuxAccountName(username, "User");
|
||||
const current = await getCurrentServerSystemUser(server, username);
|
||||
assertCredentialMutationAllowed({
|
||||
username: current.username,
|
||||
uid: current.uid,
|
||||
serverUsername: server.username,
|
||||
serverAuthType: server.authType,
|
||||
action: "password-disable",
|
||||
});
|
||||
await execStrict(
|
||||
server,
|
||||
nonInteractivePrivilegedCommand(
|
||||
server,
|
||||
`passwd -l ${escapeShellArg(current.username)}`,
|
||||
),
|
||||
configCommandTimeout(CONFIG_FAST_TIMEOUT_MS),
|
||||
);
|
||||
return { username: current.username, passwordStatus: "locked" };
|
||||
}
|
||||
|
||||
export async function addServerSystemUserSshKey(
|
||||
server: Server,
|
||||
options: {
|
||||
username: string;
|
||||
publicKey: string;
|
||||
label?: string;
|
||||
expectedRevision: string;
|
||||
},
|
||||
): Promise<ServerSystemCredentialResult> {
|
||||
assertLinuxAccountName(options.username, "User");
|
||||
const current = await getCurrentServerSystemUser(server, options.username);
|
||||
assertCredentialMutationAllowed({
|
||||
username: current.username,
|
||||
uid: current.uid,
|
||||
serverUsername: server.username,
|
||||
serverAuthType: server.authType,
|
||||
action: "key-add",
|
||||
});
|
||||
const newKey = normalizeOpenSshPublicKey(options.publicKey, options.label);
|
||||
const keyCheck = await exec(server, "ssh-keygen -lf -", {
|
||||
...configCommandTimeout(CONFIG_FAST_TIMEOUT_MS),
|
||||
stdin: `${newKey.line}\n`,
|
||||
});
|
||||
if (keyCheck.code !== 0) {
|
||||
throw new SystemAccountInputError(
|
||||
"The host rejected this SSH public key as malformed or unsupported",
|
||||
);
|
||||
}
|
||||
const keyBits = Number(keyCheck.stdout.trim().split(/\s+/)[0]);
|
||||
if (newKey.keyType === "ssh-rsa" && (!Number.isFinite(keyBits) || keyBits < 2048)) {
|
||||
throw new SystemAccountInputError("RSA SSH keys must contain at least 2048 bits");
|
||||
}
|
||||
const authorizedKeys = await readAuthorizedKeys(server, current.username);
|
||||
if (authorizedKeys.revision !== options.expectedRevision) {
|
||||
throw new SystemAccountConflictError(
|
||||
"SSH keys changed on the host after the snapshot was loaded. Refresh Server Config before trying again.",
|
||||
);
|
||||
}
|
||||
const existingKeys = authorizedKeys.content
|
||||
.split("\n")
|
||||
.map(parseAuthorizedKeyLine)
|
||||
.filter((key): key is NonNullable<typeof key> => key !== null);
|
||||
if (existingKeys.some((key) => key.fingerprint === newKey.fingerprint)) {
|
||||
throw new SystemAccountInputError("This SSH public key is already configured");
|
||||
}
|
||||
const nextContent = `${authorizedKeys.content.replace(/\s*$/, "")}${
|
||||
authorizedKeys.content.trim() ? "\n" : ""
|
||||
}${newKey.line}\n`;
|
||||
await writeAuthorizedKeys(server, current.username, nextContent);
|
||||
return {
|
||||
username: current.username,
|
||||
sshKeys: [
|
||||
...existingKeys.map(({ fingerprint, keyType, comment }) => ({
|
||||
fingerprint,
|
||||
keyType,
|
||||
comment,
|
||||
})),
|
||||
{
|
||||
fingerprint: newKey.fingerprint,
|
||||
keyType: newKey.keyType,
|
||||
comment: newKey.comment,
|
||||
},
|
||||
],
|
||||
authorizedKeysRevision: getAuthorizedKeysRevision(nextContent),
|
||||
};
|
||||
}
|
||||
|
||||
export async function revokeServerSystemUserSshKey(
|
||||
server: Server,
|
||||
options: { username: string; fingerprint: string; expectedRevision: string },
|
||||
): Promise<ServerSystemCredentialResult> {
|
||||
assertLinuxAccountName(options.username, "User");
|
||||
const current = await getCurrentServerSystemUser(server, options.username);
|
||||
assertCredentialMutationAllowed({
|
||||
username: current.username,
|
||||
uid: current.uid,
|
||||
serverUsername: server.username,
|
||||
serverAuthType: server.authType,
|
||||
action: "key-revoke",
|
||||
});
|
||||
const authorizedKeys = await readAuthorizedKeys(server, current.username);
|
||||
if (authorizedKeys.revision !== options.expectedRevision) {
|
||||
throw new SystemAccountConflictError(
|
||||
"SSH keys changed on the host after the snapshot was loaded. Refresh Server Config before trying again.",
|
||||
);
|
||||
}
|
||||
let removed = false;
|
||||
const retainedLines = authorizedKeys.content.split("\n").filter((line) => {
|
||||
const parsed = parseAuthorizedKeyLine(line);
|
||||
if (parsed?.fingerprint === options.fingerprint) {
|
||||
removed = true;
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
if (!removed) {
|
||||
throw new SystemAccountConflictError(
|
||||
"The selected SSH key no longer exists. Refresh Server Config before trying again.",
|
||||
);
|
||||
}
|
||||
const nextContent = retainedLines.filter(Boolean).join("\n");
|
||||
const normalizedContent = nextContent ? `${nextContent}\n` : "";
|
||||
await writeAuthorizedKeys(server, current.username, normalizedContent);
|
||||
const sshKeys = normalizedContent
|
||||
.split("\n")
|
||||
.map(parseAuthorizedKeyLine)
|
||||
.filter((key): key is NonNullable<typeof key> => key !== null)
|
||||
.map(({ fingerprint, keyType, comment }) => ({
|
||||
fingerprint,
|
||||
keyType,
|
||||
comment,
|
||||
}));
|
||||
return {
|
||||
username: current.username,
|
||||
sshKeys,
|
||||
authorizedKeysRevision: getAuthorizedKeysRevision(normalizedContent),
|
||||
};
|
||||
}
|
||||
|
||||
export async function updateServerSystemUser(
|
||||
server: Server,
|
||||
options: {
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { Server } from "@prisma/client";
|
||||
|
||||
import { exec, execStrict } from "./commands";
|
||||
import { testConnection } from "./connection";
|
||||
import { nonInteractivePrivilegedCommand } from "./internal/privilege";
|
||||
import { escapeShellArg } from "./internal/shell";
|
||||
import {
|
||||
parseEffectiveSshPolicy,
|
||||
type SshAuthenticationPolicy,
|
||||
type SshPermitRootLogin,
|
||||
validateSshAccessPolicy,
|
||||
} from "./internal/ssh-access-security";
|
||||
import {
|
||||
SystemAccountConflictError,
|
||||
SystemAccountInputError,
|
||||
} from "./internal/system-account-security";
|
||||
|
||||
const SSH_ACCESS_TIMEOUT_MS = 20_000;
|
||||
const SSHD_CONFIG_PATH = "/etc/ssh/sshd_config";
|
||||
const SSHD_DROP_IN_DIR = "/etc/ssh/sshd_config.d";
|
||||
const SSHD_MANAGED_CONFIG_PATH = `${SSHD_DROP_IN_DIR}/00-doktainer.conf`;
|
||||
const SSH_ROLLBACK_ROOT = "/var/lib/doktainer/ssh/rollbacks";
|
||||
|
||||
function sshAccessTimeout(timeoutMs = SSH_ACCESS_TIMEOUT_MS) {
|
||||
return { timeoutMs, queueTimeoutMs: timeoutMs };
|
||||
}
|
||||
|
||||
function findSshdScript(): string[] {
|
||||
return [
|
||||
`CONFIG=${escapeShellArg(SSHD_CONFIG_PATH)}`,
|
||||
'if [ -x /usr/sbin/sshd ]; then SSHD=/usr/sbin/sshd; elif [ -x /usr/local/sbin/sshd ]; then SSHD=/usr/local/sbin/sshd; else SSHD=$(command -v sshd 2>/dev/null || true); fi',
|
||||
'[ -n "$SSHD" ] && [ -x "$SSHD" ] || exit 3',
|
||||
'[ -f "$CONFIG" ] || exit 4',
|
||||
];
|
||||
}
|
||||
|
||||
function configurationRevisionScript(): string[] {
|
||||
return [
|
||||
`DROP_IN_DIR=${escapeShellArg(SSHD_DROP_IN_DIR)}`,
|
||||
'revision=$({ sha256sum "$CONFIG"; if [ -d "$DROP_IN_DIR" ]; then find "$DROP_IN_DIR" -maxdepth 1 -type f -name \'*.conf\' -exec sha256sum {} \\;; fi; } 2>/dev/null | sort | sha256sum | awk \'{print $1}\')',
|
||||
'[ -n "$revision" ] || exit 5',
|
||||
];
|
||||
}
|
||||
|
||||
export interface ServerSshAccessStatus {
|
||||
available: boolean;
|
||||
pubkeyAuthentication: boolean | null;
|
||||
passwordAuthentication: boolean | null;
|
||||
keyboardInteractiveAuthentication: boolean | null;
|
||||
permitRootLogin: SshPermitRootLogin | "yes" | null;
|
||||
permitEmptyPasswords: boolean | null;
|
||||
revision: string | null;
|
||||
managed: boolean;
|
||||
temporaryRollbackScheduled: boolean;
|
||||
error: string | null;
|
||||
}
|
||||
|
||||
export async function getServerSshAccessStatus(
|
||||
server: Server,
|
||||
): Promise<ServerSshAccessStatus> {
|
||||
const script = [
|
||||
"set -u",
|
||||
...findSshdScript(),
|
||||
...configurationRevisionScript(),
|
||||
`MANAGED=${escapeShellArg(SSHD_MANAGED_CONFIG_PATH)}`,
|
||||
'managed=0; [ -f "$MANAGED" ] && managed=1',
|
||||
'temporary=0; if command -v systemctl >/dev/null 2>&1 && systemctl list-units --all --type=timer --plain --no-legend \'doktainer-ssh-password-rollback-*.timer\' 2>/dev/null | grep -q .; then temporary=1; fi',
|
||||
'printf \'META|%s|%s|%s\\n\' "$revision" "$managed" "$temporary"',
|
||||
'"$SSHD" -T -f "$CONFIG"',
|
||||
].join("\n");
|
||||
const result = await exec(
|
||||
server,
|
||||
nonInteractivePrivilegedCommand(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(script)}`,
|
||||
),
|
||||
sshAccessTimeout(),
|
||||
);
|
||||
if (result.code !== 0) {
|
||||
const error =
|
||||
result.code === 3
|
||||
? "OpenSSH server binary was not found"
|
||||
: result.code === 4
|
||||
? "The OpenSSH server configuration file was not found"
|
||||
: "The effective OpenSSH configuration could not be inspected";
|
||||
return {
|
||||
available: false,
|
||||
pubkeyAuthentication: null,
|
||||
passwordAuthentication: null,
|
||||
keyboardInteractiveAuthentication: null,
|
||||
permitRootLogin: null,
|
||||
permitEmptyPasswords: null,
|
||||
revision: null,
|
||||
managed: false,
|
||||
temporaryRollbackScheduled: false,
|
||||
error,
|
||||
};
|
||||
}
|
||||
const [metaLine, ...effectiveLines] = result.stdout.split("\n");
|
||||
const [, revision, managed, temporary] = (metaLine || "").split("|");
|
||||
const effectiveOutput = effectiveLines.join("\n");
|
||||
const policy = parseEffectiveSshPolicy(effectiveOutput);
|
||||
const keyboardInteractive = effectiveLines
|
||||
.map((line) => line.trim().toLowerCase())
|
||||
.find((line) => line.startsWith("kbdinteractiveauthentication "))
|
||||
?.split(/\s+/)[1];
|
||||
if (!policy || !/^[a-f0-9]{64}$/.test(revision ?? "")) {
|
||||
return {
|
||||
available: false,
|
||||
pubkeyAuthentication: null,
|
||||
passwordAuthentication: null,
|
||||
keyboardInteractiveAuthentication: null,
|
||||
permitRootLogin: null,
|
||||
permitEmptyPasswords: null,
|
||||
revision: null,
|
||||
managed: managed === "1",
|
||||
temporaryRollbackScheduled: temporary === "1",
|
||||
error: "The host returned an unsupported effective SSH policy",
|
||||
};
|
||||
}
|
||||
return {
|
||||
available: true,
|
||||
pubkeyAuthentication: policy.pubkeyAuthentication,
|
||||
passwordAuthentication: policy.passwordAuthentication,
|
||||
keyboardInteractiveAuthentication:
|
||||
keyboardInteractive === "yes"
|
||||
? true
|
||||
: keyboardInteractive === "no"
|
||||
? false
|
||||
: null,
|
||||
permitRootLogin: policy.permitRootLogin,
|
||||
permitEmptyPasswords: policy.permitEmptyPasswords,
|
||||
revision,
|
||||
managed: managed === "1",
|
||||
temporaryRollbackScheduled: temporary === "1",
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildManagedConfiguration(policy: SshAuthenticationPolicy): string {
|
||||
return [
|
||||
"# Managed by Doktainer. Changes may be replaced by Server Config.",
|
||||
`PubkeyAuthentication ${policy.pubkeyAuthentication ? "yes" : "no"}`,
|
||||
`PasswordAuthentication ${policy.passwordAuthentication ? "yes" : "no"}`,
|
||||
"KbdInteractiveAuthentication no",
|
||||
`PermitRootLogin ${policy.permitRootLogin}`,
|
||||
"PermitEmptyPasswords no",
|
||||
"",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
async function readManagedConfiguration(
|
||||
server: Server,
|
||||
): Promise<{ exists: boolean; content: string }> {
|
||||
const script = [
|
||||
`MANAGED=${escapeShellArg(SSHD_MANAGED_CONFIG_PATH)}`,
|
||||
'if [ -f "$MANAGED" ]; then printf \'1|\'; base64 < "$MANAGED" | tr -d \'\\r\\n\'; else printf \'0|\'; fi',
|
||||
].join("\n");
|
||||
const result = await exec(
|
||||
server,
|
||||
nonInteractivePrivilegedCommand(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(script)}`,
|
||||
),
|
||||
sshAccessTimeout(),
|
||||
);
|
||||
if (result.code !== 0) {
|
||||
throw new Error("Failed to read the existing managed SSH configuration");
|
||||
}
|
||||
const separator = result.stdout.indexOf("|");
|
||||
if (separator !== 1) {
|
||||
throw new Error("The host returned an invalid managed SSH configuration");
|
||||
}
|
||||
const exists = result.stdout.slice(0, separator) === "1";
|
||||
const encoded = result.stdout.slice(separator + 1).trim();
|
||||
return {
|
||||
exists,
|
||||
content: exists && encoded ? Buffer.from(encoded, "base64").toString("utf8") : "",
|
||||
};
|
||||
}
|
||||
|
||||
async function installManagedConfiguration(
|
||||
server: Server,
|
||||
policy: SshAuthenticationPolicy,
|
||||
): Promise<void> {
|
||||
const script = [
|
||||
"set -Eeuo pipefail",
|
||||
...findSshdScript(),
|
||||
`MANAGED=${escapeShellArg(SSHD_MANAGED_CONFIG_PATH)}`,
|
||||
`DROP_IN_DIR=${escapeShellArg(SSHD_DROP_IN_DIR)}`,
|
||||
'install -d -m 755 -o root -g root "$DROP_IN_DIR"',
|
||||
'backup=$(mktemp "$DROP_IN_DIR/.00-doktainer.backup.XXXXXX")',
|
||||
'temp=$(mktemp "$DROP_IN_DIR/.00-doktainer.new.XXXXXX")',
|
||||
'had_previous=0; if [ -f "$MANAGED" ]; then cp -p "$MANAGED" "$backup"; had_previous=1; fi',
|
||||
'rollback() { if [ "$had_previous" -eq 1 ]; then install -m 644 -o root -g root "$backup" "$MANAGED"; else rm -f "$MANAGED"; fi; rm -f "$backup" "$temp"; }',
|
||||
"trap rollback ERR",
|
||||
'cat > "$temp"',
|
||||
'chown root:root "$temp"',
|
||||
'chmod 644 "$temp"',
|
||||
'mv -f "$temp" "$MANAGED"',
|
||||
'"$SSHD" -t -f "$CONFIG"',
|
||||
'effective=$("$SSHD" -T -f "$CONFIG")',
|
||||
`printf '%s\\n' "$effective" | grep -qx ${escapeShellArg(`pubkeyauthentication ${policy.pubkeyAuthentication ? "yes" : "no"}`)}`,
|
||||
`printf '%s\\n' "$effective" | grep -qx ${escapeShellArg(`passwordauthentication ${policy.passwordAuthentication ? "yes" : "no"}`)}`,
|
||||
'printf \'%s\\n\' "$effective" | grep -qx \'kbdinteractiveauthentication no\'',
|
||||
policy.permitRootLogin === "prohibit-password"
|
||||
? 'printf \'%s\\n\' "$effective" | grep -Eq \'^permitrootlogin (prohibit-password|without-password)$\''
|
||||
: 'printf \'%s\\n\' "$effective" | grep -qx \'permitrootlogin no\'',
|
||||
'printf \'%s\\n\' "$effective" | grep -qx \'permitemptypasswords no\'',
|
||||
"trap - ERR",
|
||||
'rm -f "$backup"',
|
||||
].join("\n");
|
||||
const result = await exec(
|
||||
server,
|
||||
nonInteractivePrivilegedCommand(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(script)}`,
|
||||
),
|
||||
{ ...sshAccessTimeout(), stdin: buildManagedConfiguration(policy) },
|
||||
);
|
||||
if (result.code !== 0) {
|
||||
throw new SystemAccountInputError(
|
||||
"The managed drop-in was rejected or did not become the effective SSH policy. The previous file was restored.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function reloadSshDaemon(server: Server): Promise<void> {
|
||||
const command = [
|
||||
"set -e",
|
||||
"if command -v systemctl >/dev/null 2>&1; then",
|
||||
" systemctl reload sshd 2>/dev/null || systemctl reload ssh 2>/dev/null",
|
||||
"else",
|
||||
" service sshd reload 2>/dev/null || service ssh reload 2>/dev/null",
|
||||
"fi",
|
||||
].join("\n");
|
||||
await execStrict(
|
||||
server,
|
||||
nonInteractivePrivilegedCommand(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(command)}`,
|
||||
),
|
||||
sshAccessTimeout(),
|
||||
);
|
||||
}
|
||||
|
||||
async function restoreManagedConfiguration(
|
||||
server: Server,
|
||||
previous: { exists: boolean; content: string },
|
||||
): Promise<void> {
|
||||
const encoded = Buffer.from(previous.content, "utf8").toString("base64");
|
||||
const script = [
|
||||
"set -euo pipefail",
|
||||
...findSshdScript(),
|
||||
`MANAGED=${escapeShellArg(SSHD_MANAGED_CONFIG_PATH)}`,
|
||||
`DROP_IN_DIR=${escapeShellArg(SSHD_DROP_IN_DIR)}`,
|
||||
'install -d -m 755 -o root -g root "$DROP_IN_DIR"',
|
||||
previous.exists
|
||||
? `temp=$(mktemp "$DROP_IN_DIR/.00-doktainer.restore.XXXXXX"); printf '%s' ${escapeShellArg(encoded)} | base64 -d > "$temp"; install -m 644 -o root -g root "$temp" "$MANAGED"; rm -f "$temp"`
|
||||
: 'rm -f "$MANAGED"',
|
||||
'"$SSHD" -t -f "$CONFIG"',
|
||||
].join("\n");
|
||||
await execStrict(
|
||||
server,
|
||||
nonInteractivePrivilegedCommand(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(script)}`,
|
||||
),
|
||||
sshAccessTimeout(),
|
||||
);
|
||||
await reloadSshDaemon(server);
|
||||
if (!(await testConnection(server))) {
|
||||
throw new Error(
|
||||
"The previous SSH policy was restored, but a fresh connection could not be verified",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function cancelTemporaryRollbacks(server: Server): Promise<void> {
|
||||
const script = [
|
||||
"set -euo pipefail",
|
||||
`ROLLBACK_ROOT=${escapeShellArg(SSH_ROLLBACK_ROOT)}`,
|
||||
'if command -v systemctl >/dev/null 2>&1; then',
|
||||
' systemctl list-units --all --type=timer --plain --no-legend \'doktainer-ssh-password-rollback-*.timer\' 2>/dev/null | awk \'{print $1}\' | while IFS= read -r timer; do',
|
||||
' [ -n "$timer" ] || continue',
|
||||
' service=${timer%.timer}.service',
|
||||
' systemctl stop "$timer" "$service" 2>/dev/null || true',
|
||||
" done",
|
||||
"fi",
|
||||
'if [ -d "$ROLLBACK_ROOT" ]; then',
|
||||
' find "$ROLLBACK_ROOT" -mindepth 2 -maxdepth 2 -type f -delete',
|
||||
' find "$ROLLBACK_ROOT" -mindepth 1 -maxdepth 1 -type d -empty -delete',
|
||||
"fi",
|
||||
].join("\n");
|
||||
await execStrict(
|
||||
server,
|
||||
nonInteractivePrivilegedCommand(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(script)}`,
|
||||
),
|
||||
sshAccessTimeout(),
|
||||
);
|
||||
}
|
||||
|
||||
async function scheduleTemporaryRollback(
|
||||
server: Server,
|
||||
previous: { exists: boolean; content: string },
|
||||
minutes: 15 | 30 | 60 | 240,
|
||||
): Promise<void> {
|
||||
const token = randomUUID().replace(/-/g, "");
|
||||
const unit = `doktainer-ssh-password-rollback-${token}`;
|
||||
const rollbackDir = `${SSH_ROLLBACK_ROOT}/${token}`;
|
||||
const previousEncoded = Buffer.from(previous.content, "utf8").toString("base64");
|
||||
const rollbackScript = [
|
||||
"#!/bin/bash",
|
||||
"set -euo pipefail",
|
||||
`MANAGED=${escapeShellArg(SSHD_MANAGED_CONFIG_PATH)}`,
|
||||
`ROLLBACK_DIR=${escapeShellArg(rollbackDir)}`,
|
||||
...findSshdScript(),
|
||||
'expected_hash=$(cat "$ROLLBACK_DIR/expected-managed.sha256")',
|
||||
'current_hash=$(sha256sum "$MANAGED" 2>/dev/null | awk \'{print $1}\')',
|
||||
'[ -n "$expected_hash" ] && [ "$current_hash" = "$expected_hash" ] || { rm -f "$ROLLBACK_DIR"/*; rmdir "$ROLLBACK_DIR"; exit 0; }',
|
||||
'current_backup=$(mktemp "$ROLLBACK_DIR/current.XXXXXX")',
|
||||
'cp -p "$MANAGED" "$current_backup"',
|
||||
previous.exists
|
||||
? 'install -m 644 -o root -g root "$ROLLBACK_DIR/previous.conf" "$MANAGED"'
|
||||
: 'rm -f "$MANAGED"',
|
||||
'if ! "$SSHD" -t -f "$CONFIG"; then install -m 644 -o root -g root "$current_backup" "$MANAGED"; rm -f "$current_backup"; exit 1; fi',
|
||||
'if command -v systemctl >/dev/null 2>&1; then systemctl reload sshd 2>/dev/null || systemctl reload ssh; else service sshd reload 2>/dev/null || service ssh reload; fi',
|
||||
'rm -f "$ROLLBACK_DIR"/*',
|
||||
'rmdir "$ROLLBACK_DIR"',
|
||||
].join("\n");
|
||||
const scriptEncoded = Buffer.from(rollbackScript, "utf8").toString("base64");
|
||||
const setupScript = [
|
||||
"set -euo pipefail",
|
||||
'command -v systemd-run >/dev/null 2>&1 || exit 3',
|
||||
`ROLLBACK_DIR=${escapeShellArg(rollbackDir)}`,
|
||||
`MANAGED=${escapeShellArg(SSHD_MANAGED_CONFIG_PATH)}`,
|
||||
'install -d -m 700 -o root -g root "$ROLLBACK_DIR"',
|
||||
previous.exists
|
||||
? `printf '%s' ${escapeShellArg(previousEncoded)} | base64 -d > "$ROLLBACK_DIR/previous.conf"`
|
||||
: 'touch "$ROLLBACK_DIR/previous.absent"',
|
||||
'sha256sum "$MANAGED" | awk \'{print $1}\' > "$ROLLBACK_DIR/expected-managed.sha256"',
|
||||
`printf '%s' ${escapeShellArg(scriptEncoded)} | base64 -d > "$ROLLBACK_DIR/rollback.sh"`,
|
||||
'chmod 700 "$ROLLBACK_DIR/rollback.sh"',
|
||||
`systemd-run --quiet --unit=${escapeShellArg(unit)} --on-active=${minutes}m --property=Type=oneshot --property=RuntimeMaxSec=120 ${escapeShellArg(`${rollbackDir}/rollback.sh`)}`,
|
||||
].join("\n");
|
||||
const result = await exec(
|
||||
server,
|
||||
nonInteractivePrivilegedCommand(
|
||||
server,
|
||||
`bash -lc ${escapeShellArg(setupScript)}`,
|
||||
),
|
||||
sshAccessTimeout(),
|
||||
);
|
||||
if (result.code === 3) {
|
||||
throw new SystemAccountInputError(
|
||||
"Temporary password access requires systemd-run on the host",
|
||||
);
|
||||
}
|
||||
if (result.code !== 0) {
|
||||
throw new Error("Failed to schedule the host-side SSH policy rollback");
|
||||
}
|
||||
}
|
||||
|
||||
export interface ServerSshAccessApplyResult {
|
||||
status: ServerSshAccessStatus;
|
||||
temporaryMinutes: number | null;
|
||||
}
|
||||
|
||||
export async function applyServerSshAccessPolicy(
|
||||
server: Server,
|
||||
options: {
|
||||
expectedRevision: string;
|
||||
pubkeyAuthentication: boolean;
|
||||
passwordAuthentication: boolean;
|
||||
permitRootLogin: SshPermitRootLogin;
|
||||
permitEmptyPasswords: false;
|
||||
temporaryMinutes: 15 | 30 | 60 | 240 | null;
|
||||
},
|
||||
): Promise<ServerSshAccessApplyResult> {
|
||||
const current = await getServerSshAccessStatus(server);
|
||||
if (!current.available || !current.revision) {
|
||||
throw new SystemAccountInputError(
|
||||
current.error || "SSH configuration is unavailable on this host",
|
||||
);
|
||||
}
|
||||
const desired: SshAuthenticationPolicy = {
|
||||
pubkeyAuthentication: options.pubkeyAuthentication,
|
||||
passwordAuthentication: options.passwordAuthentication,
|
||||
permitRootLogin: options.permitRootLogin,
|
||||
permitEmptyPasswords: false,
|
||||
};
|
||||
validateSshAccessPolicy({
|
||||
serverUsername: server.username,
|
||||
serverAuthType: server.authType,
|
||||
currentRevision: current.revision,
|
||||
expectedRevision: options.expectedRevision,
|
||||
currentPasswordAuthentication: current.passwordAuthentication === true,
|
||||
desired,
|
||||
temporaryMinutes: options.temporaryMinutes,
|
||||
});
|
||||
const previous = await readManagedConfiguration(server);
|
||||
await installManagedConfiguration(server, desired);
|
||||
try {
|
||||
await reloadSshDaemon(server);
|
||||
const reconnected = await testConnection(server);
|
||||
if (!reconnected) {
|
||||
throw new SystemAccountConflictError(
|
||||
"A fresh SSH connection could not be verified after reload",
|
||||
);
|
||||
}
|
||||
await cancelTemporaryRollbacks(server);
|
||||
if (options.temporaryMinutes != null) {
|
||||
await scheduleTemporaryRollback(server, previous, options.temporaryMinutes);
|
||||
}
|
||||
} catch (error) {
|
||||
try {
|
||||
await restoreManagedConfiguration(server, previous);
|
||||
} catch {
|
||||
throw new SystemAccountInputError(
|
||||
"SSH policy apply failed and automatic rollback could not be completed. Keep the current SSH session open and inspect the host configuration immediately.",
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
const status = await getServerSshAccessStatus(server);
|
||||
if (!status.available) {
|
||||
throw new Error("SSH policy was applied but its effective state is unavailable");
|
||||
}
|
||||
return { status, temporaryMinutes: options.temporaryMinutes };
|
||||
}
|
||||
Reference in new issue
Block a user