This commit is contained in:
2026-08-21 11:35:47 +05:00
parent a70db05487
commit 271f79d6f0
5 changed files with 200 additions and 9 deletions

View File

@@ -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

24
config/krb5.conf Normal file
View File

@@ -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.<domain>) 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

View File

@@ -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),

View File

@@ -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] } }]);

View File

@@ -87,4 +87,140 @@ function setPasswordViaPowerShell(userDN, password, { exe = 'powershell.exe', ti
});
}
module.exports = setPasswordViaPowerShell;
// 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
};