Pular para o conteúdo
Node.js

Node.js: desligar por SSH e ligar por Wake-on-LAN

Paloma Macetko
Ilustração colorida de um unicórnio de crina arco-íris cantando uma canção de ninar para um servidor de rede que apaga as luzes, uma coruja mensageira trazendo um envelope brilhante para acordá-lo e um cabo de rede em linhas de luz ligando um castelo com velas flutuantes

Olá meus Unicórnios! 🦄✨

Desligar uma máquina remota é fácil. Todo mundo sabe: entra por SSH, manda shutdown, pronto. 😌

O problema é o outro lado. Depois que a máquina desligou, ela não tem mais SSH, não tem mais serviço nenhum, não tem mais nada escutando. Como é que você liga de volta uma coisa que está desligada? 🤔

A resposta é linda e tem trinta anos de idade: Wake-on-LAN. A placa de rede continua energizada mesmo com o computador desligado, e ela fica ali, quietinha, esperando um pacote muito específico passar. Quando esse pacote chega, ela liga a máquina.

Este artigo é o caminho de ida e volta: desligar por SSH e ligar pelo pacote mágico, com Node.js puro. E eu vou montar o pacote na mão, byte por byte, porque é justamente a parte bonita da história. 💜

🪄 O pacote mágico não tem nada de mágico

O nome assusta, mas o "magic packet" é a coisa mais simples deste artigo. Ele é um pacote UDP com exatamente 102 bytes, montado assim:

6 bytes    0xFF 0xFF 0xFF 0xFF 0xFF 0xFF
+
96 bytes   o MAC da placa (6 bytes) repetido 16 vezes
---------
102 bytes

Só isso. Não tem cabeçalho, não tem checksum, não tem senha, não tem versão. Seis bytes FF para dizer "olha, é um pacote mágico" e o MAC dezesseis vezes para dizer "e é para você".

Em Node.js, o módulo dgram monta isso sem instalar absolutamente nada:

// Tira os separadores do MAC e devolve os 12 digitos hexadecimais.
// Aceita 02:00:5e:10:00:01, 02-00-5e-10-00-01 ou 02005e100001.
function limparMac(texto) {
    const hex = String(texto).toLowerCase().replace(/[^0-9a-f]/g, '');
    if (hex.length !== 12) {
        throw new Error('MAC invalido: "' + texto + '". Preciso de 12 digitos hexadecimais.');
    }
    return hex;
}

// O pacote magico e sempre: 6 bytes 0xFF + o MAC repetido 16 vezes.
// 6 + 16 * 6 = 102 bytes. Errar essa conta faz o pacote sair pela rede
// e a maquina simplesmente ignorar, sem erro nenhum para voce ver.
function montarPacoteMagico(mac) {
    const hex = limparMac(mac);
    const bytesDoMac = Buffer.from(hex, 'hex');
    const pacote = Buffer.alloc(102);

    pacote.fill(0xff, 0, 6);

    for (let i = 0; i < 16; i++) {
        bytesDoMac.copy(pacote, 6 + i * 6);
    }

    return pacote;
}

Repare no Buffer.alloc(102): 6 + 16 × 6 = 102. Essa conta parece boba, mas é a armadilha número um do Wake-on-LAN — e ela é silenciosa. 😱

Se você repetir o MAC 15 vezes, ou 17, o pacote sai da sua máquina perfeitamente, a rede entrega perfeitamente, e a placa de rede do outro lado simplesmente ignora. Nenhum erro, nenhum log, nenhuma pista. Você fica ali olhando para o servidor apagado achando que o Wake-on-LAN não funciona no seu hardware, quando o problema é uma repetição a mais no for. 🙃

📡 Broadcast: a linha que todo mundo esquece

Pacote montado, agora é enviar. E aqui vem a segunda pedra:

// Manda o pacote por UDP para o endereco de broadcast da rede.
function enviarPacoteMagico(pacote, destino, porta) {
    return new Promise(function (resolve, reject) {
        const socket = dgram.createSocket('udp4');

        socket.on('error', function (erro) {
            socket.close();
            reject(erro);
        });

        socket.bind(function () {
            // Sem esta linha o envio para um endereco de broadcast falha
            // com EACCES. O sistema so deixa transmitir para todo mundo
            // se o programa pedir essa permissao explicitamente.
            socket.setBroadcast(true);

            socket.send(pacote, porta, destino, function (erro) {
                socket.close();
                if (erro) {
                    reject(erro);
                } else {
                    resolve();
                }
            });
        });
    });
}

Essa linha aí no meio é a que faz a diferença:

socket.setBroadcast(true);

