From 271f79d6f0cdb767aac203c290e5b4564653aed8 Mon Sep 17 00:00:00 2001 From: kalugin66 Date: Fri, 21 Aug 2026 11:35:47 +0500 Subject: [PATCH] kerberos --- .env.example | 18 +++++- config/krb5.conf | 24 ++++++++ lib/config.js | 8 ++- lib/ldapClient.js | 21 +++++-- lib/passwordSetter.js | 138 +++++++++++++++++++++++++++++++++++++++++- 5 files changed, 200 insertions(+), 9 deletions(-) create mode 100644 config/krb5.conf diff --git a/.env.example b/.env.example index 366ce6f..ad9785f 100644 --- a/.env.example +++ b/.env.example @@ -10,6 +10,8 @@ LDAP_DOMAIN=school25.ru # ============ Administrator account ============ # Account that has rights to create / modify / block users in AD. # May be a sAMAccountName, a UPN (user@domain) or a full DN. +# NOTE: with LDAP_PASSWORD_METHOD=kerberos the account must be a +# sAMAccountName or UPN (a DN cannot be used as a Kerberos principal). ADMIN_LOGIN=admin # Passwords containing '#' (dotenv comment marker), spaces or other special chars # must be wrapped in quotes, e.g. ADMIN_PASSWORD="my#pass" @@ -32,9 +34,19 @@ ENCRYPT_TRAFFIC=false # ============ Password setting ============ # AD only allows setting unicodePwd over a protected channel (LDAPS / StartTLS / # Kerberos signed+sealed connection). Without TLS the plain LDAP modify returns -# LDAP error 53. On a domain-joined Windows server the recommended workaround is -# to set the password via PowerShell/ADSI (IADsUser.SetPassword), which uses a -# Kerberos protected channel automatically. +# LDAP error 53. +# +# Password method (LDAP_PASSWORD_METHOD): +# kerberos - (default on Linux) kinit + ldapmodify -Y GSSAPI using the admin +# account; requires krb5/openldap-clients/cyrus-sasl-gssapiv2 +# packages and /etc/krb5.conf. No TLS needed. +# powershell - (default on Windows) PowerShell/ADSI IADsUser.SetPassword on a +# domain-joined machine (Kerberos protected channel). +# ldap - plain unicodePwd LDAP modify; works only over LDAPS/StartTLS. +# LDAP_PASSWORD_METHOD=kerberos +# Kerberos realm (default: LDAP_DOMAIN in uppercase, e.g. SCHOOL25.RU) +# LDAP_REALM=SCHOOL25.RU +# Legacy switch kept for compatibility (implies LDAP_PASSWORD_METHOD=powershell): # LDAP_USE_POWERSHELL_PASSWORD=true # LDAP_POWERSHELL_EXE=powershell.exe diff --git a/config/krb5.conf b/config/krb5.conf new file mode 100644 index 0000000..badd26a --- /dev/null +++ b/config/krb5.conf @@ -0,0 +1,24 @@ +# Kerberos configuration for setting AD passwords from the Linux container. +# The realm is the LDAP domain in uppercase; kdc locations are resolved via +# DNS SRV records (_kerberos._tcp.) so no hardcoded server list is +# needed. Override the realm with the LDAP_REALM env variable if required. + +[libdefaults] + default_realm = SCHOOL25.RU + dns_lookup_kdc = true + dns_lookup_realm = false + rdns = false + forwardable = true + renew_lifetime = 7d + ticket_lifetime = 24h + +[realms] + SCHOOL25.RU = { + kdc = dc.school25.ru + kdc = dc3.school25.ru + kdc = srv.school25.ru + } + +[domain_realm] + .school25.ru = SCHOOL25.RU + school25.ru = SCHOOL25.RU \ No newline at end of file diff --git a/lib/config.js b/lib/config.js index f0a4bfe..10d6892 100644 --- a/lib/config.js +++ b/lib/config.js @@ -7,6 +7,11 @@ function getEnv(key, defaultValue) { return value !== undefined && value !== '' ? value : defaultValue; } +const legacyPowerShell = getEnv('LDAP_USE_POWERSHELL_PASSWORD', '') === 'true'; +const passwordMethodDefault = legacyPowerShell + ? 'powershell' + : process.platform === 'win32' ? 'powershell' : 'kerberos'; + const config = { ldapUrl: getEnv('LDAP_URL', 'ldap://dc.school25.ru:389'), baseDN: getEnv('LDAP_BASE_DN', 'DC=school25,DC=ru'), @@ -19,7 +24,8 @@ const config = { encryptTraffic: getEnv('ENCRYPT_TRAFFIC', 'true') === 'true', tlsRejectUnauthorized: getEnv('LDAP_TLS_REJECT_UNAUTHORIZED', 'false') === 'true', caFile: getEnv('LDAP_CA_FILE', ''), - usePowerShellPassword: getEnv('LDAP_USE_POWERSHELL_PASSWORD', process.platform === 'win32' ? 'true' : 'false') === 'true', + passwordMethod: getEnv('LDAP_PASSWORD_METHOD', passwordMethodDefault), + ldapRealm: getEnv('LDAP_REALM', String(getEnv('LDAP_DOMAIN', 'school25.ru')).toUpperCase()), powerShellExe: getEnv('LDAP_POWERSHELL_EXE', 'powershell.exe'), emailHost: getEnv('EMAIL_HOST', ''), emailPort: parseInt(getEnv('EMAIL_PORT', '587'), 10), diff --git a/lib/ldapClient.js b/lib/ldapClient.js index 5d2cf56..589ddda 100644 --- a/lib/ldapClient.js +++ b/lib/ldapClient.js @@ -4,7 +4,7 @@ const fs = require('fs'); require('./patch-ldap-dn'); const ldap = require('ldapjs'); const config = require('./config'); -const setPasswordViaPowerShell = require('./passwordSetter'); +const passwordSetter = require('./passwordSetter'); function urlScheme(url) { const match = /^([a-z0-9+.-]+):\/\//i.exec(String(url || '')); @@ -238,9 +238,22 @@ function modifyEntry(client, dn, changes) { } async function setPassword(client, userDN, password) { - if (config.usePowerShellPassword) { - await setPasswordViaPowerShell(userDN, password, { exe: config.powerShellExe, ldapUrl: config.ldapUrl }); - return; + switch (config.passwordMethod) { + case 'powershell': + await passwordSetter.setPasswordViaPowerShell(userDN, password, { exe: config.powerShellExe, ldapUrl: config.ldapUrl }); + return; + case 'kerberos': { + const principal = passwordSetter.buildPrincipal(config.adminLogin, config.ldapRealm); + await passwordSetter.setPasswordViaKerberos(userDN, password, { + ldapUrl: config.ldapUrl, + principal, + adminPassword: config.adminPassword + }); + return; + } + case 'ldap': + default: + break; } const value = Buffer.from('"' + password + '"', 'utf16le'); await modifyEntry(client, userDN, [{ operation: 'replace', modification: { unicodePwd: [value] } }]); diff --git a/lib/passwordSetter.js b/lib/passwordSetter.js index f3c7b98..53afd36 100644 --- a/lib/passwordSetter.js +++ b/lib/passwordSetter.js @@ -87,4 +87,140 @@ function setPasswordViaPowerShell(userDN, password, { exe = 'powershell.exe', ti }); } -module.exports = setPasswordViaPowerShell; \ No newline at end of file +// Sets a user's password from a Linux container via Kerberos (GSSAPI). +// AD only accepts unicodePwd over a protected channel. On Linux there is +// no ADSI, so we obtain a Kerberos ticket for the admin account (kinit) +// and perform an LDAP modify over a SASL GSSAPI connection (ldapmodify +// -Y GSSAPI). The Kerberos-sealed channel (SSF 56) is accepted by AD as +// a protected channel — the same mechanism ADSI uses on Windows. +// +// The admin password and the target password are passed via stdin / env +// variables so they never appear in the process command line, and the +// LDIF is sent over stdin to avoid temp files. + +function runChild(exe, args, { env = {}, input = '', timeoutMs = 15000, describe = exe } = {}) { + return new Promise((resolve, reject) => { + let child; + try { + child = spawn(exe, args, { + env: { ...process.env, ...env }, + windowsHide: true, + stdio: ['pipe', 'pipe', 'pipe'] + }); + } catch (err) { + reject(new Error(`Не удалось запустить ${describe}: ${err.message}`)); + return; + } + + let stdout = ''; + let stderr = ''; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + try { child.kill(); } catch (_) { /* ignore */ } + }, timeoutMs); + + child.stdout.on('data', (d) => { stdout += d; }); + child.stderr.on('data', (d) => { stderr += d; }); + + child.on('error', (err) => { + clearTimeout(timer); + reject(new Error(`Не удалось запустить ${describe}: ${err.message}`)); + }); + + child.on('close', (code) => { + clearTimeout(timer); + if (timedOut) { + reject(new Error(`Таймаут при выполнении ${describe}`)); + return; + } + resolve({ code, stdout, stderr }); + }); + + child.stdin.end(input); + }); +} + +function buildPrincipal(login, realm) { + const value = String(login || '').trim(); + if (!value) return ''; + if (value.includes('@')) { + const at = value.lastIndexOf('@'); + const name = value.slice(0, at); + return `${name}@${realm}`; + } + if (value.includes('\\')) { + return `${value.split('\\').pop()}@${realm}`; + } + return `${value}@${realm}`; +} + +function buildUnicodePwdLdif(userDN, password) { + const encoded = Buffer.from('"' + password + '"', 'utf16le').toString('base64'); + return [ + `dn: ${userDN}`, + 'changetype: modify', + 'replace: unicodePwd', + `unicodePwd:: ${encoded}`, + '' + ].join('\n'); +} + +async function setPasswordViaKerberos(userDN, password, { + exe = 'kinit', + ldapmodifyExe = 'ldapmodify', + timeoutMs = 20000, + ldapUrl = '', + principal = '', + adminPassword = '', + ccache = '/tmp/krb5cc_ldapauth' +} = {}) { + if (!principal) { + throw new Error('Не задан principal администратора (ADMIN_LOGIN/LDAP_REALM) для Kerberos'); + } + const ccacheEnv = { KRB5CCNAME: ccache }; + + const kinit = await runChild(exe, ['-c', ccache, principal], { + env: ccacheEnv, + input: String(adminPassword || '') + '\n', + timeoutMs, + describe: 'kinit' + }); + if (kinit.code !== 0) { + const detail = String(kinit.stderr || kinit.stdout || '').trim(); + throw new Error(`Не удалось получить билет Kerberos (kinit, код ${kinit.code})${detail ? ': ' + detail : ''}`); + } + + const ldapArgs = ['-Y', 'GSSAPI']; + if (ldapUrl) ldapArgs.push('-H', ldapUrl); + ldapArgs.push('-O', 'maxssf=256'); + + const ldapmodify = await runChild(ldapmodifyExe, ldapArgs, { + env: ccacheEnv, + input: buildUnicodePwdLdif(userDN, password), + timeoutMs, + describe: 'ldapmodify' + }); + + try { + await runChild('kdestroy', ['-c', ccache], { + env: ccacheEnv, + input: '', + timeoutMs: 5000, + describe: 'kdestroy' + }); + } catch (_) { /* best-effort cleanup */ } + + if (ldapmodify.code !== 0) { + const detail = String(ldapmodify.stderr || ldapmodify.stdout || '').trim(); + throw new Error(`Ошибка установки пароля через Kerberos/GSSAPI (код ${ldapmodify.code})${detail ? ': ' + detail : ''}`); + } +} + +module.exports = { + setPasswordViaPowerShell, + setPasswordViaKerberos, + buildPrincipal, + buildUnicodePwdLdif, + adsiServerPrefix +}; \ No newline at end of file