feat(servers): add secure user, group, and SSH access management

This commit is contained in:
KodekaTeam committed 2026-07-22 03:07:52 +07:00
1 parent f8dfe812ff
commit 7ef17105ad
15 files changed
+6043 -52

No files matched your search

+9
View File
@@ -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"
? {
+613 -1
View File
@@ -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 },
+12 -3
View File
@@ -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[];
+842 -12
View File
@@ -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 };
}