O pacote mágico vai para o endereço de broadcast da rede (aquele terminado em .255), porque a máquina de destino está desligada — ela não tem IP, não responde ARP, não existe para a rede. O jeito de falar com ela é gritar para a rede inteira e deixar que a placa dela reconheça o próprio MAC lá dentro.

Só que o sistema operacional não deixa um programa qualquer transmitir para todo mundo sem pedir licença. Sem o setBroadcast(true), o send falha com EACCES. Esse pelo menos dá erro — é o tipo de falha simpática, que te conta o que houve. 😅

Sobre a porta: eu uso a 9, e você vai ver a 7 por aí também. As duas funcionam, e qualquer outra também funcionaria — a placa de rede nem chega a olhar a porta UDP. Ela vasculha o quadro Ethernet procurando aquele padrão de FF seguido do MAC repetido. A porta 9 é convenção entre humanos, não exigência do protocolo. 🤓

🔬 Provando o pacote byte a byte

Eu não gosto de acreditar em código de rede sem ver o que saiu do outro lado. Então subi um receptor UDP na porta 9 do 127.0.0.1, mandei o pacote para lá e pedi para ele imprimir o que chegou:

Receptor de teste escutando 127.0.0.1:9
Pacote montado: 102 bytes
Chegaram 102 bytes na porta 9
  6 primeiros bytes : ffffffffffff
  1a copia do MAC   : 02005e100001
  16a copia do MAC  : 02005e100001
  copias do MAC     : 16/16
  tamanho 102       : true
  prefixo ffffffffffff : true

Pronto: os seis FF na frente, o MAC na primeira cópia, o mesmo MAC na décima sexta, e as 16 repetições conferidas uma a uma. 🎉

Gosto muito desse truque porque ele resolve o impasse do Wake-on-LAN: a confirmação definitiva seria uma máquina ligando, mas a parte que você realmente escreveu — a montagem dos bytes — dá para conferir sozinha, em cima da mesa, mandando o pacote para o próprio computador. E de quebra você não corre o risco de acordar sem querer alguma máquina da casa. 😬

💀 Desligando por SSH: o comando que "falha" quando dá certo

Agora o lado de ida. Para o SSH eu escolhi chamar o ssh do próprio sistema com child_process, em vez de usar uma biblioteca como a ssh2. O motivo é simples: assim o script fica com zero dependências, e o comando que ele executa é o mesmo que você digitaria no terminal — dá para ler e entender sem conhecer API nenhuma.

// Monta o comando ssh que sera executado. Separado em funcao propria
// para dar para conferir o comando sem executar nada.
function montarComandoSsh(usuario, host, comandoRemoto) {
    return [
        '-o', 'BatchMode=yes',        // nunca pergunta senha: falha em vez de travar
        '-o', 'ConnectTimeout=10',
        usuario + '@' + host,
        comandoRemoto,
    ];
}

Duas opções ali merecem atenção. O BatchMode=yes proíbe o SSH de perguntar qualquer coisa: sem ele, se a chave não estiver configurada, o script fica parado para sempre esperando alguém digitar uma senha que ninguém vai digitar. Com ele, falha na hora e você descobre o problema. 🙏

E o sudo -n, lá na chamada, tem o mesmo espírito: o -n manda o sudo desistir em vez de pedir senha. Sem TTY, um sudo que resolve perguntar é um script travado.

Mas a joia da coroa é esta:

function desligarServidor() {
    return new Promise(function (resolve, reject) {
        // O sudo precisa de -n: sem TTY ele tentaria pedir a senha e o
        // script ficaria parado para sempre esperando alguem digitar.
        const argumentos = montarComandoSsh(USUARIO, HOST, 'sudo -n /sbin/shutdown -h now');

        console.log('Executando: ssh ' + argumentos.join(' '));

        execFile('ssh', argumentos, { timeout: 20000 }, function (erro, saida, erroSaida) {
            const texto = String(saida) + String(erroSaida);

            // Aqui mora a maior confusao do desligamento remoto: o shutdown
            // derruba a propria conexao SSH que o disparou. O ssh devolve
            // codigo 255 ("Connection closed") e parece que falhou, quando
            // na verdade deu certo. Por isso 255 nao e erro aqui.
            if (erro && erro.code !== 255) {
                reject(new Error('SSH falhou: ' + texto.trim()));
                return;
            }

            resolve(texto.trim());
        });
    });
}

Leia o comentário do meio com carinho, porque é o erro que faz todo mundo achar que não funcionou. 🤯

Pense no que você acabou de pedir: mandou o servidor desligar. O servidor obedece. E ao desligar, ele derruba... a conexão SSH pela qual você mandou o comando. O ssh na sua máquina vê a conexão cair no meio, e faz a única coisa razoável: reporta erro, com código de saída 255.

