254 lines
8.7 KiB
JavaScript
254 lines
8.7 KiB
JavaScript
'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
|
||
}; |