Pular para o conteúdo
Node.js

Node.js: gerando certificado SSL e instalando no IIS

Paloma Macetko
Ilustração de um unicórnio de crina luminosa ao lado de um cadeado dourado fechando sobre um certificado com selo, e um castelo-servidor iluminado por uma varinha

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