Ou seja: o sucesso e o fracasso se parecem. Deu certo? 255. O host não responde? Também 255. Foi por isso que eu tratei o 255 como aceitável e deixei a verificação de verdade para depois — quem diz se desligou mesmo não é o código de saída, é a máquina sumir da rede.

Aliás, o 255 não é teoria minha. Apontando o comando para um endereço que não responde, é exatamente o que volta:

ssh -o BatchMode=yes -o ConnectTimeout=10 [email protected] echo teste
codigo de saida: 255
mensagem: ssh: connect to host 192.0.2.10 port 22: Connection timed out

👀 Confiar é bom, olhar a porta 22 é melhor

Se o código de saída não serve para saber se deu certo, o que serve? A rede. Máquina ligada tem a porta 22 aberta; máquina desligada não tem. Então é só ficar espiando:

// Tenta abrir a porta 22. Devolve true se conectou, false se nao.
function portaAberta(host, porta) {
    return new Promise(function (resolve) {
        const socket = new net.Socket();
        let respondido = false;

        function terminar(valor) {
            if (respondido) {
                return;
            }
            respondido = true;
            socket.destroy();
            resolve(valor);
        }

        socket.setTimeout(2500);
        socket.once('connect', function () { terminar(true); });
        socket.once('timeout', function () { terminar(false); });
        socket.once('error', function () { terminar(false); });
        socket.connect(porta, host);
    });
}

function esperar(ms) {
    return new Promise(function (resolve) { setTimeout(resolve, ms); });
}

// Fica checando a porta 22 ate ela ficar do jeito esperado.
// esperado = true  -> espera o servidor voltar
// esperado = false -> espera o servidor sumir
async function esperarResponder(host, esperado, segundos) {
    const limite = Date.now() + segundos * 1000;

    while (Date.now() < limite) {
        const aberta = await portaAberta(host, 22);

        if (aberta === esperado) {
            return true;
        }

        console.log('  ainda nao... (porta 22 ' + (aberta ? 'aberta' : 'fechada') + ')');
        await esperar(5000);
    }

    return false;
}

Achei elegante o mesmo esperarResponder servir para os dois lados da história: passando false ele espera o servidor sumir (confirmação do desligamento), passando true ele espera o servidor voltar (confirmação do Wake-on-LAN). Um parâmetro, dois usos. 😄

Repare também nos prazos diferentes: 180 segundos para desligar e 300 para ligar. Não é chute — ligar demora bem mais, porque a máquina passa pelo teste da BIOS, acorda os discos e sobe o sistema inteiro antes de o SSH existir. Prepare-se para esperar. ⏳

🔌 O que nenhum código do mundo resolve

Essa seção é a mais importante do artigo, e é a que não tem uma linha de JavaScript. 🙏

Se o Wake-on-LAN estiver desligado na BIOS ou na placa de rede, pode escrever o código mais bonito do mundo que a máquina não acorda. O pacote chega, a placa recebe, e ela não faz nada — porque ninguém mandou ela fazer.

São dois lugares para conferir, e as pessoas costumam esquecer o segundo:

  • Na BIOS/UEFI: procure por Wake on LAN, Power on by PCI-E ou Resume by LAN. Os nomes variam por fabricante.
  • No sistema operacional: no Linux, o ethtool mostra e configura. O Wake-on: g é o que você quer — o g é de magic packet.
sudo ethtool eth0 | grep Wake-on
sudo ethtool -s eth0 wol g

E mais duas armadilhas físicas, que também não têm conserto em software:

Wake-on-LAN quer cabo. Por Wi-Fi, na esmagadora maioria dos equipamentos, simplesmente não existe — a placa sem fio não fica em standby escutando. Se o servidor está no Wi-Fi, essa história toda não se aplica.

O ajuste do ethtool costuma sumir no reboot. Ele vale para a sessão atual; se você quer o comportamento permanente, precisa fixar em alguma configuração de rede que rode no boot. Já é um belo motivo para conferir com o ethtool antes de culpar o código. 😅

📦 O script inteiro

Aqui está tudo junto, do jeito que roda. Um arquivo, lido de cima para baixo, sem instalar nadadgram, net e child_process já vêm no Node.js:

// servidor.js — desliga um servidor Linux por SSH e liga por Wake-on-LAN.
//
// Configuracao por variavel de ambiente (nunca escreva senha aqui dentro):
//   SERVIDOR_HOST  endereco do servidor        ex.: 192.0.2.10
//   SERVIDOR_USER  usuario do SSH              ex.: operador
//   SERVIDOR_MAC   MAC da placa de rede        ex.: 02:00:5e:10:00:01
//   SERVIDOR_BCAST endereco de broadcast       ex.: 192.0.2.255
//
// Uso:
//   node servidor.js desligar
//   node servidor.js ligar

