diff --git a/src/app/servers/components/ServerConfigModal.tsx b/src/app/servers/components/ServerConfigModal.tsx index 21c3db5..8a5096a 100644 --- a/src/app/servers/components/ServerConfigModal.tsx +++ b/src/app/servers/components/ServerConfigModal.tsx @@ -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, 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, ) => { - 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({ + ); + case "ssh-access": + return ( + ); 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."} @@ -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, diff --git a/src/app/servers/components/server-config-utils.ts b/src/app/servers/components/server-config-utils.ts index bf42ea8..b16b3a2 100644 --- a/src/app/servers/components/server-config-utils.ts +++ b/src/app/servers/components/server-config-utils.ts @@ -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: { diff --git a/src/app/servers/components/server-config/ServerConfigSshAccessPanel.tsx b/src/app/servers/components/server-config/ServerConfigSshAccessPanel.tsx new file mode 100644 index 0000000..38e8122 --- /dev/null +++ b/src/app/servers/components/server-config/ServerConfigSshAccessPanel.tsx @@ -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 ( + + ); +} + +function SettingRow({ + title, + description, + children, +}: { + title: string; + description: string; + children: React.ReactNode; +}) { + return ( +
+
+ + {title} + + + {description} + +
+ {children} +
+ ); +} + +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(() => + 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(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 ( +
+
+
+ +
+ + SSH Access + +

+ Manage host-wide SSH authentication without changing the SSH + port. Every apply is validated, reloaded, reconnected, and audited. +

+
+
+ +
+ + {snapshotLoadError || !current.available ? ( +
+ {current.error || snapshotLoadError || + "The effective SSH policy is unavailable. No change can be applied."} +
+ ) : null} + +
+ {[ + { + 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) => ( +
+ + {item.label} + + + {item.value} + + + {item.detail} + +
+ ))} +
+ + {current.temporaryRollbackScheduled ? ( +
+ + A host-side timer is scheduled to restore the previous SSH policy. + Applying a permanent policy will cancel that timer. +
+ ) : null} + +
+
+ + How should users sign in? + +

+ Choose a preset first. Advanced settings remain available below. +

+
+
+ {[ + { + 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 ( + + ); + })} +
+ {selectedPreset === "temporary" && !temporaryAlreadyEnabled ? ( + + ) : null} +
+ + {selectedPreset === "temporary" ? ( +
+
+ + + Optional account password + +
+

+ Enabling password authentication does not create an account password. + Set one here only when the selected user does not already have it. +

+ {snapshot.nonRootUsers.length > 0 ? ( + <> + +
+ { + setTemporaryPassword(event.target.value); + setPasswordError(null); + }} + placeholder="Password (minimum 12 characters)" + style={{ width: "100%", paddingRight: 40 }} + /> + +
+ { + setConfirmTemporaryPassword(event.target.value); + setPasswordError(null); + }} + placeholder="Confirm password" + /> + + {passwordError ? ( +

+ {passwordError} +

+ ) : null} + + + ) : ( +

+ Create a non-root user from the Users tab first. +

+ )} +
+ ) : null} + + + + {advancedOpen ? ( +
+
+ + + + + + + + + + + undefined} + /> + +
+
+ + Managed drop-in + + + {configPreview} + +
+
+ ) : null} + + {(activeMethodLocked || rootConnectionBlocked || noAuthenticationMethod) ? ( +
+ + {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."} +
+ ) : null} + +
+
+ {[ + "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) => ( + + {step} + + ))} +
+
+ + +
+
+ +
+ + Public keys remain managed per account from Users → Edit → Login Access. + This tab controls the host-wide authentication policy only. +
+
+ ); +} diff --git a/src/app/servers/components/server-config/ServerConfigUsersPanel.tsx b/src/app/servers/components/server-config/ServerConfigUsersPanel.tsx index d98929c..8fda78b 100644 --- a/src/app/servers/components/server-config/ServerConfigUsersPanel.tsx +++ b/src/app/servers/components/server-config/ServerConfigUsersPanel.tsx @@ -2,14 +2,23 @@ import { useMemo, useState } from "react"; import { + Eye, + EyeOff, + Fingerprint, Group, + KeyRound, Loader2, LockKeyhole, Plus, ShieldAlert, + Trash2, UserPlus, } from "lucide-react"; -import type { ServerConfigSnapshot, ServerSystemUser } from "@/lib/api"; +import type { + ServerConfigSnapshot, + ServerSystemUser, + ServerSystemUserCreateBody, +} from "@/lib/api"; import { ServerUserCard, UserBadge, @@ -19,40 +28,123 @@ import SearchableSelect from "@/components/SearchableSelect"; interface ServerConfigUsersPanelProps { snapshot: ServerConfigSnapshot; snapshotLoadError?: string | null; + serverAuthType: "PASSWORD" | "SSH_KEY"; canManageSystemAccounts: boolean; isActionRunning: (actionKey: string) => boolean; getSystemUserUpdateActionKey: (username: string) => string; - onRequestCreateUserConfirm: (username: string, groups: string[]) => void; + onRequestCreateUserConfirm: ( + options: Omit, + ) => void; onRequestCreateGroupConfirm: (groupName: string) => void; onRequestUpdateUserConfirm: ( user: ServerSystemUser, groups: string[], shell: string, ) => void; + onRequestPasswordConfirm: (options: { + username: string; + action: "set" | "disable"; + password?: string; + requireChange?: boolean; + }) => void; + onRequestSshKeyAddConfirm: (options: { + username: string; + publicKey: string; + label?: string; + expectedRevision: string; + }) => void; + onRequestSshKeyRevokeConfirm: (options: { + username: string; + fingerprint: string; + expectedRevision: string; + }) => void; + onRequestDeleteUserConfirm: (options: { + user: ServerSystemUser; + confirmation: string; + removeHome: boolean; + }) => void; + onRequestDeleteGroupConfirm: (options: { + groupName: string; + gid: number; + members: string[]; + primaryUsers: string[]; + confirmation: string; + }) => void; } const accountNamePattern = /^[a-z_][a-z0-9_-]{0,31}$/; const sensitiveGroups = new Set(["docker", "root", "sudo", "wheel"]); +const protectedGroupNames = new Set([ + "root", + "sudo", + "wheel", + "docker", + "adm", + "www-data", + "systemd-journal", + "ssh", + "sshd", +]); +const publicKeyPattern = /^(?:ssh-(?:ed25519|rsa)|ecdsa-sha2-nistp(?:256|384|521)|sk-ssh-ed25519@openssh\.com|sk-ecdsa-sha2-nistp256@openssh\.com)\s+[A-Za-z0-9+/]+={0,3}(?:\s+.*)?$/; export default function ServerConfigUsersPanel({ snapshot, snapshotLoadError, + serverAuthType, canManageSystemAccounts, isActionRunning, getSystemUserUpdateActionKey, onRequestCreateUserConfirm, onRequestCreateGroupConfirm, onRequestUpdateUserConfirm, + onRequestPasswordConfirm, + onRequestSshKeyAddConfirm, + onRequestSshKeyRevokeConfirm, + onRequestDeleteUserConfirm, + onRequestDeleteGroupConfirm, }: ServerConfigUsersPanelProps) { const [previewMode, setPreviewMode] = useState<"user" | "group">("user"); const [previewUsername, setPreviewUsername] = useState("deploy"); const [previewGroupName, setPreviewGroupName] = useState("docker"); + const [userPurpose, setUserPurpose] = useState<"ssh" | "local">("ssh"); + const [loginMethod, setLoginMethod] = useState<"password" | "key">( + "password", + ); + const [setupPassword, setSetupPassword] = useState(""); + const [setupPasswordConfirm, setSetupPasswordConfirm] = useState(""); + const [showSetupPassword, setShowSetupPassword] = useState(false); + const [setupPublicKey, setSetupPublicKey] = useState(""); + const [requirePasswordChange, setRequirePasswordChange] = useState(true); + const [guidedSetupError, setGuidedSetupError] = useState(null); const [selectedGroups, setSelectedGroups] = useState(() => snapshot.systemGroups.includes("docker") ? ["docker"] : [], ); const [editingUsername, setEditingUsername] = useState(null); const [editDraftGroups, setEditDraftGroups] = useState([]); const [editDraftShell, setEditDraftShell] = useState("/bin/bash"); + const [editSection, setEditSection] = useState< + "account" | "login" | "danger" + >( + "account", + ); + const [deleteUserConfirmation, setDeleteUserConfirmation] = useState(""); + const [deleteUserHome, setDeleteUserHome] = useState(false); + const [managedGroup, setManagedGroup] = useState( + snapshot.systemGroups.includes("docker") + ? "docker" + : snapshot.systemGroups[0] ?? "", + ); + const [deleteGroupConfirmation, setDeleteGroupConfirmation] = useState(""); + const [passwordEditorOpen, setPasswordEditorOpen] = useState(false); + const [editPassword, setEditPassword] = useState(""); + const [editPasswordConfirm, setEditPasswordConfirm] = useState(""); + const [showEditPassword, setShowEditPassword] = useState(false); + const [editPasswordError, setEditPasswordError] = useState(null); + const [forceEditPasswordChange, setForceEditPasswordChange] = useState(true); + const [sshKeyEditorOpen, setSshKeyEditorOpen] = useState(false); + const [sshKeyLabel, setSshKeyLabel] = useState("Personal laptop"); + const [sshPublicKey, setSshPublicKey] = useState(""); + const [sshKeyError, setSshKeyError] = useState(null); const users = useMemo( () => [ ...(snapshot.rootUser ? [snapshot.rootUser] : []), @@ -121,6 +213,30 @@ export default function ServerConfigUsersPanel({ .filter(Boolean) .join(" && ") : `groupadd ${previewGroupName || ""}`; + const guidedSetupSummary = + userPurpose === "local" + ? `Create ${normalizedUsername || ""} as a local/service account with remote login disabled.` + : loginMethod === "password" + ? `Create ${normalizedUsername || ""} and prepare password-based SSH login.` + : `Create ${normalizedUsername || ""} and install an SSH public key.`; + const managedGroupDetails = snapshot.systemGroupDetails.find( + (group) => group.name === managedGroup, + ); + const managedGroupUsers = managedGroupDetails?.members ?? []; + const managedGroupPrimaryUsers = managedGroupDetails?.primaryUsers ?? []; + const managedGroupBlockReason = !managedGroup + ? "Select a group to inspect." + : !managedGroupDetails || snapshot.gidMin == null + ? "Group metadata or the host GID_MIN policy is unavailable. Refresh Server Config." + : protectedGroupNames.has(managedGroup) + ? `${managedGroup} is protected because it is a system or privileged group.` + : managedGroupDetails.gid < snapshot.gidMin + ? `GID ${managedGroupDetails.gid} is below the host GID_MIN (${snapshot.gidMin}).` + : managedGroupPrimaryUsers.length > 0 + ? `This is the primary group for ${managedGroupPrimaryUsers.join(", ")}.` + : managedGroupUsers.length > 0 + ? `Remove its members first: ${managedGroupUsers.join(", ")}.` + : null; const toggleGroup = (group: string) => { setSelectedGroups((current) => @@ -132,6 +248,57 @@ export default function ServerConfigUsersPanel({ ); }; + const stageGuidedUserSetup = () => { + if (usernameError) { + setGuidedSetupError(usernameError); + return; + } + if (userPurpose === "ssh" && loginMethod === "password") { + if (setupPassword.length < 12 || setupPassword.length > 128) { + setGuidedSetupError("Use a password between 12 and 128 characters."); + return; + } + if (setupPassword.toLowerCase().includes(normalizedUsername.toLowerCase())) { + setGuidedSetupError("The password must not contain the username."); + return; + } + if (setupPassword !== setupPasswordConfirm) { + setGuidedSetupError("Password confirmation does not match."); + return; + } + } + if ( + userPurpose === "ssh" && + loginMethod === "key" && + !publicKeyPattern.test(setupPublicKey.trim().replace(/\s+/g, " ")) + ) { + setGuidedSetupError("Paste a supported OpenSSH public key."); + return; + } + + const remoteLogin = userPurpose === "ssh"; + const credential: ServerSystemUserCreateBody["credential"] = !remoteLogin + ? { type: "none" } + : loginMethod === "password" + ? { + type: "password", + password: setupPassword, + requireChange: requirePasswordChange, + } + : { type: "ssh-key", publicKey: setupPublicKey.trim() }; + onRequestCreateUserConfirm({ + username: normalizedUsername, + groups: selectedGroups, + remoteLogin, + credential, + }); + setGuidedSetupError(null); + setSetupPassword(""); + setSetupPasswordConfirm(""); + setSetupPublicKey(""); + setShowSetupPassword(false); + }; + const beginEdit = (user: ServerSystemUser) => { if (user.isRoot) return; if (editingUsername === user.username) { @@ -142,6 +309,74 @@ export default function ServerConfigUsersPanel({ setEditingUsername(user.username); setEditDraftGroups(user.groups); setEditDraftShell(user.shell ?? "/bin/bash"); + setEditSection("account"); + setPasswordEditorOpen(false); + setEditPassword(""); + setEditPasswordConfirm(""); + setShowEditPassword(false); + setEditPasswordError(null); + setForceEditPasswordChange(true); + setSshKeyEditorOpen(false); + setSshKeyLabel("Personal laptop"); + setSshPublicKey(""); + setSshKeyError(null); + setDeleteUserConfirmation(""); + setDeleteUserHome(false); + }; + + const stageEditedPassword = (username: string) => { + if (editPassword.length < 12 || editPassword.length > 128) { + setEditPasswordError("Use a password between 12 and 128 characters."); + return; + } + if (editPassword.toLowerCase().includes(username.toLowerCase())) { + setEditPasswordError("The password must not contain the username."); + return; + } + if (editPassword !== editPasswordConfirm) { + setEditPasswordError("Password confirmation does not match."); + return; + } + + onRequestPasswordConfirm({ + username, + action: "set", + password: editPassword, + requireChange: forceEditPasswordChange, + }); + setPasswordEditorOpen(false); + setEditPassword(""); + setEditPasswordConfirm(""); + setShowEditPassword(false); + setEditPasswordError(null); + }; + + const requestSshKeyAdd = (user: ServerSystemUser) => { + const normalizedKey = sshPublicKey.trim(); + const normalizedLabel = sshKeyLabel.trim(); + + if (!normalizedLabel || normalizedLabel.length > 64) { + setSshKeyError("Use a key label between 1 and 64 characters."); + return; + } + if (normalizedKey.length > 16_384 || !publicKeyPattern.test(normalizedKey)) { + setSshKeyError("Paste a supported OpenSSH public key, for example ssh-ed25519 or ssh-rsa."); + return; + } + if (!user.authorizedKeysRevision) { + setSshKeyError( + "SSH key state is unavailable. Refresh after verifying non-interactive sudo access.", + ); + return; + } + onRequestSshKeyAddConfirm({ + username: user.username, + publicKey: normalizedKey, + label: normalizedLabel, + expectedRevision: user.authorizedKeysRevision, + }); + setSshKeyError(null); + setSshPublicKey(""); }; const toggleEditGroup = (group: string, sourceUser: ServerSystemUser) => { @@ -303,6 +538,20 @@ export default function ServerConfigUsersPanel({ {previewMode === "user" ? ( <> +
+ +
+
+
+ + What is this account for? + +
+ {[ + { + value: "ssh" as const, + title: "SSH login user", + description: "Can sign in and work on this server.", + }, + { + value: "local" as const, + title: "Local/service user", + description: "Used by apps without remote login.", + }, + ].map((option) => { + const selected = userPurpose === option.value; + return ( + + ); + })} +
+
+ + {userPurpose === "ssh" ? ( +
+ + How will this user sign in? + +
+ {[ + { + value: "password" as const, + title: "Password", + description: "Familiar for initial VPS setup.", + }, + { + value: "key" as const, + title: "SSH public key", + description: "Recommended for better security.", + }, + ].map((option) => { + const selected = loginMethod === option.value; + return ( + + ); + })} +
+ + {loginMethod === "password" ? ( +
+
+ { + setSetupPassword(event.target.value); + setGuidedSetupError(null); + }} + placeholder="Password (minimum 12 characters)" + style={{ width: "100%", paddingRight: 40 }} + /> + +
+ { + setSetupPasswordConfirm(event.target.value); + setGuidedSetupError(null); + }} + placeholder="Confirm password" + /> + +

+ Doktainer will set the account password. SSH login + also depends on the host's current password-authentication policy. +

+
+ ) : ( +