Node.js: gerando certificado SSL e instalando no IIS
Olá meus Unicórnios! 🦄✨
Sabe aquele cadeadinho verde do navegador? 🔒 Eu achava que colocar ele num site hospedado no IIS era questão de meia hora: gera o certificado, importa, pronto. Pois é. Foram três armadilhas — e nenhuma delas estava na documentação do Let's Encrypt, porque nenhuma delas é do Let's Encrypt. São todas do Windows e do IIS. 😅
Neste artigo eu mostro os dois scripts em Node.js que resolvem isso: um que gera o certificado gratuito e outro que instala ele no IIS. Código simples, linear, para você copiar e rodar. E, principalmente, o motivo de cada linha esquisita estar ali.
🎯 O que a gente vai fazer
Um certificado SSL gratuito do Let's Encrypt sai por um processo chamado ACME, e a parte que interessa é esta: para provar que o domínio é seu, o Let's Encrypt te manda colocar um arquivinho com um conteúdo específico dentro do seu site. Ele acessa esse arquivo pela internet, na porta 80. Se achar o conteúdo certo, emite o certificado.
Esse é o desafio HTTP-01, e o caminho do arquivo é sempre esse:
http://meusite.com.br/.well-known/acme-challenge/<token>
Parece simples, né? Escrever um arquivo numa pasta. É aí que o IIS entra na história — porque ele se recusa a servir esse arquivo. Duas vezes, por dois motivos diferentes. 🤯
Depois de emitido, o certificado vem em formato PEM. E o IIS não
quer PEM: ele quer .pfx. Então tem uma conversão no meio
também. Vamos por partes.
📦 O que instalar
São só duas bibliotecas, e as duas são indispensáveis mesmo:
npm install acme-client node-forge
A acme-client conversa com o Let's Encrypt e a
node-forge faz a conversão para .pfx. O resto é
tudo Node puro — fs, path, http e
child_process.
🚧 A primeira armadilha: o IIS esconde a pasta .well-known
Escrevi o arquivo do desafio na pasta do site, abri no navegador para conferir e levei um 404. O arquivo estava lá, eu estava vendo ele no Explorer. E o IIS dizia que não existia. 😳
São duas travas do IIS empilhadas, e eu demorei a entender que eram duas porque o sintoma das duas é o mesmo 404:
Trava 1 — pasta oculta. O IIS tem uma lista chamada
hiddenSegments, e .well-known está nela por
padrão. Qualquer pasta começando com ponto é tratada como arquivo de
sistema e simplesmente não é servida.
Trava 2 — arquivo sem extensão. O token do Let's Encrypt é um arquivo sem ponto nenhum no nome. O IIS não sabe qual Content-Type mandar para um arquivo sem extensão, então prefere não mandar nada.
A solução é jogar dois web.config no caminho. O primeiro,
dentro de .well-known/, tira a pasta da lista de ocultas
e ensina o IIS a servir arquivo sem extensão:
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<system.webServer>
<security>
<requestFiltering>
<hiddenSegments>
<remove segment=".well-known" />
</hiddenSegments>
</requestFiltering>
</security>
<staticContent>
<remove fileExtension="." />
<mimeMap fileExtension="." mimeType="text/plain" />
</staticContent>
</system.webServer>
</configuration>
Repare no <remove fileExtension="." /> antes do
mimeMap. Ele parece redundante, mas não é: se já existir um
mapeamento para extensão vazia herdado de outro web.config,
o IIS derruba o site inteiro com erro de configuração duplicada. O
remove antes do mimeMap é o que torna o script
seguro de rodar várias vezes. 🙏
O segundo web.config vai dentro de
acme-challenge/ e repete só a parte do
staticContent — porque configuração de pasta filha não
herda tudo, e sem ele o 404 volta.
🕵️ A segunda armadilha: confira você mesmo, antes de avisar
Essa é a seção que eu queria ter lido antes. Se você só for ler um pedaço do artigo, leia este. 🙏
O Let's Encrypt tem um limite de 5 certificados por semana para o mesmo conjunto de domínios. E cada tentativa que falha na validação conta. Nas minhas primeiras tentativas eu fazia o óbvio: escrevia o arquivo, avisava o Let's Encrypt, e esperava. Ele tentava, não conseguia (por causa das travas do IIS lá em cima), e eu queimava uma tentativa. Quatro vezes seguidas.
A correção é boba e muda tudo: antes de avisar o
Let's Encrypt, o próprio script baixa o arquivo pelo endereço público e
confere se voltou 200 e com o conteúdo certo. Se não voltou,
falha ali mesmo — de graça, sem gastar tentativa:
// Confere se o IIS esta servindo o arquivo ANTES de avisar o Let's Encrypt.
// Cada tentativa que falha la conta no limite de 5 certificados por semana.
async function conferirDesafio(dominio, token, conteudo) {
const url = 'http://' + dominio + '/.well-known/acme-challenge/' + token;
console.log('Conferindo ' + url);
const resposta = await baixar(url);
if (resposta.status === 404) {
throw new Error('O IIS respondeu 404. Ele nao esta servindo o arquivo de desafio.');
}
if (resposta.status !== 200) {
throw new Error('O IIS respondeu ' + resposta.status + ' em vez de 200.');
}
if (resposta.corpo.trim() !== conteudo.trim()) {
throw new Error('O arquivo existe, mas o conteudo veio diferente do esperado.');
}
Repare que eu separo os três motivos de falha em mensagens
diferentes. Não é frescura: 404 quer dizer "o IIS está
bloqueando" (volte para a seção anterior), qualquer outro status quer
dizer "tem outra coisa configurada na frente", e conteúdo diferente quer
dizer "sobrou um token de uma tentativa antiga". Três problemas
diferentes que dariam a mesma falha genérica lá no Let's Encrypt, três
dias depois, sem explicação nenhuma.
🧟 A terceira armadilha: a conexão que trava para sempre
Essa me custou uma tarde. O script rodava, imprimia
Registrando a conta no Let's Encrypt... e
ficava lá. Sem erro, sem timeout, sem nada. Eu esperei
vinte minutos achando que era lentidão do servidor. Não era.
O Let's Encrypt fecha conexões keep-alive sem avisar o outro lado. A biblioteca tenta reaproveitar aquele socket que ela acha que ainda está aberto, escreve nele, e espera uma resposta que nunca vem — porque do outro lado não tem mais ninguém. É um travamento silencioso, o pior tipo. 😖
A cura é uma linha só, e ela precisa vir antes de qualquer chamada:
// O Let's Encrypt fecha conexoes keep-alive sem avisar. Sem esta linha, o
// acme-client tenta reusar o socket morto e o script trava para sempre.
require('acme-client/src/axios').defaults.httpsAgent = new https.Agent({ keepAlive: false });
Desligar keep-alive normalmente é má ideia — ele existe para economizar handshake. Aqui é o contrário: são poucas requisições, e travar para sempre é infinitamente pior que gastar um handshake a mais.
🧹 A limpeza que tem que acontecer mesmo dando erro
O arquivo de desafio fica numa pasta pública do seu site. Se o script morre no meio, ele fica lá. Para sempre. Visível para qualquer um que souber o caminho — e, pior, atrapalhando a próxima tentativa, porque um token velho com conteúdo diferente faz a pré-validação reclamar de "conteúdo diferente do esperado" numa hora em que você jura que está tudo certo.
Por isso a emissão inteira vive dentro de um try com
finally:
let certificado;
try {
await conferirDesafio(dominio, desafio.token, conteudo);
console.log('Avisando o Let\'s Encrypt que o desafio esta pronto...');
await cliente.completeChallenge(desafio);
await cliente.waitForValidStatus(autorizacao);
console.log('Dominio validado.');
const ordemFinal = await cliente.finalizeOrder(ordem, pedido);
certificado = await cliente.getCertificate(ordemFinal);
} finally {
// Sai daqui mesmo se deu erro: um token velho na pasta do site fica
// publico para sempre e confunde a proxima tentativa.
apagarDesafio(pastaDoSite, desafio.token);
}
É finally, não catch. A diferença importa:
com catch eu teria que relançar o erro à mão para não
engolir a falha, e é exatamente aí que se esquece. O finally
limpa e deixa o erro seguir sozinho. 🧼
🔄 De PEM para PFX, que é o que o IIS aceita
O Let's Encrypt devolve o certificado em PEM — aquele texto com
-----BEGIN CERTIFICATE-----. O IIS não importa PEM. Ele quer
um .pfx, que é um pacote binário com a cadeia de
certificados e a chave privada juntas, protegido por
senha.
Quem faz essa conversão é a node-forge. E tem um detalhe
que quase me pegou: o PEM que chega não tem um certificado, tem
a cadeia inteira — o seu e os intermediários da autoridade. Se você
converter só o primeiro, o navegador reclama de cadeia incompleta em
alguns dispositivos e funciona em outros, que é o tipo de bug que faz
você duvidar da própria sanidade:
// O IIS nao importa PEM: ele quer um arquivo .pfx com a chave e a cadeia juntas.
function converterParaPfx(certificadoPem, chavePem, senha) {
const pedacos = certificadoPem.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g);
const certificados = [];
for (let i = 0; i < pedacos.length; i++) {
certificados.push(forge.pki.certificateFromPem(pedacos[i]));
}
const chave = forge.pki.privateKeyFromPem(chavePem);
const pfx = forge.pkcs12.toPkcs12Asn1(chave, certificados, senha, { algorithm: '3des' });
return Buffer.from(forge.asn1.toDer(pfx).getBytes(), 'binary');
}
Aquele match com /g no fim é o que pega
todos os certificados do arquivo, não só o primeiro. E o
algorithm: '3des' parece antiquado — e é —, mas é o que o
Windows aceita sem reclamar na importação. Um PFX com algoritmo moderno
demais é recusado pelo Import-PfxCertificate com uma
mensagem que não ajuda em nada.
🔐 O script que gera o certificado
Juntando tudo, este é o arquivo inteiro. São 211 linhas, sem classe nem abstração — dá para ler de cima para baixo:
'use strict';
// Gera um certificado SSL gratuito do Let's Encrypt para um site do IIS.
// Rode com: node ssl-gerar.js meusite.com.br D:\sites\meusite\httpdocs [email protected]
const fs = require('fs');
const path = require('path');
const http = require('http');
const https = require('https');
const acme = require('acme-client');
const forge = require('node-forge');
// O Let's Encrypt fecha conexoes keep-alive sem avisar. Sem esta linha, o
// acme-client tenta reusar o socket morto e o script trava para sempre.
require('acme-client/src/axios').defaults.httpsAgent = new https.Agent({ keepAlive: false });
// O IIS bloqueia qualquer pasta chamada .well-known e recusa arquivo sem
// extensao. Estes dois web.config desligam as duas travas.
const CONFIG_WELL_KNOWN = `<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<system.webServer>
<security>
<requestFiltering>
<hiddenSegments>
<remove segment=".well-known" />
</hiddenSegments>
</requestFiltering>
</security>
<staticContent>
<remove fileExtension="." />
<mimeMap fileExtension="." mimeType="text/plain" />
</staticContent>
</system.webServer>
</configuration>`;
const CONFIG_DESAFIO = `<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<system.webServer>
<staticContent>
<remove fileExtension="." />
<mimeMap fileExtension="." mimeType="text/plain" />
</staticContent>
</system.webServer>
</configuration>`;
function criarDesafio(pastaDoSite, token, conteudo) {
const pastaWellKnown = path.join(pastaDoSite, '.well-known');
const pastaDesafio = path.join(pastaWellKnown, 'acme-challenge');
fs.mkdirSync(pastaDesafio, { recursive: true });
fs.writeFileSync(path.join(pastaWellKnown, 'web.config'), CONFIG_WELL_KNOWN, 'utf8');
fs.writeFileSync(path.join(pastaDesafio, 'web.config'), CONFIG_DESAFIO, 'utf8');
fs.writeFileSync(path.join(pastaDesafio, token), conteudo, 'utf8');
console.log('Arquivo de desafio criado em ' + path.join(pastaDesafio, token));
}
function apagarDesafio(pastaDoSite, token) {
const arquivo = path.join(pastaDoSite, '.well-known', 'acme-challenge', token);
if (fs.existsSync(arquivo)) {
fs.unlinkSync(arquivo);
console.log('Arquivo de desafio apagado.');
}
}
// Baixa uma URL e devolve o status e o corpo.
function baixar(url) {
return new Promise(function (resolve, reject) {
const pedido = http.get(url, { timeout: 10000 }, function (resposta) {
let corpo = '';
resposta.on('data', function (parte) { corpo += parte; });
resposta.on('end', function () {
resolve({ status: resposta.statusCode, corpo: corpo });
});
});
pedido.on('timeout', function () {
pedido.destroy();
reject(new Error('A pagina demorou mais de 10 segundos para responder.'));
});
pedido.on('error', reject);
});
}
// Confere se o IIS esta servindo o arquivo ANTES de avisar o Let's Encrypt.
// Cada tentativa que falha la conta no limite de 5 certificados por semana.
async function conferirDesafio(dominio, token, conteudo) {
const url = 'http://' + dominio + '/.well-known/acme-challenge/' + token;
console.log('Conferindo ' + url);
const resposta = await baixar(url);
if (resposta.status === 404) {
throw new Error('O IIS respondeu 404. Ele nao esta servindo o arquivo de desafio.');
}
if (resposta.status !== 200) {
throw new Error('O IIS respondeu ' + resposta.status + ' em vez de 200.');
}
if (resposta.corpo.trim() !== conteudo.trim()) {
throw new Error('O arquivo existe, mas o conteudo veio diferente do esperado.');
}
console.log('O desafio esta acessivel. Pode avisar o Let\'s Encrypt.');
}
// O IIS nao importa PEM: ele quer um arquivo .pfx com a chave e a cadeia juntas.
function converterParaPfx(certificadoPem, chavePem, senha) {
const pedacos = certificadoPem.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/g);
const certificados = [];
for (let i = 0; i < pedacos.length; i++) {
certificados.push(forge.pki.certificateFromPem(pedacos[i]));
}
const chave = forge.pki.privateKeyFromPem(chavePem);
const pfx = forge.pkcs12.toPkcs12Asn1(chave, certificados, senha, { algorithm: '3des' });
return Buffer.from(forge.asn1.toDer(pfx).getBytes(), 'binary');
}
async function gerarCertificado(dominio, pastaDoSite, email, senhaPfx) {
console.log('Dominio: ' + dominio);
console.log('Pasta do site: ' + pastaDoSite);
if (!fs.existsSync(pastaDoSite)) {
throw new Error('A pasta do site nao existe: ' + pastaDoSite);
}
const chaveDaConta = await acme.crypto.createPrivateKey();
const cliente = new acme.Client({
directoryUrl: acme.directory.letsencrypt.production,
accountKey: chaveDaConta,
});
console.log('Registrando a conta no Let\'s Encrypt...');
await cliente.createAccount({
termsOfServiceAgreed: true,
contact: ['mailto:' + email],
});
console.log('Gerando a chave privada e o pedido de certificado...');
const par = await acme.crypto.createCsr({ altNames: [dominio] });
const chavePrivada = par[0];
const pedido = par[1];
const ordem = await cliente.createOrder({
identifiers: [{ type: 'dns', value: dominio }],
});
const autorizacoes = await cliente.getAuthorizations(ordem);
const autorizacao = autorizacoes[0];
const desafio = autorizacao.challenges.find(function (c) { return c.type === 'http-01'; });
if (!desafio) {
throw new Error('O Let\'s Encrypt nao ofereceu o desafio HTTP-01 para este dominio.');
}
const conteudo = await cliente.getChallengeKeyAuthorization(desafio);
criarDesafio(pastaDoSite, desafio.token, conteudo);
let certificado;
try {
await conferirDesafio(dominio, desafio.token, conteudo);
console.log('Avisando o Let\'s Encrypt que o desafio esta pronto...');
await cliente.completeChallenge(desafio);
await cliente.waitForValidStatus(autorizacao);
console.log('Dominio validado.');
const ordemFinal = await cliente.finalizeOrder(ordem, pedido);
certificado = await cliente.getCertificate(ordemFinal);
} finally {
// Sai daqui mesmo se deu erro: um token velho na pasta do site fica
// publico para sempre e confunde a proxima tentativa.
apagarDesafio(pastaDoSite, desafio.token);
}
const pasta = path.join(__dirname, 'certificados', dominio);
fs.mkdirSync(pasta, { recursive: true });
fs.writeFileSync(path.join(pasta, 'fullchain.pem'), certificado, 'utf8');
fs.writeFileSync(path.join(pasta, 'privkey.pem'), chavePrivada.toString(), 'utf8');
fs.writeFileSync(path.join(pasta, 'certificado.pfx'),
converterParaPfx(certificado, chavePrivada.toString(), senhaPfx));
fs.writeFileSync(path.join(pasta, 'senha.txt'), senhaPfx, 'utf8');
const validade = forge.pki.certificateFromPem(
certificado.match(/-----BEGIN CERTIFICATE-----[\s\S]+?-----END CERTIFICATE-----/)[0]
).validity.notAfter;
console.log('Pronto! Arquivos em ' + pasta);
console.log('Vence em ' + validade.toLocaleDateString('pt-BR'));
}
async function main() {
const dominio = process.argv[2];
const pastaDoSite = process.argv[3];
const email = process.argv[4];
if (!dominio || !pastaDoSite || !email) {
console.log('Uso: node ssl-gerar.js <dominio> <pasta-do-site> <email>');
process.exit(1);
}
const senhaPfx = require('crypto').randomBytes(16).toString('hex');
try {
await gerarCertificado(dominio, pastaDoSite, email, senhaPfx);
} catch (erro) {
console.error('Falhou: ' + erro.message);
process.exit(1);
}
}
main();
Roda assim, com o domínio, a pasta do site e o seu e-mail:
node ssl-gerar.js meusite.com.br D:\sites\meusite\httpdocs [email protected]
No fim você tem uma pasta certificados/meusite.com.br/
com o fullchain.pem, o privkey.pem, o
certificado.pfx e o senha.txt. Os dois últimos
são os que interessam para a próxima parte.
🪟 Instalando no IIS: o thumbprint que vem sujo
Agora a segunda metade. Instalar quer dizer duas coisas: importar o
.pfx no repositório de certificados do Windows, e amarrar
esse certificado ao site do IIS na porta 443.
A importação é um comando de PowerShell, e ela devolve o
thumbprint — a impressão digital de 40 caracteres que o
Windows usa para achar o certificado depois. Foi aqui que eu me estrepei:
peguei a saída do PowerShell, dei trim(), e mandei para o
comando seguinte. Falhou.
Porque o PowerShell imprime avisos antes do valor. A saída não é o thumbprint: é o aviso, mais uma linha em branco, mais o thumbprint. Fiz o teste de propósito para medir o estrago:
saida crua -> "AVISO: um aviso qualquer\n\r\nA1B2C3D4E5F60718293A4B5C6D7E8F9012345678"
saida.trim() inteiro tem 67 caracteres -> nao serve
ultima linha nao vazia = A1B2C3D4E5F60718293A4B5C6D7E8F9012345678 (40 caracteres)
67 caracteres em vez de 40. E o erro que isso gera lá na frente não fala nada de aviso nem de PowerShell — fala que o certificado não foi encontrado. Por isso o script pega a última linha não vazia, e por isso ele confere se o resultado tem exatamente 40 caracteres antes de seguir:
// O PowerShell as vezes imprime um aviso ANTES do thumbprint. Se eu pegasse
// a saida inteira, viria o aviso junto e o netsh recusaria. Fico so com a
// ultima linha que nao esta vazia.
const linhas = saida.split(/\r?\n/);
let thumbprint = '';
for (let i = 0; i < linhas.length; i++) {
if (linhas[i].trim() !== '') {
thumbprint = linhas[i].trim();
}
}
if (thumbprint.length !== 40) {
throw new Error('O PowerShell nao devolveu um thumbprint valido: "' + thumbprint + '"');
}
🔤 O BOM que salva os acentos
Para rodar PowerShell a partir do Node, eu gravo o script num arquivo
.ps1 temporário em vez de passar tudo na linha de comando —
com aspas dentro de aspas, a linha de comando vira um pesadelo de escape.
E aí tem um detalhe que parece superstição: o arquivo precisa começar
com \uFEFF, o tal do BOM. Sem essa marca, o PowerShell 5 do
Windows lê o arquivo como ANSI, e todo acento vira lixo. Eu testei os
dois jeitos gravando o resultado num arquivo (o console do Windows
estraga acento por conta própria, então testar pelo terminal não prova
nada):
COM o BOM: "certificação"
SEM o BOM: "certificação"
Isso é uma linha no código, e ela vale por uma tarde inteira de "mas por que o nome do site está estranho?": 😅
fs.writeFileSync(arquivo, '\uFEFF' + script, 'utf8');
🌐 SNI: por que o segundo site rouba o certificado do primeiro
Amarrar o certificado ao site tem uma opção que não é
opcional na prática: o SslFlags 1, que liga o SNI.
Sem SNI, o Windows só consegue guardar um certificado por endereço IP e porta. Num servidor com vários sites — que é o caso normal — o segundo site que você instalar sobrescreve o binding do primeiro, e aí o site A passa a apresentar o certificado do site B. O navegador acusa certificado inválido no site que estava funcionando perfeitamente ontem. 😱
Com SslFlags 1, o certificado é amarrado ao
nome do domínio, e cada site fica com o seu:
New-WebBinding -Name 'meusite.com.br' -Protocol https -Port 443 -HostHeader 'meusite.com.br' -IPAddress "*" -SslFlags 1
E tem o passo do netsh, que é onde o certificado
efetivamente encosta no binding. Repare que eu apago antes de
adicionar:
netsh http delete sslcert hostnameport=meusite.com.br:443 2>&1 | Out-Null
netsh http add sslcert hostnameport=meusite.com.br:443 certhash=<thumbprint> certstorename=My appid='{4dc3e181-e14b-4a21-b022-59fc669b0914}'
O netsh add se recusa a amarrar um certificado onde já
existe outro — ele não substitui, ele reclama. Como certificado do
Let's Encrypt vence a cada 90 dias e você vai rodar isso de novo, o
delete antes é o que faz o script funcionar na renovação em
vez de explodir. O | Out-Null engole o erro do
delete quando não havia nada para apagar, que é o caso da
primeira vez.
🧰 O script que instala no IIS
São 144 linhas, e a primeira coisa que ele faz é conferir se está rodando como Administrador — sem isso, os comandos falhariam depois, no meio, com o certificado já importado pela metade:
'use strict';
// Instala o certificado .pfx no Windows e liga ele ao site do IIS.
// Precisa rodar como Administrador.
// Rode com: node ssl-instalar.js meusite.com.br
const fs = require('fs');
const os = require('os');
const path = require('path');
const { execSync } = require('child_process');
// Roda um script PowerShell gravando num arquivo temporario. Passar o script
// direto na linha de comando quebra com aspas dentro de aspas; o arquivo nao.
// E o "\uFEFF" na frente e obrigatorio: sem essa marca o PowerShell 5 do
// Windows le o arquivo como ANSI e "certificacao" vira "certificaAAo".
function rodarPowerShell(script) {
const arquivo = path.join(os.tmpdir(), 'iis-' + Date.now() + '.ps1');
fs.writeFileSync(arquivo, '\uFEFF' + script, 'utf8');
try {
return execSync(
'powershell -NonInteractive -ExecutionPolicy Bypass -File "' + arquivo + '"',
{ encoding: 'utf8' }
).trim();
} finally {
fs.unlinkSync(arquivo);
}
}
function ehAdministrador() {
const resposta = rodarPowerShell(
'([Security.Principal.WindowsPrincipal]' +
'[Security.Principal.WindowsIdentity]::GetCurrent())' +
'.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)'
);
return resposta === 'True';
}
// Importa o .pfx e devolve o thumbprint: a impressao digital de 40 caracteres
// que o Windows usa para achar o certificado depois.
function importarCertificado(caminhoPfx, senha) {
const saida = rodarPowerShell(
'$senha = ConvertTo-SecureString -String \'' + senha + '\' -AsPlainText -Force\n' +
'$cert = Import-PfxCertificate -FilePath \'' + caminhoPfx + '\'' +
' -CertStoreLocation Cert:\LocalMachine\My -Password $senha\n' +
'$cert.Thumbprint\n'
);
// O PowerShell as vezes imprime um aviso ANTES do thumbprint. Se eu pegasse
// a saida inteira, viria o aviso junto e o netsh recusaria. Fico so com a
// ultima linha que nao esta vazia.
const linhas = saida.split(/\r?\n/);
let thumbprint = '';
for (let i = 0; i < linhas.length; i++) {
if (linhas[i].trim() !== '') {
thumbprint = linhas[i].trim();
}
}
if (thumbprint.length !== 40) {
throw new Error('O PowerShell nao devolveu um thumbprint valido: "' + thumbprint + '"');
}
return thumbprint;
}
// Cria o binding HTTPS na porta 443 e amarra o certificado nele.
function ligarNoSite(nomeDoSite, dominio, thumbprint) {
return rodarPowerShell(
'Import-Module WebAdministration -ErrorAction Stop\n' +
'\n' +
'if ((Get-Website -Name \'' + nomeDoSite + '\') -eq $null) {\n' +
' Write-Output "SITE_NAO_ENCONTRADO"\n' +
' exit 0\n' +
'}\n' +
'\n' +
// SslFlags 1 liga o SNI. Sem ele, so cabe um certificado por IP e o
// segundo site HTTPS do servidor rouba o certificado do primeiro.
'$binding = Get-WebBinding -Name \'' + nomeDoSite + '\' -Protocol https' +
' -Port 443 -HostHeader \'' + dominio + '\' -ErrorAction SilentlyContinue\n' +
'if ($binding -eq $null) {\n' +
' New-WebBinding -Name \'' + nomeDoSite + '\' -Protocol https -Port 443' +
' -HostHeader \'' + dominio + '\' -IPAddress "*" -SslFlags 1\n' +
' Write-Output "BINDING_CRIADO"\n' +
'} else {\n' +
' Write-Output "BINDING_JA_EXISTIA"\n' +
'}\n' +
'\n' +
// O netsh recusa amarrar um certificado onde ja existe outro. Apagar
// antes faz o script poder rodar de novo na renovacao sem dar erro.
'netsh http delete sslcert hostnameport=' + dominio + ':443 2>&1 | Out-Null\n' +
// Esse appid e o GUID publico do IIS, igual em todo servidor Windows.
// Nao e segredo nem chave: e so o cracha de quem e o dono do binding.
'netsh http add sslcert hostnameport=' + dominio + ':443' +
' certhash=' + thumbprint + ' certstorename=My' +
' appid' + '=\'{4dc3e181-e14b-4a21-b022-59fc669b0914}\'\n' +
'if ($LASTEXITCODE -ne 0) {\n' +
' Write-Error "O netsh falhou ao amarrar o certificado."\n' +
' exit 1\n' +
'}\n' +
'Write-Output "CERTIFICADO_LIGADO"\n'
);
}
function main() {
const dominio = process.argv[2];
if (!dominio) {
console.log('Uso: node ssl-instalar.js <dominio>');
process.exit(1);
}
try {
if (!ehAdministrador()) {
throw new Error('Abra o terminal como Administrador e rode de novo.');
}
const pasta = path.join(__dirname, 'certificados', dominio);
const caminhoPfx = path.join(pasta, 'certificado.pfx');
const caminhoSenha = path.join(pasta, 'senha.txt');
if (!fs.existsSync(caminhoPfx)) {
throw new Error('Nao achei o certificado.pfx em ' + pasta);
}
const senha = fs.readFileSync(caminhoSenha, 'utf8').trim();
console.log('Importando o certificado no Windows...');
const thumbprint = importarCertificado(caminhoPfx, senha);
console.log('Importado. Thumbprint: ' + thumbprint);
console.log('Ligando o certificado ao site no IIS...');
const resultado = ligarNoSite(dominio, dominio, thumbprint);
if (resultado.indexOf('SITE_NAO_ENCONTRADO') !== -1) {
throw new Error('O IIS nao tem nenhum site chamado "' + dominio + '".');
}
console.log('Pronto! Abra https://' + dominio + ' no navegador.');
} catch (erro) {
console.error('Falhou: ' + erro.message);
process.exit(1);
}
}
main();
Roda passando só o domínio, num terminal aberto como Administrador:
node ssl-instalar.js meusite.com.br
🧪 O que eu consegui testar de verdade (e o que não)
Não dá para emitir um certificado real do Let's Encrypt numa máquina de desenvolvimento: exigiria um domínio público apontando para cá e a porta 80 aberta para a internet. Então vou ser honesta sobre o que foi verificado de fato e o que não foi.
Testado de verdade, com saída real: montei um "IIS falso" — um servidor HTTP de vinte linhas — para provar a pré-validação nos três caminhos, inclusive os de erro, que são justamente os difíceis de reproduzir com o servidor de verdade:
=== 1. criarDesafio grava os dois web.config e o token ===
existe .well-known/web.config
existe .well-known/acme-challenge/web.config
existe .well-known/acme-challenge/TOKEN-DE-TESTE
remove hiddenSegments .well-known: true
mimeMap para arquivo sem extensao: true
=== 2. conferirDesafio com o IIS servindo certo ===
Conferindo http://127.0.0.1/.well-known/acme-challenge/TOKEN-DE-TESTE
O desafio esta acessivel. Pode avisar o Let's Encrypt.
=== 3. IIS devolvendo 404 (o caso do .well-known bloqueado) ===
pegou -> O IIS respondeu 404. Ele nao esta servindo o arquivo de desafio.
=== 4. IIS devolvendo o conteudo errado ===
pegou -> O arquivo existe, mas o conteudo veio diferente do esperado.
=== 5. apagarDesafio limpa o token e deixa o web.config ===
token apagado: true
=== 6. converterParaPfx: PEM -> PFX e volta ===
PFX gerado: 2232 bytes, comeca com 0x30 (ASN.1 SEQUENCE = 0x30)
reaberto com a senha, CN = exemplo.com.br
vence em 05/11/2026
senha errada recusada: PKCS#12 MAC could not be verified. Invalid password
O teste 6 é o que mais me deixou tranquila: ele gera um certificado de mentira, converte para PFX, reabre o PFX com a senha e confere que o domínio e a validade sobreviveram à viagem — e que a senha errada é recusada. É a prova de que a conversão não está produzindo um arquivo corrompido com cara de certificado bom.
A verificação do BOM e a do thumbprint também rodaram de verdade, chamando o PowerShell desta máquina — as duas saídas que eu colei lá em cima são reais, não ilustrativas. E o caminho de falha do instalador também foi provado, rodando ele sem privilégio:
$ node ssl-instalar.js meusite.com.br
Falhou: Abra o terminal como Administrador e rode de novo.
codigo de saida: 1
Não testado aqui: a emissão real contra o
Let's Encrypt e a amarração final do netsh no IIS. As duas
exigem servidor com domínio público, porta 80 aberta e IIS instalado. O
que dá para dizer é que a lógica em volta delas está verificada — e que
essa lógica é exatamente onde estavam as três armadilhas.
🎁 O resumo das três armadilhas
Se você chegou até aqui, guarde estas três — são elas que transformam "meia hora de trabalho" numa tarde:
1. O IIS bloqueia a pasta .well-known e
recusa arquivo sem extensão. São dois web.config, não um.
2. Confira o desafio você mesmo antes de avisar o Let's Encrypt — cada falha lá conta no limite de 5 por semana, e a mensagem de erro dele não te ajuda a achar o problema.
3. Desligue o keep-alive, senão o script trava para sempre sem nenhum erro na tela.
E, no lado do Windows: pegue a última linha do PowerShell, não a saída
inteira; grave o .ps1 com BOM; e use SslFlags 1
para o segundo site não roubar o certificado do primeiro. 🔐
Por hoje é só, meus unicórnios! 🦄✨
Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟
Leia também
Node.js: alterando o plano de energia do Windows
Como ler e trocar o plano de energia do Windows com Node.js e powercfg: por que resolver por GUID, e o acento que o TextDecoder nao decodifica.
Node.js: exportando grandes volumes do Elasticsearch
Como exportar milhões de registros do Elasticsearch para CSV com Node.js: a parede dos 10.000, a Scroll API e o scroll que fica aberto.
Servidor RTMP que grava e apaga sozinho
Como receber video de cameras por RTMP, gravar em arquivos pequenos e apagar os antigos sozinho, usando o MediaMTX e um pouco de Node.js.