const dgram = require('dgram');
const net = require('net');
const { execFile } = require('child_process');

const HOST = process.env.SERVIDOR_HOST || '192.0.2.10';
const USUARIO = process.env.SERVIDOR_USER || 'operador';
const MAC = process.env.SERVIDOR_MAC || '02:00:5e:10:00:01';
const BROADCAST = process.env.SERVIDOR_BCAST || '192.0.2.255';

// ---------------------------------------------------------------------------
// Wake-on-LAN: montando o pacote magico na mao
// ---------------------------------------------------------------------------

// Tira os separadores do MAC e devolve os 12 digitos hexadecimais.
// Aceita 02:00:5e:10:00:01, 02-00-5e-10-00-01 ou 02005e100001.
function limparMac(texto) {
    const hex = String(texto).toLowerCase().replace(/[^0-9a-f]/g, '');
    if (hex.length !== 12) {
        throw new Error('MAC invalido: "' + texto + '". Preciso de 12 digitos hexadecimais.');
    }
    return hex;
}

// O pacote magico e sempre: 6 bytes 0xFF + o MAC repetido 16 vezes.
// 6 + 16 * 6 = 102 bytes. Errar essa conta faz o pacote sair pela rede
// e a maquina simplesmente ignorar, sem erro nenhum para voce ver.
function montarPacoteMagico(mac) {
    const hex = limparMac(mac);
    const bytesDoMac = Buffer.from(hex, 'hex');
    const pacote = Buffer.alloc(102);

    pacote.fill(0xff, 0, 6);

    for (let i = 0; i < 16; i++) {
        bytesDoMac.copy(pacote, 6 + i * 6);
    }

    return pacote;
}

// Manda o pacote por UDP para o endereco de broadcast da rede.
function enviarPacoteMagico(pacote, destino, porta) {
    return new Promise(function (resolve, reject) {
        const socket = dgram.createSocket('udp4');

        socket.on('error', function (erro) {
            socket.close();
            reject(erro);
        });

        socket.bind(function () {
            // Sem esta linha o envio para um endereco de broadcast falha
            // com EACCES. O sistema so deixa transmitir para todo mundo
            // se o programa pedir essa permissao explicitamente.
            socket.setBroadcast(true);

            socket.send(pacote, porta, destino, function (erro) {
                socket.close();
                if (erro) {
                    reject(erro);
                } else {
                    resolve();
                }
            });
        });
    });
}

async function ligarServidor() {
    const pacote = montarPacoteMagico(MAC);

    console.log('MAC alvo: ' + MAC);
    console.log('Pacote montado: ' + pacote.length + ' bytes');

    // A porta 9 e a convencao do Wake-on-LAN (a 7 tambem aparece por ai).
    // A placa de rede nem olha a porta: ela procura o padrao dentro do
    // quadro Ethernet. Qualquer porta UDP funcionaria.
    await enviarPacoteMagico(pacote, BROADCAST, 9);

    console.log('Pacote magico enviado para ' + BROADCAST + ':9');
}

// ---------------------------------------------------------------------------
// Desligar por SSH
// ---------------------------------------------------------------------------

// Monta o comando ssh que sera executado. Separado em funcao propria
// para dar para conferir o comando sem executar nada.
function montarComandoSsh(usuario, host, comandoRemoto) {
    return [
        '-o', 'BatchMode=yes',        // nunca pergunta senha: falha em vez de travar
        '-o', 'ConnectTimeout=10',
        usuario + '@' + host,
        comandoRemoto,
    ];
}

function desligarServidor() {
    return new Promise(function (resolve, reject) {
        // O sudo precisa de -n: sem TTY ele tentaria pedir a senha e o
        // script ficaria parado para sempre esperando alguem digitar.
        const argumentos = montarComandoSsh(USUARIO, HOST, 'sudo -n /sbin/shutdown -h now');

        console.log('Executando: ssh ' + argumentos.join(' '));

        execFile('ssh', argumentos, { timeout: 20000 }, function (erro, saida, erroSaida) {
            const texto = String(saida) + String(erroSaida);

            // Aqui mora a maior confusao do desligamento remoto: o shutdown
            // derruba a propria conexao SSH que o disparou. O ssh devolve
            // codigo 255 ("Connection closed") e parece que falhou, quando
            // na verdade deu certo. Por isso 255 nao e erro aqui.
            if (erro && erro.code !== 255) {
                reject(new Error('SSH falhou: ' + texto.trim()));
                return;
            }

            resolve(texto.trim());
        });
    });
}

