Node.js: desligar por SSH e ligar por Wake-on-LAN
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
ethtoolmostra e configura. OWake-on: gé o que você quer — ogé 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 nada — dgram, 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
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.
Node.js: gerando certificado SSL e instalando no IIS
Como emitir um certificado gratuito do Let's Encrypt com Node.js e instalar no IIS do Windows, com as armadilhas que travaram tudo.