feat(servers): add secure user, group, and SSH access management
This commit is contained in:
1 parent
f8dfe812ff
commit
7ef17105ad
15 files changed
+6043
-52
No files matched your search
@@ -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
@@ -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`,
|
||||
|
||||
@@ -84,6 +84,15 @@ const app = Fastify({
|
||||
trustProxy: process.env.TRUST_PROXY === "true",
|
||||
logger: {
|
||||
level: process.env.NODE_ENV === "production" ? "warn" : "info",
|
||||
redact: {
|
||||
paths: [
|
||||
"req.body.password",
|
||||
"req.body.credential.password",
|
||||
"request.body.password",
|
||||
"request.body.credential.password",
|
||||
],
|
||||
censor: "[REDACTED]",
|
||||
},
|
||||
transport:
|
||||
process.env.NODE_ENV !== "production"
|
||||
? {
|
||||
|
||||
@@ -127,6 +127,20 @@ const ServerSystemUserCreateSchema = z
|
||||
message: "Group names must be unique",
|
||||
}),
|
||||
acknowledgePrivilegedGroups: z.boolean().default(false),
|
||||
remoteLogin: z.boolean(),
|
||||
credential: z.discriminatedUnion("type", [
|
||||
z.object({ type: z.literal("none") }),
|
||||
z.object({
|
||||
type: z.literal("password"),
|
||||
password: z.string().min(12).max(128),
|
||||
requireChange: z.boolean().default(true),
|
||||
}),
|
||||
z.object({
|
||||
type: z.literal("ssh-key"),
|
||||
publicKey: z.string().trim().min(32).max(16_384),
|
||||
label: z.string().trim().min(1).max(64).optional(),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
.superRefine((value, context) => {
|
||||
const selectedPrivilegedGroups = getPrivilegedSystemGroups(value.groups);
|
||||
@@ -140,7 +154,39 @@ const ServerSystemUserCreateSchema = z
|
||||
message: `Explicit confirmation is required for privileged groups: ${selectedPrivilegedGroups.join(", ")}`,
|
||||
});
|
||||
}
|
||||
if (!value.remoteLogin && value.credential.type !== "none") {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["credential"],
|
||||
message: "Users without remote login cannot receive SSH credentials",
|
||||
});
|
||||
}
|
||||
if (value.remoteLogin && value.credential.type === "none") {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["credential"],
|
||||
message: "SSH login users require an initial password or public key",
|
||||
});
|
||||
}
|
||||
});
|
||||
const ServerSystemUserPasswordSchema = z.object({
|
||||
password: z.string().min(12).max(128),
|
||||
requireChange: z.boolean().default(true),
|
||||
});
|
||||
const AuthorizedKeysRevisionSchema = z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/, "Invalid authorized_keys revision");
|
||||
const ServerSystemUserSshKeyCreateSchema = z.object({
|
||||
publicKey: z.string().trim().min(32).max(16_384),
|
||||
label: z.string().trim().min(1).max(64).optional(),
|
||||
expectedRevision: AuthorizedKeysRevisionSchema,
|
||||
});
|
||||
const ServerSystemUserSshKeyRevokeSchema = z.object({
|
||||
expectedRevision: AuthorizedKeysRevisionSchema,
|
||||
});
|
||||
const SshKeyFingerprintSchema = z
|
||||
.string()
|
||||
.regex(/^SHA256:[A-Za-z0-9+/]{43}$/, "Invalid SSH key fingerprint");
|
||||
const ServerSystemUserUpdateSchema = z.object({
|
||||
groups: z
|
||||
.array(LinuxAccountNameSchema)
|
||||
@@ -158,6 +204,46 @@ const ServerSystemUserUpdateSchema = z.object({
|
||||
expectedShell: z.string().trim().min(1).max(128),
|
||||
acknowledgePrivilegedGroups: z.boolean().default(false),
|
||||
});
|
||||
const ServerSystemUserDeleteSchema = z.object({
|
||||
expectedUid: z.number().int().nonnegative(),
|
||||
expectedGid: z.number().int().nonnegative(),
|
||||
expectedHome: z.string().min(1).max(4096),
|
||||
expectedShell: z.string().min(1).max(4096),
|
||||
confirmation: LinuxAccountNameSchema,
|
||||
removeHome: z.boolean().default(false),
|
||||
});
|
||||
const ServerSystemGroupDeleteSchema = z.object({
|
||||
expectedGid: z.number().int().nonnegative(),
|
||||
expectedMembers: z
|
||||
.array(z.string().min(1).max(256).regex(/^[^,\r\n|]+$/))
|
||||
.max(1024)
|
||||
.refine((members) => new Set(members).size === members.length, {
|
||||
message: "Expected group members must be unique",
|
||||
}),
|
||||
expectedPrimaryUsers: z
|
||||
.array(z.string().min(1).max(256).regex(/^[^,\r\n|]+$/))
|
||||
.max(1024)
|
||||
.refine((members) => new Set(members).size === members.length, {
|
||||
message: "Expected primary users must be unique",
|
||||
}),
|
||||
confirmation: LinuxAccountNameSchema,
|
||||
});
|
||||
const ServerSshAccessUpdateSchema = z.object({
|
||||
expectedRevision: z
|
||||
.string()
|
||||
.regex(/^[a-f0-9]{64}$/, "Invalid SSH configuration revision"),
|
||||
pubkeyAuthentication: z.boolean(),
|
||||
passwordAuthentication: z.boolean(),
|
||||
permitRootLogin: z.enum(["no", "prohibit-password"]),
|
||||
permitEmptyPasswords: z.literal(false),
|
||||
temporaryMinutes: z.union([
|
||||
z.literal(15),
|
||||
z.literal(30),
|
||||
z.literal(60),
|
||||
z.literal(240),
|
||||
z.null(),
|
||||
]),
|
||||
});
|
||||
const ServerSystemGroupCreateSchema = z
|
||||
.object({
|
||||
groupName: LinuxAccountNameSchema,
|
||||
@@ -665,6 +751,84 @@ export async function serverRoutes(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
"/:id/ssh-access",
|
||||
{ preHandler: serverSystemAccountWriteAccess },
|
||||
async (req, reply) => {
|
||||
const { id } = req.params as { id: string };
|
||||
const body = ServerSshAccessUpdateSchema.safeParse(req.body ?? {});
|
||||
if (!body.success) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: body.error.flatten(),
|
||||
});
|
||||
}
|
||||
const server = await getAccessibleServer(
|
||||
req.userId,
|
||||
id,
|
||||
req.organizationId,
|
||||
);
|
||||
if (!server) {
|
||||
return reply.status(403).send({
|
||||
success: false,
|
||||
error: "Forbidden — you do not have access to this server",
|
||||
});
|
||||
}
|
||||
try {
|
||||
const result = await ssh.applyServerSshAccessPolicy(server, body.data);
|
||||
const message = body.data.temporaryMinutes
|
||||
? `Temporary SSH password access enabled for ${body.data.temporaryMinutes} minutes on ${server.name}`
|
||||
: `SSH access policy updated on ${server.name}`;
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SSH_ACCESS_UPDATE",
|
||||
category: "SERVER",
|
||||
level:
|
||||
body.data.passwordAuthentication ||
|
||||
body.data.permitRootLogin !== "no"
|
||||
? "WARNING"
|
||||
: "SUCCESS",
|
||||
message,
|
||||
meta: {
|
||||
pubkeyAuthentication: body.data.pubkeyAuthentication,
|
||||
passwordAuthentication: body.data.passwordAuthentication,
|
||||
permitRootLogin: body.data.permitRootLogin,
|
||||
permitEmptyPasswords: false,
|
||||
temporaryMinutes: body.data.temporaryMinutes,
|
||||
},
|
||||
});
|
||||
return reply.send({ success: true, data: result, message });
|
||||
} catch (err: unknown) {
|
||||
const isConflict = err instanceof SystemAccountConflictError;
|
||||
const message =
|
||||
err instanceof SystemAccountInputError
|
||||
? err.message
|
||||
: "The SSH access policy could not be applied safely. The previous managed policy was restored when possible.";
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SSH_ACCESS_UPDATE_FAILED",
|
||||
category: "SERVER",
|
||||
level: isConflict ? "WARNING" : "ERROR",
|
||||
message: `Failed to update SSH access policy on ${server.name}`,
|
||||
meta: {
|
||||
pubkeyAuthentication: body.data.pubkeyAuthentication,
|
||||
passwordAuthentication: body.data.passwordAuthentication,
|
||||
permitRootLogin: body.data.permitRootLogin,
|
||||
temporaryMinutes: body.data.temporaryMinutes,
|
||||
conflict: isConflict,
|
||||
},
|
||||
});
|
||||
return reply
|
||||
.status(isConflict ? 409 : 400)
|
||||
.send({ success: false, error: message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/:id/system-users",
|
||||
{ preHandler: serverSystemAccountWriteAccess },
|
||||
@@ -705,6 +869,12 @@ export async function serverRoutes(app: FastifyInstance) {
|
||||
username: body.data.username,
|
||||
groups: body.data.groups,
|
||||
privilegedGroups,
|
||||
remoteLogin: body.data.remoteLogin,
|
||||
credentialType: body.data.credential.type,
|
||||
requirePasswordChange:
|
||||
body.data.credential.type === "password"
|
||||
? body.data.credential.requireChange
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
return reply.status(201).send({ success: true, message });
|
||||
@@ -721,7 +891,12 @@ export async function serverRoutes(app: FastifyInstance) {
|
||||
category: "SERVER",
|
||||
level: "ERROR",
|
||||
message: `Failed to create system user ${body.data.username} on ${server.name}`,
|
||||
meta: { username: body.data.username, groups: body.data.groups },
|
||||
meta: {
|
||||
username: body.data.username,
|
||||
groups: body.data.groups,
|
||||
remoteLogin: body.data.remoteLogin,
|
||||
credentialType: body.data.credential.type,
|
||||
},
|
||||
});
|
||||
return reply.status(400).send({ success: false, error: message });
|
||||
}
|
||||
@@ -787,6 +962,85 @@ export async function serverRoutes(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/:id/system-groups/:groupName",
|
||||
{ preHandler: serverSystemAccountWriteAccess },
|
||||
async (req, reply) => {
|
||||
const { id, groupName } = req.params as {
|
||||
id: string;
|
||||
groupName: string;
|
||||
};
|
||||
const parsedGroupName = LinuxAccountNameSchema.safeParse(groupName);
|
||||
const body = ServerSystemGroupDeleteSchema.safeParse(req.body ?? {});
|
||||
if (!parsedGroupName.success) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: parsedGroupName.error.flatten(),
|
||||
});
|
||||
}
|
||||
if (!body.success) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: body.error.flatten(),
|
||||
});
|
||||
}
|
||||
if (body.data.confirmation !== parsedGroupName.data) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: "Type the exact group name to confirm deletion",
|
||||
});
|
||||
}
|
||||
const server = await getAccessibleServer(
|
||||
req.userId,
|
||||
id,
|
||||
req.organizationId,
|
||||
);
|
||||
if (!server) {
|
||||
return reply.status(403).send({
|
||||
success: false,
|
||||
error: "Forbidden — you do not have access to this server",
|
||||
});
|
||||
}
|
||||
try {
|
||||
const result = await ssh.deleteServerSystemGroup(server, {
|
||||
groupName: parsedGroupName.data,
|
||||
...body.data,
|
||||
});
|
||||
const message = `System group ${result.groupName} deleted from ${server.name}`;
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SYSTEM_GROUP_DELETE",
|
||||
category: "SERVER",
|
||||
level: "WARNING",
|
||||
message,
|
||||
meta: { groupName: result.groupName, gid: result.gid },
|
||||
});
|
||||
return reply.send({ success: true, data: result, message });
|
||||
} catch (err: unknown) {
|
||||
const isConflict = err instanceof SystemAccountConflictError;
|
||||
const message =
|
||||
err instanceof SystemAccountInputError
|
||||
? err.message
|
||||
: "The host could not delete the system group. Verify sudo access and group usage, then try again.";
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SYSTEM_GROUP_DELETE_FAILED",
|
||||
category: "SERVER",
|
||||
level: isConflict ? "WARNING" : "ERROR",
|
||||
message: `Failed to delete system group ${parsedGroupName.data} from ${server.name}`,
|
||||
meta: { groupName: parsedGroupName.data, conflict: isConflict },
|
||||
});
|
||||
return reply
|
||||
.status(isConflict ? 409 : 400)
|
||||
.send({ success: false, error: message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.patch(
|
||||
"/:id/system-users/:username",
|
||||
{ preHandler: serverSystemAccountWriteAccess },
|
||||
@@ -876,6 +1130,364 @@ export async function serverRoutes(app: FastifyInstance) {
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/:id/system-users/:username",
|
||||
{ preHandler: serverSystemAccountWriteAccess },
|
||||
async (req, reply) => {
|
||||
const { id, username } = req.params as {
|
||||
id: string;
|
||||
username: string;
|
||||
};
|
||||
const parsedUsername = LinuxAccountNameSchema.safeParse(username);
|
||||
const body = ServerSystemUserDeleteSchema.safeParse(req.body ?? {});
|
||||
if (!parsedUsername.success) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: parsedUsername.error.flatten(),
|
||||
});
|
||||
}
|
||||
if (!body.success) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: body.error.flatten(),
|
||||
});
|
||||
}
|
||||
if (body.data.confirmation !== parsedUsername.data) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: "Type the exact username to confirm deletion",
|
||||
});
|
||||
}
|
||||
const server = await getAccessibleServer(
|
||||
req.userId,
|
||||
id,
|
||||
req.organizationId,
|
||||
);
|
||||
if (!server) {
|
||||
return reply.status(403).send({
|
||||
success: false,
|
||||
error: "Forbidden — you do not have access to this server",
|
||||
});
|
||||
}
|
||||
try {
|
||||
const result = await ssh.deleteServerSystemUser(server, {
|
||||
username: parsedUsername.data,
|
||||
...body.data,
|
||||
});
|
||||
const message = `System user ${result.username} deleted from ${server.name}`;
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SYSTEM_USER_DELETE",
|
||||
category: "SERVER",
|
||||
level: "WARNING",
|
||||
message,
|
||||
meta: {
|
||||
username: result.username,
|
||||
uid: result.uid,
|
||||
home: result.home,
|
||||
homeRemoved: result.homeRemoved,
|
||||
},
|
||||
});
|
||||
return reply.send({ success: true, data: result, message });
|
||||
} catch (err: unknown) {
|
||||
const isConflict = err instanceof SystemAccountConflictError;
|
||||
const message =
|
||||
err instanceof SystemAccountInputError
|
||||
? err.message
|
||||
: "The host could not delete the system user. Verify sudo access and active processes, then try again.";
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SYSTEM_USER_DELETE_FAILED",
|
||||
category: "SERVER",
|
||||
level: isConflict ? "WARNING" : "ERROR",
|
||||
message: `Failed to delete system user ${parsedUsername.data} from ${server.name}`,
|
||||
meta: {
|
||||
username: parsedUsername.data,
|
||||
removeHome: body.data.removeHome,
|
||||
conflict: isConflict,
|
||||
},
|
||||
});
|
||||
return reply
|
||||
.status(isConflict ? 409 : 400)
|
||||
.send({ success: false, error: message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.put(
|
||||
"/:id/system-users/:username/password",
|
||||
{ preHandler: serverSystemAccountWriteAccess },
|
||||
async (req, reply) => {
|
||||
const { id, username } = req.params as { id: string; username: string };
|
||||
const parsedUsername = LinuxAccountNameSchema.safeParse(username);
|
||||
const body = ServerSystemUserPasswordSchema.safeParse(req.body ?? {});
|
||||
if (!parsedUsername.success) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: parsedUsername.error.flatten(),
|
||||
});
|
||||
}
|
||||
if (!body.success) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: body.error.flatten(),
|
||||
});
|
||||
}
|
||||
const server = await getAccessibleServer(
|
||||
req.userId,
|
||||
id,
|
||||
req.organizationId,
|
||||
);
|
||||
if (!server) {
|
||||
return reply.status(403).send({ success: false, error: "Forbidden" });
|
||||
}
|
||||
try {
|
||||
const result = await ssh.setServerSystemUserPassword(server, {
|
||||
username: parsedUsername.data,
|
||||
...body.data,
|
||||
});
|
||||
const message = `Password updated for system user ${result.username} on ${server.name}`;
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SYSTEM_USER_PASSWORD_SET",
|
||||
category: "SERVER",
|
||||
level: "WARNING",
|
||||
message,
|
||||
meta: {
|
||||
username: result.username,
|
||||
requireChange: body.data.requireChange,
|
||||
},
|
||||
});
|
||||
return reply.send({ success: true, data: result, message });
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof SystemAccountInputError
|
||||
? err.message
|
||||
: "The host could not update this password. Verify sudo and password-management support, then try again.";
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SYSTEM_USER_PASSWORD_SET_FAILED",
|
||||
category: "SERVER",
|
||||
level: "ERROR",
|
||||
message: `Failed to update password for ${parsedUsername.data} on ${server.name}`,
|
||||
meta: { username: parsedUsername.data },
|
||||
});
|
||||
return reply.status(400).send({ success: false, error: message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/:id/system-users/:username/password",
|
||||
{ preHandler: serverSystemAccountWriteAccess },
|
||||
async (req, reply) => {
|
||||
const { id, username } = req.params as { id: string; username: string };
|
||||
const parsedUsername = LinuxAccountNameSchema.safeParse(username);
|
||||
if (!parsedUsername.success) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: parsedUsername.error.flatten(),
|
||||
});
|
||||
}
|
||||
const server = await getAccessibleServer(
|
||||
req.userId,
|
||||
id,
|
||||
req.organizationId,
|
||||
);
|
||||
if (!server) {
|
||||
return reply.status(403).send({ success: false, error: "Forbidden" });
|
||||
}
|
||||
try {
|
||||
const result = await ssh.disableServerSystemUserPassword(
|
||||
server,
|
||||
parsedUsername.data,
|
||||
);
|
||||
const message = `Password login disabled for system user ${result.username} on ${server.name}`;
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SYSTEM_USER_PASSWORD_DISABLE",
|
||||
category: "SERVER",
|
||||
level: "WARNING",
|
||||
message,
|
||||
meta: { username: result.username },
|
||||
});
|
||||
return reply.send({ success: true, data: result, message });
|
||||
} catch (err: unknown) {
|
||||
const message =
|
||||
err instanceof SystemAccountInputError
|
||||
? err.message
|
||||
: "The host could not disable this password. Verify sudo access, then try again.";
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SYSTEM_USER_PASSWORD_DISABLE_FAILED",
|
||||
category: "SERVER",
|
||||
level: "ERROR",
|
||||
message: `Failed to disable password for ${parsedUsername.data} on ${server.name}`,
|
||||
meta: { username: parsedUsername.data },
|
||||
});
|
||||
return reply.status(400).send({ success: false, error: message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/:id/system-users/:username/ssh-keys",
|
||||
{ preHandler: serverSystemAccountWriteAccess },
|
||||
async (req, reply) => {
|
||||
const { id, username } = req.params as { id: string; username: string };
|
||||
const parsedUsername = LinuxAccountNameSchema.safeParse(username);
|
||||
const body = ServerSystemUserSshKeyCreateSchema.safeParse(req.body ?? {});
|
||||
if (!parsedUsername.success) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: parsedUsername.error.flatten(),
|
||||
});
|
||||
}
|
||||
if (!body.success) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: body.error.flatten(),
|
||||
});
|
||||
}
|
||||
const server = await getAccessibleServer(
|
||||
req.userId,
|
||||
id,
|
||||
req.organizationId,
|
||||
);
|
||||
if (!server) {
|
||||
return reply.status(403).send({ success: false, error: "Forbidden" });
|
||||
}
|
||||
try {
|
||||
const result = await ssh.addServerSystemUserSshKey(server, {
|
||||
username: parsedUsername.data,
|
||||
...body.data,
|
||||
});
|
||||
const addedKey = result.sshKeys?.at(-1);
|
||||
const message = `SSH public key added for system user ${result.username} on ${server.name}`;
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SYSTEM_USER_SSH_KEY_ADD",
|
||||
category: "SERVER",
|
||||
level: "WARNING",
|
||||
message,
|
||||
meta: {
|
||||
username: result.username,
|
||||
fingerprint: addedKey?.fingerprint,
|
||||
keyType: addedKey?.keyType,
|
||||
label: addedKey?.comment,
|
||||
},
|
||||
});
|
||||
return reply.status(201).send({ success: true, data: result, message });
|
||||
} catch (err: unknown) {
|
||||
const isConflict = err instanceof SystemAccountConflictError;
|
||||
const message =
|
||||
err instanceof SystemAccountInputError
|
||||
? err.message
|
||||
: "The host could not add this SSH public key. Verify sudo access and home-directory permissions, then try again.";
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SYSTEM_USER_SSH_KEY_ADD_FAILED",
|
||||
category: "SERVER",
|
||||
level: isConflict ? "WARNING" : "ERROR",
|
||||
message: `Failed to add SSH key for ${parsedUsername.data} on ${server.name}`,
|
||||
meta: { username: parsedUsername.data, conflict: isConflict },
|
||||
});
|
||||
return reply
|
||||
.status(isConflict ? 409 : 400)
|
||||
.send({ success: false, error: message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.delete(
|
||||
"/:id/system-users/:username/ssh-keys",
|
||||
{ preHandler: serverSystemAccountWriteAccess },
|
||||
async (req, reply) => {
|
||||
const { id, username } = req.params as { id: string; username: string };
|
||||
const parsedUsername = LinuxAccountNameSchema.safeParse(username);
|
||||
const rawBody = req.body as Record<string, unknown> | undefined;
|
||||
const fingerprint = SshKeyFingerprintSchema.safeParse(
|
||||
rawBody?.fingerprint,
|
||||
);
|
||||
const body = ServerSystemUserSshKeyRevokeSchema.safeParse(req.body ?? {});
|
||||
if (!parsedUsername.success || !fingerprint.success || !body.success) {
|
||||
return reply.status(400).send({
|
||||
success: false,
|
||||
error: "Invalid username, fingerprint, or authorized_keys revision",
|
||||
});
|
||||
}
|
||||
const server = await getAccessibleServer(
|
||||
req.userId,
|
||||
id,
|
||||
req.organizationId,
|
||||
);
|
||||
if (!server) {
|
||||
return reply.status(403).send({ success: false, error: "Forbidden" });
|
||||
}
|
||||
try {
|
||||
const result = await ssh.revokeServerSystemUserSshKey(server, {
|
||||
username: parsedUsername.data,
|
||||
fingerprint: fingerprint.data,
|
||||
expectedRevision: body.data.expectedRevision,
|
||||
});
|
||||
const message = `SSH public key revoked for system user ${result.username} on ${server.name}`;
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SYSTEM_USER_SSH_KEY_REVOKE",
|
||||
category: "SERVER",
|
||||
level: "WARNING",
|
||||
message,
|
||||
meta: {
|
||||
username: result.username,
|
||||
fingerprint: fingerprint.data,
|
||||
},
|
||||
});
|
||||
return reply.send({ success: true, data: result, message });
|
||||
} catch (err: unknown) {
|
||||
const isConflict = err instanceof SystemAccountConflictError;
|
||||
const message =
|
||||
err instanceof SystemAccountInputError
|
||||
? err.message
|
||||
: "The host could not revoke this SSH public key. Verify sudo access and home-directory permissions, then try again.";
|
||||
await auditLog({
|
||||
userId: req.userId,
|
||||
organizationId: req.organizationId,
|
||||
serverId: id,
|
||||
action: "SERVER_SYSTEM_USER_SSH_KEY_REVOKE_FAILED",
|
||||
category: "SERVER",
|
||||
level: isConflict ? "WARNING" : "ERROR",
|
||||
message: `Failed to revoke SSH key for ${parsedUsername.data} on ${server.name}`,
|
||||
meta: {
|
||||
username: parsedUsername.data,
|
||||
fingerprint: fingerprint.data,
|
||||
conflict: isConflict,
|
||||
},
|
||||
});
|
||||
return reply
|
||||
.status(isConflict ? 409 : 400)
|
||||
.send({ success: false, error: message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
app.post(
|
||||
"/:id/reset",
|
||||
{ preHandler: serverWriteAccess },
|
||||
|
||||
@@ -20,6 +20,7 @@ const commandRunContext = new AsyncLocalStorage<{
|
||||
type ExecOptions = {
|
||||
timeoutMs?: number;
|
||||
queueTimeoutMs?: number;
|
||||
stdin?: string;
|
||||
};
|
||||
|
||||
const REMOTE_JOB_DIR = "/tmp/doktainer-process-jobs";
|
||||
@@ -278,7 +279,7 @@ export async function exec(
|
||||
options: ExecOptions = {},
|
||||
): Promise<SSHExecCommandResponse> {
|
||||
const logSink = commandLogSink.getStore();
|
||||
if (logSink) {
|
||||
if (logSink && options.stdin === undefined) {
|
||||
let stdout = "";
|
||||
let code = 0;
|
||||
|
||||
@@ -309,7 +310,11 @@ export async function exec(
|
||||
try {
|
||||
const ssh = await getConnection(server);
|
||||
return await withCommandTimeout(
|
||||
() => ssh.execCommand(command),
|
||||
() =>
|
||||
ssh.execCommand(
|
||||
command,
|
||||
options.stdin === undefined ? undefined : { stdin: options.stdin },
|
||||
),
|
||||
options.timeoutMs,
|
||||
() => {
|
||||
logCommandTimeout(server, command, options.timeoutMs!);
|
||||
@@ -327,7 +332,11 @@ export async function exec(
|
||||
closeConnection(server.id);
|
||||
const ssh = await getConnection(server);
|
||||
return withCommandTimeout(
|
||||
() => ssh.execCommand(command),
|
||||
() =>
|
||||
ssh.execCommand(
|
||||
command,
|
||||
options.stdin === undefined ? undefined : { stdin: options.stdin },
|
||||
),
|
||||
options.timeoutMs,
|
||||
() => {
|
||||
logCommandTimeout(server, command, options.timeoutMs!);
|
||||
|
||||
@@ -3,6 +3,7 @@ export * from "./commands";
|
||||
export * from "./metrics";
|
||||
export * from "./platform";
|
||||
export * from "./server-config";
|
||||
export * from "./ssh-access";
|
||||
export * from "./web-stack";
|
||||
export * from "./docker-engine";
|
||||
export * from "./docker-networks";
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
import {
|
||||
SystemAccountConflictError,
|
||||
SystemAccountInputError,
|
||||
} from "./system-account-security";
|
||||
|
||||
export type SshPermitRootLogin = "no" | "prohibit-password";
|
||||
|
||||
export interface SshAuthenticationPolicy {
|
||||
pubkeyAuthentication: boolean;
|
||||
passwordAuthentication: boolean;
|
||||
permitRootLogin: SshPermitRootLogin;
|
||||
permitEmptyPasswords: false;
|
||||
}
|
||||
|
||||
export interface EffectiveSshAuthenticationPolicy {
|
||||
pubkeyAuthentication: boolean;
|
||||
passwordAuthentication: boolean;
|
||||
permitRootLogin: SshPermitRootLogin | "yes";
|
||||
permitEmptyPasswords: boolean;
|
||||
}
|
||||
|
||||
export function getSshConfigurationRevision(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
|
||||
export function validateSshAccessPolicy(options: {
|
||||
serverUsername: string;
|
||||
serverAuthType: "PASSWORD" | "SSH_KEY";
|
||||
currentRevision: string;
|
||||
expectedRevision: string;
|
||||
currentPasswordAuthentication: boolean;
|
||||
desired: SshAuthenticationPolicy;
|
||||
temporaryMinutes: 15 | 30 | 60 | 240 | null;
|
||||
}): void {
|
||||
if (options.currentRevision !== options.expectedRevision) {
|
||||
throw new SystemAccountConflictError(
|
||||
"SSH configuration changed on the host after the snapshot was loaded. Refresh Server Config before applying changes.",
|
||||
);
|
||||
}
|
||||
if (options.desired.permitEmptyPasswords !== false) {
|
||||
throw new SystemAccountInputError(
|
||||
"PermitEmptyPasswords must remain disabled",
|
||||
);
|
||||
}
|
||||
if (
|
||||
!options.desired.pubkeyAuthentication &&
|
||||
!options.desired.passwordAuthentication
|
||||
) {
|
||||
throw new SystemAccountInputError(
|
||||
"At least one SSH authentication method must remain enabled",
|
||||
);
|
||||
}
|
||||
if (
|
||||
options.serverAuthType === "PASSWORD" &&
|
||||
!options.desired.passwordAuthentication
|
||||
) {
|
||||
throw new SystemAccountInputError(
|
||||
"PasswordAuthentication cannot be disabled while Doktainer uses a password for this server",
|
||||
);
|
||||
}
|
||||
if (
|
||||
options.serverAuthType === "SSH_KEY" &&
|
||||
!options.desired.pubkeyAuthentication
|
||||
) {
|
||||
throw new SystemAccountInputError(
|
||||
"PubkeyAuthentication cannot be disabled while Doktainer uses an SSH key for this server",
|
||||
);
|
||||
}
|
||||
if (options.serverUsername === "root") {
|
||||
if (options.desired.permitRootLogin === "no") {
|
||||
throw new SystemAccountInputError(
|
||||
"PermitRootLogin cannot be disabled while Doktainer still connects as root",
|
||||
);
|
||||
}
|
||||
if (options.serverAuthType === "PASSWORD") {
|
||||
throw new SystemAccountInputError(
|
||||
"A root password connection cannot be preserved by the managed safe policy. Change the Doktainer server account before applying SSH Access settings.",
|
||||
);
|
||||
}
|
||||
}
|
||||
if (options.temporaryMinutes != null) {
|
||||
if (!options.desired.passwordAuthentication) {
|
||||
throw new SystemAccountInputError(
|
||||
"Temporary password access requires PasswordAuthentication",
|
||||
);
|
||||
}
|
||||
if (options.serverAuthType !== "SSH_KEY") {
|
||||
throw new SystemAccountInputError(
|
||||
"Temporary password access requires Doktainer to use an SSH key so automatic rollback cannot lock out the active connection",
|
||||
);
|
||||
}
|
||||
if (options.currentPasswordAuthentication) {
|
||||
throw new SystemAccountInputError(
|
||||
"Password authentication is already enabled. Select a permanent policy instead of scheduling a temporary rollback.",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function parseEffectiveSshPolicy(
|
||||
output: string,
|
||||
): EffectiveSshAuthenticationPolicy | null {
|
||||
const values = new Map<string, string>();
|
||||
for (const rawLine of output.split("\n")) {
|
||||
const line = rawLine.trim();
|
||||
if (!line) continue;
|
||||
const separator = line.indexOf(" ");
|
||||
if (separator < 1) continue;
|
||||
values.set(line.slice(0, separator).toLowerCase(), line.slice(separator + 1).trim());
|
||||
}
|
||||
const pubkey = values.get("pubkeyauthentication");
|
||||
const password = values.get("passwordauthentication");
|
||||
const root = values.get("permitrootlogin");
|
||||
const empty = values.get("permitemptypasswords");
|
||||
if (
|
||||
!["yes", "no"].includes(pubkey ?? "") ||
|
||||
!["yes", "no"].includes(password ?? "") ||
|
||||
!["yes", "no"].includes(empty ?? "") ||
|
||||
!root
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
const permitRootLogin =
|
||||
root === "no"
|
||||
? "no"
|
||||
: root === "prohibit-password" || root === "without-password"
|
||||
? "prohibit-password"
|
||||
: root === "yes"
|
||||
? "yes"
|
||||
: null;
|
||||
if (!permitRootLogin) return null;
|
||||
return {
|
||||
pubkeyAuthentication: pubkey === "yes",
|
||||
passwordAuthentication: password === "yes",
|
||||
permitRootLogin,
|
||||
permitEmptyPasswords: empty === "yes",
|
||||
};
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export const LINUX_ACCOUNT_NAME_PATTERN = /^[a-z_][a-z0-9_-]{0,31}$/;
|
||||
|
||||
export const PRIVILEGED_SYSTEM_GROUPS = new Set([
|
||||
@@ -7,6 +9,14 @@ export const PRIVILEGED_SYSTEM_GROUPS = new Set([
|
||||
"wheel",
|
||||
]);
|
||||
|
||||
export const PROTECTED_SYSTEM_GROUPS = new Set([
|
||||
...PRIVILEGED_SYSTEM_GROUPS,
|
||||
"adm",
|
||||
"systemd-journal",
|
||||
"ssh",
|
||||
"sshd",
|
||||
]);
|
||||
|
||||
export const ALLOWED_SYSTEM_USER_SHELLS = new Set([
|
||||
"/bin/bash",
|
||||
"/bin/sh",
|
||||
@@ -17,6 +27,29 @@ export const ALLOWED_SYSTEM_USER_SHELLS = new Set([
|
||||
"/sbin/nologin",
|
||||
]);
|
||||
|
||||
export const SUPPORTED_OPENSSH_PUBLIC_KEY_TYPES = new Set([
|
||||
"ssh-ed25519",
|
||||
"ssh-rsa",
|
||||
"ecdsa-sha2-nistp256",
|
||||
"ecdsa-sha2-nistp384",
|
||||
"ecdsa-sha2-nistp521",
|
||||
"[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[];
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
Reference in new issue
Block a user