// ---------------------------------------------------------------------------
// Esperar o servidor responder (ou parar de responder)
// ---------------------------------------------------------------------------

// Tenta abrir a porta 22. Devolve true se conectou, false se nao.
function portaAberta(host, porta) {
    return new Promise(function (resolve) {
        const socket = new net.Socket();
        let respondido = false;

        function terminar(valor) {
            if (respondido) {
                return;
            }
            respondido = true;
            socket.destroy();
            resolve(valor);
        }

        socket.setTimeout(2500);
        socket.once('connect', function () { terminar(true); });
        socket.once('timeout', function () { terminar(false); });
        socket.once('error', function () { terminar(false); });
        socket.connect(porta, host);
    });
}

function esperar(ms) {
    return new Promise(function (resolve) { setTimeout(resolve, ms); });
}

// Fica checando a porta 22 ate ela ficar do jeito esperado.
// esperado = true  -> espera o servidor voltar
// esperado = false -> espera o servidor sumir
async function esperarResponder(host, esperado, segundos) {
    const limite = Date.now() + segundos * 1000;

    while (Date.now() < limite) {
        const aberta = await portaAberta(host, 22);

        if (aberta === esperado) {
            return true;
        }

        console.log('  ainda nao... (porta 22 ' + (aberta ? 'aberta' : 'fechada') + ')');
        await esperar(5000);
    }

    return false;
}

// ---------------------------------------------------------------------------
// Programa principal
// ---------------------------------------------------------------------------

async function main() {
    const acao = process.argv[2];

    if (acao === 'desligar') {
        console.log('Desligando ' + USUARIO + '@' + HOST + ' ...');
        await desligarServidor();
        console.log('Comando enviado. Esperando o servidor sumir da rede...');

        const sumiu = await esperarResponder(HOST, false, 180);
        if (sumiu) {
            console.log('Servidor desligado.');
        } else {
            console.log('O servidor ainda responde. Confira manualmente.');
        }
        return;
    }

    if (acao === 'ligar') {
        await ligarServidor();
        console.log('Esperando o servidor voltar...');

        const voltou = await esperarResponder(HOST, true, 300);
        if (voltou) {
            console.log('Servidor ligado.');
        } else {
            console.log('O servidor nao voltou. Confira se o Wake-on-LAN esta ligado na BIOS.');
        }
        return;
    }

    console.log('Uso: node servidor.js desligar');
    console.log('     node servidor.js ligar');
}

// Sem este if, dar require() neste arquivo executaria o main() junto e
// imprimiria o texto de uso do lado de quem so queria uma funcao.
if (require.main === module) {
    main().catch(function (erro) {
        console.error('Erro: ' + erro.message);
        process.exit(1);
    });
}

module.exports = { montarPacoteMagico, limparMac, montarComandoSsh };

Para usar, exporte as variáveis e chame:

export SERVIDOR_HOST=192.0.2.10
export SERVIDOR_USER=operador
export SERVIDOR_MAC=02:00:5e:10:00:01
export SERVIDOR_BCAST=192.0.2.255

node servidor.js desligar
node servidor.js ligar

Repare que não existe senha em lugar nenhum. O desligamento depende de chave SSH já configurada e de um sudo que não peça senha para o shutdown — que é como isso deve ser feito quando um script está no comando. Senha escrita no arquivo é o começo de uma história ruim. 🔐

🎁 O detalhe que me pegou de surpresa

Quando fui montar o teste do pacote, dei um require no próprio script para pegar a função montarPacoteMagico — e o terminal cuspiu o texto de ajuda da linha de comando na minha cara. 😳

Óbvio, né? Dar require num arquivo executa o arquivo, e o meu chamava main() lá embaixo. A correção é uma linha, e é a razão de ela estar no script:

// Sem este if, dar require() neste arquivo executaria o main() junto e
// imprimiria o texto de uso do lado de quem so queria uma funcao.
if (require.main === module) {
    main().catch(function (erro) {
        console.error('Erro: ' + erro.message);
        process.exit(1);
    });
}

O require.main === module só é verdade quando o arquivo foi chamado direto pelo node. Quando alguém dá require nele, é falso, e o main() fica quieto. É o detalhe que separa um script de um script reaproveitável — e foi o meu próprio teste que me obrigou a arrumar. 😄

Por hoje é só, meus unicórnios! 🦄✨

Que a magia do arco-íris continue brilhando em suas vidas! Até mais! 🌈🌟

Leia também