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

+551 -20
View File
@@ -15,7 +15,9 @@ import {
getUser,
type Server as ServerType,
type ServerConfigSnapshot,
type ServerSshAccessUpdateBody,
type ServerSystemUser,
type ServerSystemUserCreateBody,
servers as serversApi,
type WebStackAction,
type WebStackComponentKey,
@@ -40,6 +42,7 @@ import { UserBadge } from "@/app/servers/components/server-config/ServerConfigPr
import ServerConfigActionsPanel from "@/app/servers/components/server-config/ServerConfigActionsPanel";
import ServerConfigMountsPanel from "@/app/servers/components/server-config/ServerConfigMountsPanel";
import ServerConfigOverviewPanel from "@/app/servers/components/server-config/ServerConfigOverviewPanel";
import ServerConfigSshAccessPanel from "@/app/servers/components/server-config/ServerConfigSshAccessPanel";
import ServerConfigServicesPanel from "@/app/servers/components/server-config/ServerConfigServicesPanel";
import ServerConfigUsersPanel from "@/app/servers/components/server-config/ServerConfigUsersPanel";
import ServerConfigWebServerPanel from "@/app/servers/components/server-config/ServerConfigWebServerPanel";
@@ -86,6 +89,7 @@ export default function ServerConfigModal({
const tabs: Array<{ id: ServerConfigTab; label: string }> = [
{ id: "overview", label: "Overview" },
{ id: "users", label: "Users" },
{ id: "ssh-access", label: "SSH Access" },
{ id: "services", label: "Services" },
{ id: "web-server", label: "Web Server" },
{ id: "mounts", label: "Disk Mounts" },
@@ -134,6 +138,11 @@ export default function ServerConfigModal({
(username: string) => `system-user:update:${username}`,
[],
);
const getSystemGroupDeleteActionKey = useCallback(
(groupName: string) => `system-group:delete:${groupName}`,
[],
);
const sshAccessActionKey = "ssh-access:update";
const loadSnapshot = useCallback(async () => {
setLoading(true);
@@ -549,8 +558,7 @@ export default function ServerConfigModal({
};
const handleSystemUserCreate = async (
username: string,
groups: string[],
options: Omit<ServerSystemUserCreateBody, "acknowledgePrivilegedGroups">,
privileged: boolean,
) => {
const actionKey = getSystemAccountActionKey("user");
@@ -559,8 +567,7 @@ export default function ServerConfigModal({
setNotice(null);
try {
const res = await serversApi.createSystemUser(server.id, {
username,
groups,
...options,
acknowledgePrivilegedGroups: privileged,
});
publishNotice({
@@ -569,9 +576,11 @@ export default function ServerConfigModal({
title: "System User Created",
summary: res.message,
details: [
`User: ${username}`,
`Groups: ${groups.length > 0 ? groups.join(", ") : "default group only"}`,
"No password was configured for the account.",
`User: ${options.username}`,
`Groups: ${options.groups.length > 0 ? options.groups.join(", ") : "default group only"}`,
options.remoteLogin
? `Initial login: ${options.credential.type === "password" ? "password" : "SSH public key"}`
: "Remote login disabled",
],
});
onActionComplete(res.message);
@@ -584,7 +593,10 @@ export default function ServerConfigModal({
tone: "error",
title: "System User Creation Failed",
summary: message,
details: [`User: ${username}`, `Server: ${server.name} (${server.ip})`],
details: [
`User: ${options.username}`,
`Server: ${server.name} (${server.ip})`,
],
});
onActionComplete(message, "error");
} finally {
@@ -630,6 +642,162 @@ export default function ServerConfigModal({
}
};
const handleSystemUserDelete = async (options: {
username: string;
expectedUid: number;
expectedGid: number;
expectedHome: string;
expectedShell: string;
confirmation: string;
removeHome: boolean;
}) => {
const actionKey = getSystemUserUpdateActionKey(options.username);
setActiveActionKey(actionKey);
setError("");
setNotice(null);
try {
const res = await serversApi.deleteSystemUser(
server.id,
options.username,
{
expectedUid: options.expectedUid,
expectedGid: options.expectedGid,
expectedHome: options.expectedHome,
expectedShell: options.expectedShell,
confirmation: options.confirmation,
removeHome: options.removeHome,
},
);
publishNotice({
tab: "users",
tone: "success",
title: "System User Deleted",
summary: res.message,
details: [
`User: ${res.data.username} (UID ${res.data.uid})`,
res.data.homeRemoved
? `Home directory removed: ${res.data.home}`
: `Home directory preserved: ${res.data.home}`,
],
});
onActionComplete(res.message);
await loadSnapshot();
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Failed to delete system user";
publishNotice({
tab: "users",
tone: "error",
title: "System User Deletion Failed",
summary: message,
details: [
`User: ${options.username}`,
"Refresh Server Config before retrying if the account changed or started a process.",
],
});
onActionComplete(message, "error");
} finally {
setActiveActionKey(null);
}
};
const handleSystemGroupDelete = async (options: {
groupName: string;
expectedGid: number;
expectedMembers: string[];
expectedPrimaryUsers: string[];
confirmation: string;
}) => {
const actionKey = getSystemGroupDeleteActionKey(options.groupName);
setActiveActionKey(actionKey);
setError("");
setNotice(null);
try {
const res = await serversApi.deleteSystemGroup(
server.id,
options.groupName,
{
expectedGid: options.expectedGid,
expectedMembers: options.expectedMembers,
expectedPrimaryUsers: options.expectedPrimaryUsers,
confirmation: options.confirmation,
},
);
publishNotice({
tab: "users",
tone: "success",
title: "System Group Deleted",
summary: res.message,
details: [`Group: ${res.data.groupName} (GID ${res.data.gid})`],
});
onActionComplete(res.message);
await loadSnapshot();
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Failed to delete system group";
publishNotice({
tab: "users",
tone: "error",
title: "System Group Deletion Failed",
summary: message,
details: [
`Group: ${options.groupName}`,
"Refresh Server Config before retrying if membership changed.",
],
});
onActionComplete(message, "error");
} finally {
setActiveActionKey(null);
}
};
const handleSshAccessUpdate = async (
options: ServerSshAccessUpdateBody,
) => {
setActiveActionKey(sshAccessActionKey);
setError("");
setNotice(null);
try {
const res = await serversApi.updateSshAccess(server.id, options);
publishNotice({
tab: "ssh-access",
tone: "success",
title: options.temporaryMinutes
? "Temporary Password Access Enabled"
: "SSH Access Policy Updated",
summary: res.message,
details: [
`Public key authentication: ${options.pubkeyAuthentication ? "enabled" : "disabled"}`,
`Password authentication: ${options.passwordAuthentication ? "enabled" : "disabled"}`,
`Root login: ${options.permitRootLogin === "no" ? "blocked" : "keys only"}`,
options.temporaryMinutes
? `Host-side rollback scheduled after ${options.temporaryMinutes} minutes`
: "No temporary rollback scheduled",
"A fresh SSH connection was verified after reload.",
],
});
onActionComplete(res.message);
await loadSnapshot();
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Failed to update SSH access";
publishNotice({
tab: "ssh-access",
tone: "error",
title: "SSH Access Update Failed",
summary: message,
details: [
`Server: ${server.name} (${server.ip})`,
"The backend attempted to restore the previous managed configuration before returning this error.",
"Keep the current SSH session open if the message reports that rollback could not be completed.",
],
});
onActionComplete(message, "error");
} finally {
setActiveActionKey(null);
}
};
const handleSystemUserUpdate = async (
username: string,
groups: string[],
@@ -693,28 +861,213 @@ export default function ServerConfigModal({
}
};
const handleSystemUserPassword = async (options: {
username: string;
action: "set" | "disable";
password?: string;
requireChange?: boolean;
noticeTab?: "users" | "ssh-access";
}) => {
const actionKey = getSystemUserUpdateActionKey(options.username);
setActiveActionKey(actionKey);
setError("");
setNotice(null);
try {
const res =
options.action === "set"
? await serversApi.setSystemUserPassword(server.id, options.username, {
password: options.password ?? "",
requireChange: options.requireChange ?? true,
})
: await serversApi.disableSystemUserPassword(
server.id,
options.username,
);
publishNotice({
tab: options.noticeTab ?? "users",
tone: "success",
title:
options.action === "set"
? "System User Password Updated"
: "System User Password Disabled",
summary: res.message,
details: [
`User: ${options.username}`,
options.action === "set" && options.requireChange
? "Password change required at next login"
: null,
"The password value was not stored in the audit log.",
].filter((value): value is string => Boolean(value)),
});
onActionComplete(res.message);
await loadSnapshot();
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Failed to update user password";
publishNotice({
tab: "users",
tone: "error",
title: "System User Password Action Failed",
summary: message,
details: [`User: ${options.username}`, `Server: ${server.name}`],
});
onActionComplete(message, "error");
} finally {
setActiveActionKey(null);
}
};
const handleSystemUserSshKey = async (options: {
username: string;
action: "add" | "revoke";
publicKey?: string;
label?: string;
fingerprint?: string;
expectedRevision: string;
}) => {
const actionKey = getSystemUserUpdateActionKey(options.username);
setActiveActionKey(actionKey);
setError("");
setNotice(null);
try {
const res =
options.action === "add"
? await serversApi.addSystemUserSshKey(server.id, options.username, {
publicKey: options.publicKey ?? "",
label: options.label,
expectedRevision: options.expectedRevision,
})
: await serversApi.revokeSystemUserSshKey(
server.id,
options.username,
{
fingerprint: options.fingerprint ?? "",
expectedRevision: options.expectedRevision,
},
);
publishNotice({
tab: "users",
tone: "success",
title:
options.action === "add"
? "SSH Public Key Added"
: "SSH Public Key Revoked",
summary: res.message,
details: [
`User: ${options.username}`,
options.fingerprint ? `Fingerprint: ${options.fingerprint}` : null,
"authorized_keys ownership and permissions were enforced.",
].filter((value): value is string => Boolean(value)),
});
onActionComplete(res.message);
await loadSnapshot();
} catch (err: unknown) {
const message =
err instanceof Error ? err.message : "Failed to update SSH public key";
publishNotice({
tab: "users",
tone: "error",
title: "SSH Public Key Action Failed",
summary: message,
details: [
`User: ${options.username}`,
"Refresh Server Config before retrying if authorized_keys changed outside Doktainer.",
],
});
onActionComplete(message, "error");
} finally {
setActiveActionKey(null);
}
};
const requestSystemUserCreateConfirm = (
username: string,
groups: string[],
options: Omit<ServerSystemUserCreateBody, "acknowledgePrivilegedGroups">,
) => {
const privilegedGroups = groups.filter((group) =>
const privilegedGroups = options.groups.filter((group) =>
["root", "docker", "sudo", "wheel"].includes(group),
);
const privileged = privilegedGroups.length > 0;
setPendingConfirm({
kind: "system-user",
username,
groups,
username: options.username,
groups: options.groups,
remoteLogin: options.remoteLogin,
credential: options.credential,
privileged,
title: `Create system user ${username}`,
description: privileged
? `This account will join privileged group(s): ${privilegedGroups.join(", ")}. This may grant administrative or root-equivalent host access.`
: `This creates a passwordless host account with a home directory and Bash shell${groups.length > 0 ? `, assigned to: ${groups.join(", ")}` : ""}.`,
title: `Create system user ${options.username}`,
description: [
options.remoteLogin
? `SSH login will be initialized with ${options.credential.type === "password" ? "a password" : "a public key"}.`
: "Remote login will be disabled with a nologin shell.",
options.groups.length > 0
? `Groups: ${options.groups.join(", ")}.`
: null,
privileged
? `Privileged group access requires extra care: ${privilegedGroups.join(", ")}.`
: null,
]
.filter(Boolean)
.join(" "),
confirmLabel: "Create System User",
tone: privileged ? "danger" : "info",
});
};
const requestSystemUserPasswordConfirm = (options: {
username: string;
action: "set" | "disable";
password?: string;
requireChange?: boolean;
noticeTab?: "users" | "ssh-access";
}) => {
setPendingConfirm({
kind: "system-user-password",
...options,
title:
options.action === "set"
? `Set password for ${options.username}`
: `Disable password for ${options.username}`,
description:
options.action === "set"
? `The password will be sent once through the encrypted SSH connection and will${options.requireChange ? "" : " not"} require replacement at the next login.`
: "Password authentication will be locked for this account. SSH public keys are unaffected.",
confirmLabel:
options.action === "set" ? "Set Password" : "Disable Password",
tone: "warning",
});
};
const requestSystemUserSshKeyAddConfirm = (options: {
username: string;
publicKey: string;
label?: string;
expectedRevision: string;
}) => {
setPendingConfirm({
kind: "system-user-key-add",
...options,
title: `Add SSH key for ${options.username}`,
description: `Add the public key${options.label ? ` labelled ${options.label}` : ""} to this account's authorized_keys file.`,
confirmLabel: "Add SSH Public Key",
tone: "warning",
});
};
const requestSystemUserSshKeyRevokeConfirm = (options: {
username: string;
fingerprint: string;
expectedRevision: string;
}) => {
setPendingConfirm({
kind: "system-user-key-revoke",
...options,
title: `Revoke SSH key for ${options.username}`,
description: `Remove ${options.fingerprint} from this account. Existing sessions remain open, but future login with this key will fail.`,
confirmLabel: "Revoke SSH Public Key",
tone: "danger",
});
};
const requestSystemGroupCreateConfirm = (groupName: string) => {
const privileged = ["root", "docker", "sudo", "wheel"].includes(
groupName,
@@ -770,6 +1123,92 @@ export default function ServerConfigModal({
});
};
const requestSystemUserDeleteConfirm = (options: {
user: ServerSystemUser;
confirmation: string;
removeHome: boolean;
}) => {
const { user } = options;
if (
user.uid == null ||
user.gid == null ||
!user.home ||
!user.shell
) {
return;
}
setPendingConfirm({
kind: "system-user-delete",
username: user.username,
expectedUid: user.uid,
expectedGid: user.gid,
expectedHome: user.home,
expectedShell: user.shell,
confirmation: options.confirmation,
removeHome: options.removeHome,
title: `Delete system user ${user.username}`,
description: options.removeHome
? `Permanently delete ${user.username} and its home directory ${user.home}. The host will reject this if the account is protected, changed, or owns active processes.`
: `Delete ${user.username} while preserving ${user.home}. The host will reject this if the account is protected, changed, or owns active processes.`,
confirmLabel: options.removeHome
? "Delete User & Home"
: "Delete User",
tone: "danger",
});
};
const requestSystemGroupDeleteConfirm = (options: {
groupName: string;
gid: number;
members: string[];
primaryUsers: string[];
confirmation: string;
}) => {
setPendingConfirm({
kind: "system-group-delete",
groupName: options.groupName,
expectedGid: options.gid,
expectedMembers: options.members,
expectedPrimaryUsers: options.primaryUsers,
confirmation: options.confirmation,
title: `Delete system group ${options.groupName}`,
description: `Permanently delete the empty group ${options.groupName} (GID ${options.gid}). System, privileged, changed, or in-use groups will be rejected by the host.`,
confirmLabel: "Delete Group",
tone: "danger",
});
};
const requestSshAccessUpdateConfirm = (
options: ServerSshAccessUpdateBody,
) => {
const weakened =
options.passwordAuthentication ||
!options.pubkeyAuthentication ||
options.permitRootLogin !== "no";
setPendingConfirm({
kind: "ssh-access",
options,
title: options.temporaryMinutes
? `Enable password login for ${options.temporaryMinutes} minutes`
: "Apply SSH access policy",
description: [
`Public keys: ${options.pubkeyAuthentication ? "enabled" : "disabled"}.`,
`Passwords: ${options.passwordAuthentication ? "enabled" : "disabled"}.`,
`Root login: ${options.permitRootLogin === "no" ? "blocked" : "keys only"}.`,
"Empty passwords: blocked.",
options.temporaryMinutes
? `The host will restore the previous managed policy after ${options.temporaryMinutes} minutes.`
: null,
]
.filter(Boolean)
.join(" "),
confirmLabel: options.temporaryMinutes
? "Enable Temporary Access"
: "Apply SSH Policy",
tone: weakened ? "danger" : "warning",
});
};
const renderActiveTab = () => {
if (!snapshot) {
return null;
@@ -789,12 +1228,32 @@ export default function ServerConfigModal({
<ServerConfigUsersPanel
snapshot={snapshot}
snapshotLoadError={snapshotLoadError}
serverAuthType={server.authType}
canManageSystemAccounts={canManageSystemAccounts}
isActionRunning={isActionRunning}
getSystemUserUpdateActionKey={getSystemUserUpdateActionKey}
onRequestCreateUserConfirm={requestSystemUserCreateConfirm}
onRequestCreateGroupConfirm={requestSystemGroupCreateConfirm}
onRequestUpdateUserConfirm={requestSystemUserUpdateConfirm}
onRequestPasswordConfirm={requestSystemUserPasswordConfirm}
onRequestSshKeyAddConfirm={requestSystemUserSshKeyAddConfirm}
onRequestSshKeyRevokeConfirm={
requestSystemUserSshKeyRevokeConfirm
}
onRequestDeleteUserConfirm={requestSystemUserDeleteConfirm}
onRequestDeleteGroupConfirm={requestSystemGroupDeleteConfirm}
/>
);
case "ssh-access":
return (
<ServerConfigSshAccessPanel
server={server}
snapshot={snapshot}
snapshotLoadError={snapshotLoadError}
canManageSystemAccounts={canManageSystemAccounts}
actionRunning={isActionRunning(sshAccessActionKey)}
onRequestApplyConfirm={requestSshAccessUpdateConfirm}
onRequestPasswordConfirm={requestSystemUserPasswordConfirm}
/>
);
case "services":
@@ -1069,7 +1528,13 @@ export default function ServerConfigModal({
>
{pendingConfirm.kind === "system-user" ||
pendingConfirm.kind === "system-group" ||
pendingConfirm.kind === "system-user-update"
pendingConfirm.kind === "system-user-update" ||
pendingConfirm.kind === "system-user-password" ||
pendingConfirm.kind === "system-user-key-add" ||
pendingConfirm.kind === "system-user-key-revoke" ||
pendingConfirm.kind === "system-user-delete" ||
pendingConfirm.kind === "system-group-delete" ||
pendingConfirm.kind === "ssh-access"
? "This changes host access control. Review the exact account and group names before continuing; the action will be recorded in the audit log."
: "Confirm this action only if you expect a brief service interruption or cleanup change on the host."}
</div>
@@ -1121,13 +1586,79 @@ export default function ServerConfigModal({
if (currentConfirm.kind === "system-user") {
void handleSystemUserCreate(
currentConfirm.username,
currentConfirm.groups,
{
username: currentConfirm.username,
groups: currentConfirm.groups,
remoteLogin: currentConfirm.remoteLogin,
credential: currentConfirm.credential,
},
currentConfirm.privileged,
);
return;
}
if (currentConfirm.kind === "system-user-password") {
void handleSystemUserPassword({
username: currentConfirm.username,
action: currentConfirm.action,
password: currentConfirm.password,
requireChange: currentConfirm.requireChange,
noticeTab: currentConfirm.noticeTab,
});
return;
}
if (currentConfirm.kind === "system-user-key-add") {
void handleSystemUserSshKey({
username: currentConfirm.username,
action: "add",
publicKey: currentConfirm.publicKey,
label: currentConfirm.label,
expectedRevision: currentConfirm.expectedRevision,
});
return;
}
if (currentConfirm.kind === "system-user-key-revoke") {
void handleSystemUserSshKey({
username: currentConfirm.username,
action: "revoke",
fingerprint: currentConfirm.fingerprint,
expectedRevision: currentConfirm.expectedRevision,
});
return;
}
if (currentConfirm.kind === "system-user-delete") {
void handleSystemUserDelete({
username: currentConfirm.username,
expectedUid: currentConfirm.expectedUid,
expectedGid: currentConfirm.expectedGid,
expectedHome: currentConfirm.expectedHome,
expectedShell: currentConfirm.expectedShell,
confirmation: currentConfirm.confirmation,
removeHome: currentConfirm.removeHome,
});
return;
}
if (currentConfirm.kind === "system-group-delete") {
void handleSystemGroupDelete({
groupName: currentConfirm.groupName,
expectedGid: currentConfirm.expectedGid,
expectedMembers: currentConfirm.expectedMembers,
expectedPrimaryUsers:
currentConfirm.expectedPrimaryUsers,
confirmation: currentConfirm.confirmation,
});
return;
}
if (currentConfirm.kind === "ssh-access") {
void handleSshAccessUpdate(currentConfirm.options);
return;
}
if (currentConfirm.kind === "system-group") {
void handleSystemGroupCreate(
currentConfirm.groupName,
@@ -1,6 +1,8 @@
import type {
Server as ServerType,
ServerConfigSnapshot,
ServerSshAccessUpdateBody,
ServerSystemUserCreateBody,
WebStackAction,
WebStackComponentKey,
WebStackComponentStatus,
@@ -9,6 +11,7 @@ import type {
export type ServerConfigTab =
| "overview"
| "users"
| "ssh-access"
| "services"
| "web-server"
| "mounts"
@@ -50,12 +53,47 @@ export type ServerPendingConfirm =
kind: "system-user";
username: string;
groups: string[];
remoteLogin: boolean;
credential: ServerSystemUserCreateBody["credential"];
privileged: boolean;
title: string;
description: string;
confirmLabel: string;
tone: "danger" | "warning" | "info";
}
| {
kind: "system-user-password";
action: "set" | "disable";
username: string;
password?: string;
requireChange?: boolean;
noticeTab?: "users" | "ssh-access";
title: string;
description: string;
confirmLabel: string;
tone: "danger" | "warning" | "info";
}
| {
kind: "system-user-key-add";
username: string;
publicKey: string;
label?: string;
expectedRevision: string;
title: string;
description: string;
confirmLabel: string;
tone: "danger" | "warning" | "info";
}
| {
kind: "system-user-key-revoke";
username: string;
fingerprint: string;
expectedRevision: string;
title: string;
description: string;
confirmLabel: string;
tone: "danger" | "warning" | "info";
}
| {
kind: "system-group";
groupName: string;
@@ -78,6 +116,40 @@ export type ServerPendingConfirm =
confirmLabel: string;
tone: "danger" | "warning" | "info";
}
| {
kind: "system-user-delete";
username: string;
expectedUid: number;
expectedGid: number;
expectedHome: string;
expectedShell: string;
confirmation: string;
removeHome: boolean;
title: string;
description: string;
confirmLabel: string;
tone: "danger";
}
| {
kind: "system-group-delete";
groupName: string;
expectedGid: number;
expectedMembers: string[];
expectedPrimaryUsers: string[];
confirmation: string;
title: string;
description: string;
confirmLabel: string;
tone: "danger";
}
| {
kind: "ssh-access";
options: ServerSshAccessUpdateBody;
title: string;
description: string;
confirmLabel: string;
tone: "danger" | "warning" | "info";
}
| {
kind: "docker";
action: "install" | "uninstall" | "reinstall";
@@ -440,6 +512,21 @@ export function createUnavailableServerConfigSnapshot(
rootUser: null,
nonRootUsers: [],
systemGroups: [],
systemGroupDetails: [],
uidMin: null,
gidMin: null,
sshAccess: {
available: false,
pubkeyAuthentication: null,
passwordAuthentication: null,
keyboardInteractiveAuthentication: null,
permitRootLogin: null,
permitEmptyPasswords: null,
revision: null,
managed: false,
temporaryRollbackScheduled: false,
error: loadError,
},
hasRootUser: false,
sudoNonInteractive: false,
docker: {
@@ -0,0 +1,893 @@
"use client";
import { useMemo, useState } from "react";
import {
AlertTriangle,
CheckCircle2,
ChevronDown,
ChevronUp,
Eye,
EyeOff,
KeyRound,
Loader2,
RotateCcw,
ServerCog,
ShieldCheck,
} from "lucide-react";
import type {
Server as ServerType,
ServerConfigSnapshot,
ServerSshAccessUpdateBody,
} from "@/lib/api";
import { UserBadge } from "@/app/servers/components/server-config/ServerConfigPrimitives";
interface ServerConfigSshAccessPanelProps {
server: ServerType;
snapshot: ServerConfigSnapshot;
snapshotLoadError?: string | null;
canManageSystemAccounts: boolean;
actionRunning: boolean;
onRequestApplyConfirm: (options: ServerSshAccessUpdateBody) => void;
onRequestPasswordConfirm: (options: {
username: string;
action: "set";
password: string;
requireChange: boolean;
noticeTab?: "ssh-access";
}) => void;
}
function PolicySwitch({
checked,
disabled = false,
label,
onChange,
}: {
checked: boolean;
disabled?: boolean;
label: string;
onChange: (checked: boolean) => void;
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
aria-label={label}
disabled={disabled}
onClick={() => onChange(!checked)}
style={{
width: 40,
height: 22,
flex: "0 0 auto",
padding: 2,
borderRadius: 999,
border: checked
? "1px solid rgba(16,185,129,0.5)"
: "1px solid var(--border)",
background: checked ? "rgba(16,185,129,0.2)" : "var(--bg-input)",
cursor: disabled ? "not-allowed" : "pointer",
opacity: disabled ? 0.6 : 1,
}}
>
<span
style={{
display: "block",
width: 16,
height: 16,
borderRadius: "50%",
background: checked ? "#10b981" : "var(--text-muted)",
transform: checked ? "translateX(17px)" : "translateX(0)",
transition: "transform 150ms ease",
}}
/>
</button>
);
}
function SettingRow({
title,
description,
children,
}: {
title: string;
description: string;
children: React.ReactNode;
}) {
return (
<div
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 14,
padding: "12px 0",
borderBottom: "1px solid var(--border)",
}}
>
<div style={{ minWidth: 0 }}>
<strong
style={{ display: "block", color: "var(--text-primary)", fontSize: 13 }}
>
{title}
</strong>
<span
style={{
display: "block",
marginTop: 4,
color: "var(--text-muted)",
fontSize: 11,
lineHeight: 1.45,
}}
>
{description}
</span>
</div>
{children}
</div>
);
}
type LoginPreset = "key-only" | "key-password" | "temporary";
export default function ServerConfigSshAccessPanel({
server,
snapshot,
snapshotLoadError,
canManageSystemAccounts,
actionRunning,
onRequestApplyConfirm,
onRequestPasswordConfirm,
}: ServerConfigSshAccessPanelProps) {
const current = snapshot.sshAccess;
const initialPubkey = current.pubkeyAuthentication ?? true;
const initialPassword =
current.passwordAuthentication ?? server.authType === "PASSWORD";
const initialRoot =
current.permitRootLogin === "prohibit-password"
? "prohibit-password"
: server.username === "root" && server.authType === "SSH_KEY"
? "prohibit-password"
: "no";
const [publicKeyAuthentication, setPublicKeyAuthentication] =
useState(initialPubkey);
const [passwordAuthentication, setPasswordAuthentication] =
useState(initialPassword);
const [permitRootLogin, setPermitRootLogin] = useState<
"no" | "prohibit-password"
>(initialRoot);
const [selectedPreset, setSelectedPreset] = useState<LoginPreset>(() =>
current.temporaryRollbackScheduled
? "temporary"
: initialPassword
? "key-password"
: "key-only",
);
const [temporaryDuration, setTemporaryDuration] = useState<
15 | 30 | 60 | 240
>(15);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [passwordUsername, setPasswordUsername] = useState(
() =>
snapshot.nonRootUsers.find((user) => !user.isSshUser)?.username ??
snapshot.nonRootUsers[0]?.username ??
"",
);
const [temporaryPassword, setTemporaryPassword] = useState("");
const [confirmTemporaryPassword, setConfirmTemporaryPassword] = useState("");
const [showTemporaryPassword, setShowTemporaryPassword] = useState(false);
const [forcePasswordChange, setForcePasswordChange] = useState(true);
const [passwordError, setPasswordError] = useState<string | null>(null);
const activeMethodLocked =
(server.authType === "PASSWORD" && !passwordAuthentication) ||
(server.authType === "SSH_KEY" && !publicKeyAuthentication);
const rootConnectionBlocked =
server.username === "root" &&
(permitRootLogin === "no" || server.authType === "PASSWORD");
const noAuthenticationMethod =
!publicKeyAuthentication && !passwordAuthentication;
const temporaryUnsupported = server.authType !== "SSH_KEY";
const temporaryPresetBlocked =
temporaryUnsupported ||
(current.passwordAuthentication === true &&
!current.temporaryRollbackScheduled);
const temporaryAlreadyEnabled =
selectedPreset === "temporary" && current.passwordAuthentication === true;
const temporaryMinutes =
selectedPreset === "temporary" && !temporaryAlreadyEnabled
? temporaryDuration
: null;
const hasChanges =
publicKeyAuthentication !== current.pubkeyAuthentication ||
passwordAuthentication !== current.passwordAuthentication ||
permitRootLogin !== current.permitRootLogin ||
temporaryMinutes != null ||
(current.temporaryRollbackScheduled && selectedPreset !== "temporary");
const formBlocked =
!current.available ||
!current.revision ||
activeMethodLocked ||
rootConnectionBlocked ||
noAuthenticationMethod ||
(selectedPreset === "temporary" && temporaryPresetBlocked);
const configPreview = useMemo(
() =>
[
`PubkeyAuthentication ${publicKeyAuthentication ? "yes" : "no"}`,
`PasswordAuthentication ${passwordAuthentication ? "yes" : "no"}`,
"KbdInteractiveAuthentication no",
`PermitRootLogin ${permitRootLogin}`,
"PermitEmptyPasswords no",
].join("\n"),
[passwordAuthentication, permitRootLogin, publicKeyAuthentication],
);
const selectPreset = (preset: LoginPreset) => {
if (preset === "temporary" && temporaryPresetBlocked) return;
setSelectedPreset(preset);
setPublicKeyAuthentication(true);
setPasswordAuthentication(preset !== "key-only");
};
const resetForm = () => {
setPublicKeyAuthentication(initialPubkey);
setPasswordAuthentication(initialPassword);
setPermitRootLogin(initialRoot);
setSelectedPreset(
current.temporaryRollbackScheduled
? "temporary"
: initialPassword
? "key-password"
: "key-only",
);
setTemporaryDuration(15);
};
const reviewTemporaryPassword = () => {
if (!passwordUsername) {
setPasswordError("Select a non-root user first.");
return;
}
if (temporaryPassword.length < 12 || temporaryPassword.length > 128) {
setPasswordError("Use a password between 12 and 128 characters.");
return;
}
if (
temporaryPassword.toLowerCase().includes(passwordUsername.toLowerCase())
) {
setPasswordError("The password must not contain the username.");
return;
}
if (temporaryPassword !== confirmTemporaryPassword) {
setPasswordError("Password confirmation does not match.");
return;
}
onRequestPasswordConfirm({
username: passwordUsername,
action: "set",
password: temporaryPassword,
requireChange: forcePasswordChange,
noticeTab: "ssh-access",
});
setTemporaryPassword("");
setConfirmTemporaryPassword("");
setShowTemporaryPassword(false);
setPasswordError(null);
};
return (
<div style={{ display: "grid", gap: 16 }}>
<div
className="card"
style={{
padding: 18,
display: "flex",
justifyContent: "space-between",
alignItems: "flex-start",
gap: 14,
flexWrap: "wrap",
borderColor: "rgba(59,130,246,0.24)",
background:
"linear-gradient(135deg, rgba(59,130,246,0.08), rgba(16,185,129,0.04))",
}}
>
<div style={{ display: "flex", alignItems: "flex-start", gap: 10 }}>
<ShieldCheck size={18} color="#3b82f6" style={{ marginTop: 1 }} />
<div>
<strong style={{ color: "var(--text-primary)", fontSize: 14 }}>
SSH Access
</strong>
<p
style={{
maxWidth: 650,
marginTop: 6,
color: "var(--text-muted)",
fontSize: 13,
lineHeight: 1.55,
}}
>
Manage host-wide SSH authentication without changing the SSH
port. Every apply is validated, reloaded, reconnected, and audited.
</p>
</div>
</div>
<UserBadge
label={current.managed ? "Managed by Doktainer" : "Host policy"}
tone={current.available ? "success" : "warning"}
/>
</div>
{snapshotLoadError || !current.available ? (
<div
className="card"
style={{
padding: 12,
color: "#b45309",
background: "rgba(245,158,11,0.08)",
borderColor: "rgba(245,158,11,0.22)",
fontSize: 12,
lineHeight: 1.5,
}}
>
{current.error || snapshotLoadError ||
"The effective SSH policy is unavailable. No change can be applied."}
</div>
) : null}
<div
style={{
display: "grid",
gridTemplateColumns:
"repeat(auto-fit, minmax(min(210px, 100%), 1fr))",
gap: 12,
}}
>
{[
{
label: "Connected user",
value: snapshot.currentUser ?? server.username,
detail:
server.authType === "SSH_KEY"
? "Doktainer uses an SSH key"
: "Doktainer uses a password",
},
{
label: "Connection port",
value: String(server.sshPort),
detail: "Port changes are excluded",
},
{
label: "Effective login",
value: current.passwordAuthentication ? "Key + password" : "Key only",
detail: "Read with sshd -T",
},
{
label: "Root login",
value:
current.permitRootLogin === "no"
? "Blocked"
: current.permitRootLogin === "prohibit-password"
? "Keys only"
: "Allowed",
detail: "Empty passwords are always blocked",
},
].map((item) => (
<div
key={item.label}
className="card"
style={{ padding: 14, display: "grid", gap: 5 }}
>
<span style={{ color: "var(--text-muted)", fontSize: 11 }}>
{item.label}
</span>
<strong style={{ color: "var(--text-primary)", fontSize: 13 }}>
{item.value}
</strong>
<span style={{ color: "var(--text-muted)", fontSize: 10 }}>
{item.detail}
</span>
</div>
))}
</div>
{current.temporaryRollbackScheduled ? (
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 9,
padding: 12,
borderRadius: 9,
color: "#b45309",
background: "rgba(245,158,11,0.08)",
border: "1px solid rgba(245,158,11,0.22)",
fontSize: 12,
lineHeight: 1.5,
}}
>
<AlertTriangle size={15} style={{ flex: "0 0 auto" }} />
A host-side timer is scheduled to restore the previous SSH policy.
Applying a permanent policy will cancel that timer.
</div>
) : null}
<div className="card" style={{ padding: 18, display: "grid", gap: 14 }}>
<div>
<strong style={{ color: "var(--text-primary)", fontSize: 14 }}>
How should users sign in?
</strong>
<p style={{ marginTop: 5, color: "var(--text-muted)", fontSize: 12 }}>
Choose a preset first. Advanced settings remain available below.
</p>
</div>
<div
style={{
display: "grid",
gridTemplateColumns:
"repeat(auto-fit, minmax(min(210px, 100%), 1fr))",
gap: 10,
}}
>
{[
{
value: "key-only" as const,
title: "Key only",
badge: "Recommended",
description: "Production policy using authorized public keys.",
disabled: server.authType === "PASSWORD",
},
{
value: "key-password" as const,
title: "Key and password",
badge: "Easy setup",
description: "Allow either a public key or account password.",
disabled: false,
},
{
value: "temporary" as const,
title: "Temporary password",
badge: `${temporaryDuration} min`,
description: "Enable password login with host-side rollback.",
disabled: temporaryPresetBlocked,
},
].map((preset) => {
const selected = selectedPreset === preset.value;
return (
<button
key={preset.value}
type="button"
className="btn"
disabled={preset.disabled}
onClick={() => selectPreset(preset.value)}
title={
preset.disabled
? "Change Doktainer to an SSH key connection before selecting this policy"
: undefined
}
style={{
minHeight: 96,
height: "auto",
padding: 12,
display: "grid",
justifyItems: "start",
alignContent: "center",
gap: 6,
textAlign: "left",
opacity: preset.disabled ? 0.6 : 1,
borderColor: selected
? "rgba(59,130,246,0.5)"
: "var(--border)",
background: selected
? "rgba(59,130,246,0.1)"
: "var(--bg-input)",
}}
>
<span
style={{
display: "flex",
justifyContent: "space-between",
alignItems: "center",
gap: 8,
width: "100%",
}}
>
<strong
style={{
color: selected ? "#3b82f6" : "var(--text-primary)",
fontSize: 13,
}}
>
{preset.title}
</strong>
<UserBadge
label={preset.badge}
tone={
preset.value === "key-only"
? "success"
: preset.value === "temporary"
? "warning"
: "info"
}
/>
</span>
<span
style={{ color: "var(--text-muted)", fontSize: 10, lineHeight: 1.45 }}
>
{preset.description}
</span>
</button>
);
})}
</div>
{selectedPreset === "temporary" && !temporaryAlreadyEnabled ? (
<label
style={{
display: "grid",
gridTemplateColumns: "minmax(0, 1fr) minmax(130px, 180px)",
alignItems: "center",
gap: 12,
padding: 10,
borderRadius: 8,
background: "rgba(245,158,11,0.07)",
border: "1px solid rgba(245,158,11,0.2)",
}}
>
<span style={{ color: "var(--text-secondary)", fontSize: 11 }}>
Restore the previous policy after
</span>
<select
className="input"
value={temporaryDuration}
onChange={(event) =>
setTemporaryDuration(
Number(event.target.value) as 15 | 30 | 60 | 240,
)
}
>
<option value={15}>15 minutes</option>
<option value={30}>30 minutes</option>
<option value={60}>1 hour</option>
<option value={240}>4 hours</option>
</select>
</label>
) : null}
</div>
{selectedPreset === "temporary" ? (
<div
className="card"
style={{
padding: 18,
display: "grid",
gap: 12,
borderColor: "rgba(245,158,11,0.28)",
}}
>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
<KeyRound size={15} color="#f59e0b" />
<strong style={{ color: "var(--text-primary)", fontSize: 14 }}>
Optional account password
</strong>
</div>
<p style={{ color: "var(--text-muted)", fontSize: 11, lineHeight: 1.5 }}>
Enabling password authentication does not create an account password.
Set one here only when the selected user does not already have it.
</p>
{snapshot.nonRootUsers.length > 0 ? (
<>
<select
className="input"
value={passwordUsername}
onChange={(event) => {
setPasswordUsername(event.target.value);
setPasswordError(null);
}}
>
{snapshot.nonRootUsers.map((user) => (
<option key={user.username} value={user.username}>
{user.username}
{user.passwordStatus === "set" ? " (password configured)" : ""}
</option>
))}
</select>
<div style={{ position: "relative" }}>
<input
className="input"
type={showTemporaryPassword ? "text" : "password"}
value={temporaryPassword}
maxLength={128}
autoComplete="new-password"
onChange={(event) => {
setTemporaryPassword(event.target.value);
setPasswordError(null);
}}
placeholder="Password (minimum 12 characters)"
style={{ width: "100%", paddingRight: 40 }}
/>
<button
type="button"
className="btn btn-ghost btn-sm"
onClick={() => setShowTemporaryPassword((value) => !value)}
aria-label={showTemporaryPassword ? "Hide password" : "Show password"}
style={{
position: "absolute",
top: "50%",
right: 5,
width: 28,
minWidth: 28,
height: 28,
padding: 0,
transform: "translateY(-50%)",
}}
>
{showTemporaryPassword ? <EyeOff size={13} /> : <Eye size={13} />}
</button>
</div>
<input
className="input"
type={showTemporaryPassword ? "text" : "password"}
value={confirmTemporaryPassword}
maxLength={128}
autoComplete="new-password"
onChange={(event) => {
setConfirmTemporaryPassword(event.target.value);
setPasswordError(null);
}}
placeholder="Confirm password"
/>
<label
style={{
display: "flex",
alignItems: "center",
gap: 8,
color: "var(--text-secondary)",
fontSize: 11,
}}
>
<input
type="checkbox"
checked={forcePasswordChange}
onChange={(event) => setForcePasswordChange(event.target.checked)}
/>
Require password change at next login
</label>
{passwordError ? (
<p role="alert" style={{ color: "#ef4444", fontSize: 11 }}>
{passwordError}
</p>
) : null}
<button
type="button"
className="btn btn-sm"
disabled={!temporaryPassword || !confirmTemporaryPassword}
onClick={reviewTemporaryPassword}
style={{ justifyContent: "center" }}
>
<KeyRound size={13} /> Review Password Change
</button>
</>
) : (
<p style={{ color: "var(--text-muted)", fontSize: 11 }}>
Create a non-root user from the Users tab first.
</p>
)}
</div>
) : null}
<button
type="button"
className="btn"
onClick={() => setAdvancedOpen((value) => !value)}
aria-expanded={advancedOpen}
style={{ width: "100%", justifyContent: "space-between", padding: "10px 14px" }}
>
<span style={{ display: "flex", alignItems: "center", gap: 8 }}>
<ServerCog size={14} /> Advanced SSH configuration
</span>
{advancedOpen ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</button>
{advancedOpen ? (
<div
style={{
display: "grid",
gridTemplateColumns:
"repeat(auto-fit, minmax(min(320px, 100%), 1fr))",
gap: 16,
alignItems: "start",
}}
>
<div className="card" style={{ padding: 18, display: "grid", gap: 2 }}>
<SettingRow
title="Public key authentication"
description="Required while Doktainer connects with an SSH key."
>
<PolicySwitch
checked={publicKeyAuthentication}
disabled={server.authType === "SSH_KEY"}
label="Public key authentication"
onChange={setPublicKeyAuthentication}
/>
</SettingRow>
<SettingRow
title="Password authentication"
description="Required while Doktainer connects with a password."
>
<PolicySwitch
checked={passwordAuthentication}
disabled={server.authType === "PASSWORD"}
label="Password authentication"
onChange={setPasswordAuthentication}
/>
</SettingRow>
<SettingRow
title="Permit root login"
description="Direct root password login is never offered."
>
<select
className="input"
value={permitRootLogin}
onChange={(event) =>
setPermitRootLogin(
event.target.value as "no" | "prohibit-password",
)
}
style={{ width: 150, minWidth: 150, fontSize: 12 }}
>
<option value="no">No</option>
<option value="prohibit-password">Keys only</option>
</select>
</SettingRow>
<SettingRow
title="Permit empty passwords"
description="Hard-disabled and unavailable for editing."
>
<PolicySwitch
checked={false}
disabled
label="Permit empty passwords"
onChange={() => undefined}
/>
</SettingRow>
</div>
<div className="card" style={{ padding: 18, display: "grid", gap: 12 }}>
<strong style={{ color: "var(--text-primary)", fontSize: 14 }}>
Managed drop-in
</strong>
<code
style={{
display: "block",
padding: 12,
borderRadius: 8,
color: "var(--text-primary)",
background: "var(--bg-input)",
border: "1px solid var(--border)",
fontSize: 11,
lineHeight: 1.7,
whiteSpace: "pre-wrap",
}}
>
{configPreview}
</code>
</div>
</div>
) : null}
{(activeMethodLocked || rootConnectionBlocked || noAuthenticationMethod) ? (
<div
style={{
display: "flex",
alignItems: "flex-start",
gap: 9,
padding: 12,
borderRadius: 9,
color: "#b45309",
background: "rgba(245,158,11,0.08)",
border: "1px solid rgba(245,158,11,0.22)",
fontSize: 12,
lineHeight: 1.5,
}}
>
<AlertTriangle size={15} style={{ flex: "0 0 auto" }} />
{rootConnectionBlocked
? "The saved Doktainer connection still uses root. Move it to a non-root account before disabling root login."
: activeMethodLocked
? "The authentication method used by Doktainer must remain enabled."
: "At least one authentication method must remain enabled."}
</div>
) : null}
<div
className="card"
style={{
padding: 18,
display: "grid",
gridTemplateColumns:
"repeat(auto-fit, minmax(min(280px, 100%), 1fr))",
gap: 16,
alignItems: "center",
}}
>
<div style={{ display: "grid", gap: 8 }}>
{[
"Write /etc/ssh/sshd_config.d/00-doktainer.conf atomically",
"Validate combined configuration with sshd -t and sshd -T",
"Reload SSH without restarting the daemon",
"Verify a fresh connection and rollback on failure",
].map((step) => (
<span
key={step}
style={{
display: "flex",
alignItems: "center",
gap: 7,
color: "var(--text-secondary)",
fontSize: 11,
}}
>
<CheckCircle2 size={13} color="#10b981" /> {step}
</span>
))}
</div>
<div style={{ display: "grid", gap: 9 }}>
<button
type="button"
className="btn btn-primary"
disabled={
formBlocked ||
!hasChanges ||
actionRunning ||
!canManageSystemAccounts
}
onClick={() => {
if (!current.revision) return;
onRequestApplyConfirm({
expectedRevision: current.revision,
pubkeyAuthentication: publicKeyAuthentication,
passwordAuthentication,
permitRootLogin,
permitEmptyPasswords: false,
temporaryMinutes,
});
}}
style={{ justifyContent: "center" }}
>
{actionRunning ? (
<Loader2 size={14} className="animate-spin" />
) : (
<ShieldCheck size={14} />
)}
Review & Apply SSH Policy
</button>
<button
type="button"
className="btn"
disabled={actionRunning}
onClick={resetForm}
style={{ justifyContent: "center" }}
>
<RotateCcw size={14} /> Reset to Effective Policy
</button>
</div>
</div>
<div
className="card"
style={{
padding: 14,
display: "flex",
alignItems: "flex-start",
gap: 9,
color: "var(--text-secondary)",
fontSize: 11,
lineHeight: 1.55,
}}
>
<KeyRound size={15} color="#3b82f6" style={{ flex: "0 0 auto" }} />
Public keys remain managed per account from Users → Edit → Login Access.
This tab controls the host-wide authentication policy only.
</div>
</div>
);
}
File diff suppressed because it is too large. Load diff
+155
View File
@@ -1340,6 +1340,35 @@ export interface ServerSystemUser {
primaryGroup: string | null;
isRoot: boolean;
isSshUser: boolean;
passwordStatus: "set" | "locked" | "not-set" | "unknown";
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 ServerSshAccessStatus {
available: boolean;
pubkeyAuthentication: boolean | null;
passwordAuthentication: boolean | null;
keyboardInteractiveAuthentication: boolean | null;
permitRootLogin: "no" | "prohibit-password" | "yes" | null;
permitEmptyPasswords: boolean | null;
revision: string | null;
managed: boolean;
temporaryRollbackScheduled: boolean;
error: string | null;
}
export interface ServerServiceStatus {
@@ -1432,6 +1461,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;
@@ -1462,6 +1495,11 @@ export interface ServerSystemUserCreateBody {
username: string;
groups: string[];
acknowledgePrivilegedGroups: boolean;
remoteLogin: boolean;
credential:
| { type: "none" }
| { type: "password"; password: string; requireChange: boolean }
| { type: "ssh-key"; publicKey: string; label?: string };
}
export interface ServerSystemGroupCreateBody {
@@ -1486,6 +1524,38 @@ export interface ServerSystemUserUpdateResult {
shell: string;
}
export interface ServerSystemCredentialResult {
username: string;
passwordStatus?: ServerSystemUser["passwordStatus"];
sshKeys?: ServerSystemSshKey[];
authorizedKeysRevision?: string;
}
export interface ServerSystemUserDeleteBody {
expectedUid: number;
expectedGid: number;
expectedHome: string;
expectedShell: string;
confirmation: string;
removeHome: boolean;
}
export interface ServerSystemGroupDeleteBody {
expectedGid: number;
expectedMembers: string[];
expectedPrimaryUsers: string[];
confirmation: string;
}
export interface ServerSshAccessUpdateBody {
expectedRevision: string;
pubkeyAuthentication: boolean;
passwordAuthentication: boolean;
permitRootLogin: "no" | "prohibit-password";
permitEmptyPasswords: false;
temporaryMinutes: 15 | 30 | 60 | 240 | null;
}
export const servers = {
list: () =>
get<{ success: boolean; data: Server[] }>("/servers", { timeoutMs: 10000 }),
@@ -1541,6 +1611,91 @@ export const servers = {
body,
{ timeoutMs: 30000 },
),
setSystemUserPassword: (
id: string,
username: string,
body: { password: string; requireChange: boolean },
) =>
put<{
success: boolean;
data: ServerSystemCredentialResult;
message: string;
}>(
`/servers/${id}/system-users/${encodeURIComponent(username)}/password`,
body,
{ timeoutMs: 30000 },
),
disableSystemUserPassword: (id: string, username: string) =>
del<{
success: boolean;
data: ServerSystemCredentialResult;
message: string;
}>(
`/servers/${id}/system-users/${encodeURIComponent(username)}/password`,
undefined,
{ timeoutMs: 30000 },
),
addSystemUserSshKey: (
id: string,
username: string,
body: { publicKey: string; label?: string; expectedRevision: string },
) =>
post<{
success: boolean;
data: ServerSystemCredentialResult;
message: string;
}>(
`/servers/${id}/system-users/${encodeURIComponent(username)}/ssh-keys`,
body,
{ timeoutMs: 30000 },
),
revokeSystemUserSshKey: (
id: string,
username: string,
body: { fingerprint: string; expectedRevision: string },
) =>
del<{
success: boolean;
data: ServerSystemCredentialResult;
message: string;
}>(
`/servers/${id}/system-users/${encodeURIComponent(username)}/ssh-keys`,
body,
{ timeoutMs: 30000 },
),
deleteSystemUser: (
id: string,
username: string,
body: ServerSystemUserDeleteBody,
) =>
del<{
success: boolean;
data: { username: string; uid: number; home: string; homeRemoved: boolean };
message: string;
}>(`/servers/${id}/system-users/${encodeURIComponent(username)}`, body, {
timeoutMs: 130000,
}),
deleteSystemGroup: (
id: string,
groupName: string,
body: ServerSystemGroupDeleteBody,
) =>
del<{
success: boolean;
data: { groupName: string; gid: number };
message: string;
}>(`/servers/${id}/system-groups/${encodeURIComponent(groupName)}`, body, {
timeoutMs: 30000,
}),
updateSshAccess: (id: string, body: ServerSshAccessUpdateBody) =>
put<{
success: boolean;
data: {
status: ServerSshAccessStatus;
temporaryMinutes: number | null;
};
message: string;
}>(`/servers/${id}/ssh-access`, body, { timeoutMs: 60000 }),
reset: (id: string, confirmation: string) =>
post<{ success: boolean; message: string }>(
`/servers/${id}/reset`,
+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",
"[email protected]",
"[email protected]",
]);
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 };
}
+168
View File
@@ -0,0 +1,168 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
getSshConfigurationRevision,
parseEffectiveSshPolicy,
validateSshAccessPolicy,
} from "../../src/server/services/ssh-services/internal/ssh-access-security";
import {
SystemAccountConflictError,
SystemAccountInputError,
} from "../../src/server/services/ssh-services/internal/system-account-security";
const basePolicy = {
serverUsername: "deploy",
serverAuthType: "SSH_KEY" as const,
currentRevision: "a".repeat(64),
expectedRevision: "a".repeat(64),
currentPasswordAuthentication: false,
desired: {
pubkeyAuthentication: true,
passwordAuthentication: false,
permitRootLogin: "no" as const,
permitEmptyPasswords: false as const,
},
temporaryMinutes: null,
};
test("effective SSH policy parsing normalizes root key-only aliases", () => {
assert.deepEqual(
parseEffectiveSshPolicy([
"pubkeyauthentication yes",
"passwordauthentication no",
"permitrootlogin without-password",
"permitemptypasswords no",
].join("\n")),
{
pubkeyAuthentication: true,
passwordAuthentication: false,
permitRootLogin: "prohibit-password",
permitEmptyPasswords: false,
},
);
assert.equal(
parseEffectiveSshPolicy([
"pubkeyauthentication yes",
"passwordauthentication yes",
"permitrootlogin yes",
"permitemptypasswords yes",
].join("\n"))?.permitEmptyPasswords,
true,
);
});
test("SSH policy revision is stable and content-sensitive", () => {
assert.equal(getSshConfigurationRevision("same"), getSshConfigurationRevision("same"));
assert.notEqual(getSshConfigurationRevision("same"), getSshConfigurationRevision("changed"));
});
test("SSH policy keeps the active Doktainer authentication method", () => {
assert.doesNotThrow(() => validateSshAccessPolicy(basePolicy));
assert.throws(
() =>
validateSshAccessPolicy({
...basePolicy,
desired: {
...basePolicy.desired,
pubkeyAuthentication: false,
passwordAuthentication: true,
},
}),
/uses an SSH key/i,
);
assert.throws(
() =>
validateSshAccessPolicy({
...basePolicy,
serverAuthType: "PASSWORD",
desired: {
...basePolicy.desired,
passwordAuthentication: false,
},
}),
/uses a password/i,
);
});
test("SSH policy rejects stale revisions, empty passwords, and disabling all methods", () => {
assert.throws(
() =>
validateSshAccessPolicy({
...basePolicy,
expectedRevision: "b".repeat(64),
}),
SystemAccountConflictError,
);
assert.throws(
() =>
validateSshAccessPolicy({
...basePolicy,
desired: {
...basePolicy.desired,
permitEmptyPasswords: true,
} as never,
}),
/must remain disabled/i,
);
assert.throws(
() =>
validateSshAccessPolicy({
...basePolicy,
desired: {
...basePolicy.desired,
pubkeyAuthentication: false,
passwordAuthentication: false,
},
}),
/at least one/i,
);
});
test("root sessions and temporary password access have lockout guards", () => {
assert.throws(
() =>
validateSshAccessPolicy({
...basePolicy,
serverUsername: "root",
}),
/connects as root/i,
);
assert.throws(
() =>
validateSshAccessPolicy({
...basePolicy,
serverUsername: "root",
serverAuthType: "PASSWORD",
desired: {
...basePolicy.desired,
passwordAuthentication: true,
permitRootLogin: "prohibit-password",
},
}),
SystemAccountInputError,
);
assert.doesNotThrow(() =>
validateSshAccessPolicy({
...basePolicy,
desired: {
...basePolicy.desired,
passwordAuthentication: true,
},
temporaryMinutes: 15,
}),
);
assert.throws(
() =>
validateSshAccessPolicy({
...basePolicy,
currentPasswordAuthentication: true,
desired: {
...basePolicy.desired,
passwordAuthentication: true,
},
temporaryMinutes: 15,
}),
/already enabled/i,
);
});
@@ -4,13 +4,32 @@ import test from "node:test";
import {
assertLinuxAccountName,
assertAllowedSystemUserShell,
assertCredentialMutationAllowed,
assertSystemUserPassword,
getAuthorizedKeysRevision,
getPrivilegedSystemGroups,
hasRequiredPrivilegeAcknowledgement,
normalizeOpenSshPublicKey,
parseAuthorizedKeyLine,
parseSystemUserPasswordStatus,
SystemAccountConflictError,
SystemAccountInputError,
validateSystemGroupDeletePolicy,
validateSystemUserDeletePolicy,
validateSystemUserUpdatePolicy,
} from "../../src/server/services/ssh-services/internal/system-account-security";
function createEd25519PublicKey(comment = "test device") {
const type = Buffer.from("ssh-ed25519");
const key = Buffer.alloc(32, 7);
const blob = Buffer.alloc(4 + type.length + 4 + key.length);
blob.writeUInt32BE(type.length, 0);
type.copy(blob, 4);
blob.writeUInt32BE(key.length, 4 + type.length);
key.copy(blob, 8 + type.length);
return `ssh-ed25519 ${blob.toString("base64")} ${comment}`;
}
test("system account names accept conservative Linux-safe identifiers", () => {
for (const name of ["deploy", "www-data", "_service", "app_2026"]) {
assert.doesNotThrow(() => assertLinuxAccountName(name, "User"));
@@ -156,3 +175,219 @@ test("new privileged memberships require acknowledgement during updates", () =>
/Explicit confirmation is required for privileged groups: docker/,
);
});
test("password validation rejects weak, username-derived, and control-character values", () => {
assert.doesNotThrow(() =>
assertSystemUserPassword("deploy", "correct-horse-battery-staple"),
);
assert.throws(() => assertSystemUserPassword("deploy", "short"), /12/);
assert.throws(
() => assertSystemUserPassword("deploy", "Deploy-password-2026"),
/username/i,
);
assert.throws(
() => assertSystemUserPassword("deploy", "valid-password\nsecret"),
/control characters/i,
);
});
test("OpenSSH public keys are normalized, fingerprinted, and parsed without exposing private material", () => {
const key = createEd25519PublicKey();
const normalized = normalizeOpenSshPublicKey(key, "Developer laptop");
assert.equal(normalized.keyType, "ssh-ed25519");
assert.match(normalized.fingerprint, /^SHA256:[A-Za-z0-9+/]{43}$/);
assert.equal(normalized.comment, "Developer laptop");
assert.deepEqual(parseAuthorizedKeyLine(normalized.line), normalized);
assert.equal(getAuthorizedKeysRevision(""), "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
assert.throws(
() => normalizeOpenSshPublicKey(key.replace("ssh-ed25519", "ssh-rsa")),
/does not match/i,
);
});
test("password status codes are reduced to safe account states", () => {
assert.equal(parseSystemUserPasswordStatus("P"), "set");
assert.equal(parseSystemUserPasswordStatus("L"), "locked");
assert.equal(parseSystemUserPasswordStatus("NP"), "not-set");
assert.equal(parseSystemUserPasswordStatus("unexpected"), "unknown");
});
test("active Doktainer credentials cannot be replaced or revoked", () => {
const base = {
username: "operator",
uid: 1000,
serverUsername: "operator",
};
assert.throws(
() =>
assertCredentialMutationAllowed({
...base,
serverAuthType: "PASSWORD",
action: "password-set",
}),
/active Doktainer SSH connection/i,
);
assert.throws(
() =>
assertCredentialMutationAllowed({
...base,
serverAuthType: "SSH_KEY",
action: "key-revoke",
}),
/reconnect verification/i,
);
assert.doesNotThrow(() =>
assertCredentialMutationAllowed({
...base,
serverAuthType: "SSH_KEY",
action: "key-add",
}),
);
});
const regularDelete = {
username: "deploy",
uid: 1001,
gid: 1001,
home: "/home/deploy",
shell: "/bin/bash",
uidMin: 1000,
serverUsername: "operator",
expectedUid: 1001,
expectedGid: 1001,
expectedHome: "/home/deploy",
expectedShell: "/bin/bash",
confirmation: "deploy",
activeProcessCount: 0,
removeHome: false,
};
test("regular users can be approved for deletion with stale-state guards", () => {
assert.deepEqual(validateSystemUserDeletePolicy(regularDelete), {
username: "deploy",
uid: 1001,
home: "/home/deploy",
removeHome: false,
});
assert.throws(
() =>
validateSystemUserDeletePolicy({
...regularDelete,
expectedShell: "/bin/sh",
}),
SystemAccountConflictError,
);
});
test("user deletion protects root, system users, active SSH identity, and active processes", () => {
assert.throws(
() => validateSystemUserDeletePolicy({ ...regularDelete, uid: 0 }),
/root account is protected/i,
);
assert.throws(
() => validateSystemUserDeletePolicy({ ...regularDelete, uid: 999 }),
/system users/i,
);
assert.throws(
() =>
validateSystemUserDeletePolicy({
...regularDelete,
serverUsername: "deploy",
}),
/active Doktainer SSH connection/i,
);
assert.throws(
() =>
validateSystemUserDeletePolicy({
...regularDelete,
activeProcessCount: 2,
}),
/active processes/i,
);
assert.throws(
() =>
validateSystemUserDeletePolicy({
...regularDelete,
confirmation: "other-user",
}),
/exact username/i,
);
});
test("recursive user deletion is restricted to the standard account home", () => {
assert.doesNotThrow(() =>
validateSystemUserDeletePolicy({ ...regularDelete, removeHome: true }),
);
assert.throws(
() =>
validateSystemUserDeletePolicy({
...regularDelete,
home: "/srv/shared",
expectedHome: "/srv/shared",
removeHome: true,
}),
/standard \/home/i,
);
});
const regularGroupDelete = {
groupName: "developers",
gid: 1002,
gidMin: 1000,
members: [] as string[],
primaryUsers: [] as string[],
expectedGid: 1002,
expectedMembers: [] as string[],
expectedPrimaryUsers: [] as string[],
confirmation: "developers",
};
test("only empty non-system groups can be approved for deletion", () => {
assert.doesNotThrow(() =>
validateSystemGroupDeletePolicy(regularGroupDelete),
);
assert.throws(
() =>
validateSystemGroupDeletePolicy({
...regularGroupDelete,
gid: 999,
expectedGid: 999,
}),
/system and privileged/i,
);
assert.throws(
() =>
validateSystemGroupDeletePolicy({
...regularGroupDelete,
groupName: "docker",
confirmation: "docker",
}),
/system and privileged/i,
);
assert.throws(
() =>
validateSystemGroupDeletePolicy({
...regularGroupDelete,
members: ["deploy"],
expectedMembers: ["deploy"],
}),
/members first/i,
);
assert.throws(
() =>
validateSystemGroupDeletePolicy({
...regularGroupDelete,
primaryUsers: ["deploy"],
expectedPrimaryUsers: ["deploy"],
}),
/primary for/i,
);
assert.throws(
() =>
validateSystemGroupDeletePolicy({
...regularGroupDelete,
expectedMembers: ["stale-member"],
}),
SystemAccountConflictError,
);
});