'use strict'; const { spawn } = require('child_process'); // Sets a user's password via PowerShell/ADSI (IADsUser.SetPassword). // On a domain-joined machine ADSI negotiates a Kerberos signed/sealed // connection, which Active Directory accepts as a "protected channel" // for unicodePwd — unlike a plain LDAP bind over TCP (AD error 53). // // The DN and password are passed via environment variables so they never // appear in the process command line, and the script body is pure ASCII // (no encoding issues with Windows PowerShell 5.1). The script itself is // sent over stdin to avoid temp files. // // The server is passed explicitly (when `ldapUrl` is provided) so ADSI // targets the SAME domain controller that the LDAP write went to. Without // it ADSI does a serverless bind and the DC Locator may pick another DC // (e.g. on a remote site) that has not replicated the freshly created user // yet, which fails with "Такой объект на сервере отсутствует" (0x8007203A). function adsiServerPrefix(ldapUrl) { const match = /^([a-z0-9+.-]+):\/\/([^/:]+)(?::(\d+))?/i.exec(String(ldapUrl || '').trim()); if (!match) return ''; const scheme = match[1].toLowerCase(); const host = match[2]; const port = match[3]; const prefix = scheme === 'ldaps' ? 'LDAPS://' : 'LDAP://'; return prefix + host + (port ? ':' + port : '') + '/'; } function setPasswordViaPowerShell(userDN, password, { exe = 'powershell.exe', timeoutMs = 15000, ldapUrl = '' } = {}) { return new Promise((resolve, reject) => { const serverPrefix = adsiServerPrefix(ldapUrl); const script = [ '$ErrorActionPreference = "Stop"', '$dn = [Environment]::GetEnvironmentVariable("LDAP_PW_DN")', '$pw = [Environment]::GetEnvironmentVariable("LDAP_PW_VALUE")', '$server = [Environment]::GetEnvironmentVariable("LDAP_PW_SERVER")', '$target = if ($server) { $server + $dn } else { "LDAP://" + $dn }', '$user = [adsi]($target)', '$user.SetPassword($pw)' ].join('; '); let child; try { child = spawn(exe, ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', '-'], { env: { ...process.env, LDAP_PW_DN: userDN, LDAP_PW_VALUE: password, LDAP_PW_SERVER: serverPrefix }, windowsHide: true, stdio: ['pipe', 'pipe', 'pipe'] }); } catch (err) { reject(new Error(`Не удалось запустить PowerShell для установки пароля: ${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(`Не удалось запустить PowerShell для установки пароля: ${err.message}`)); }); child.on('close', (code) => { clearTimeout(timer); if (timedOut) { reject(new Error('Таймаут при установке пароля через PowerShell')); return; } if (code === 0) { resolve(); return; } const detail = String(stderr || stdout || '').trim(); reject(new Error(`Ошибка установки пароля через PowerShell (код ${code})${detail ? ': ' + detail : ''}`)); }); child.stdin.end(script); }); } // 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', retries = 3, retryDelayMs = 1000 } = {}) { if (!principal) { throw new Error('Не задан principal администратора (ADMIN_LOGIN/LDAP_REALM) для Kerberos'); } const maxAttempts = retries >= 1 ? retries : 3; const ccacheEnv = { KRB5CCNAME: ccache }; async function destroyCache() { try { await runChild('kdestroy', ['-c', ccache], { env: ccacheEnv, input: '', timeoutMs: 5000, describe: 'kdestroy' }); } catch (_) { /* best-effort cleanup */ } } // Transient GSSAPI/KDC errors (e.g. "Server (ldap/...@REALM) unknown" from // ldapmodify) often pass on retry — DNS canonicalization or DC selection may // differ. Each attempt gets a fresh kinit ticket so a partial/poisoned // ccache can't cause repeated failures. Rewriting the same unicodePwd is // idempotent, so retrying is safe. let lastError = null; for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { 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' }); if (ldapmodify.code !== 0) { const detail = String(ldapmodify.stderr || ldapmodify.stdout || '').trim(); throw new Error(`Ошибка установки пароля через Kerberos/GSSAPI (код ${ldapmodify.code})${detail ? ': ' + detail : ''}`); } await destroyCache(); return; } catch (err) { lastError = err; await destroyCache(); if (attempt < maxAttempts) { console.warn(`⚠ Установка пароля через Kerberos: попытка ${attempt}/${maxAttempts} не удалась, повтор через ${retryDelayMs} мс: ${err.message}`); await new Promise((resolve) => setTimeout(resolve, retryDelayMs)); } } } throw lastError; } module.exports = { setPasswordViaPowerShell, setPasswordViaKerberos, buildPrincipal, buildUnicodePwdLdif, adsiServerPrefix };