Pular para o conteúdo
Node.js

Node.js: checando as portas abertas de um IP

Paloma Macetko
Unicórnio de crina luminosa diante de um castelo com janelas acesas e apagadas, enquanto uma coruja com uma varinha aponta um feixe de luz para uma das janelas abertas

Olá meus Unicórnios! 🦄✨

Tudo começou com uma pergunta simples: "o servidor está com a porta 3306 aberta para fora?". Eu podia abrir um nmap, claro. Mas eu queria isso dentro de uma ferramenta minha, rodando no navegador, sem depender de um binário instalado no servidor. 🤔

Achei que seria meia hora de trabalho. Afinal, é só tentar conectar, né? Pois é. 😅 O código que funciona sai em quinze minutos mesmo — o problema é que ele mente para você de três jeitos diferentes, e eu só descobri isso depois de encanar com resultados que não batiam.

Neste artigo eu conto o caminho inteiro: como sondar uma porta com o módulo net, por que "fechada" e "filtrada" são coisas diferentes (e como o relógio prova isso), por que o banner do serviço às vezes não vem, e — o mais importante — o guarda que impede a sua ferramenta de virar arma na mão dos outros. 🔐

Exemplos_ChecarPortasAbertas no GitHub Todo o código deste artigo, rodando: varredura pelo terminal e uma página no navegador. Sem nenhuma dependência. github.com

🔌 Sondando uma porta: o mínimo que funciona

A ideia é velha e simples: se eu consigo completar um handshake TCP com ip:porta, tem alguém escutando ali. Não precisa de privilégio de root, não precisa de pacote cru — é a mesma conexão que qualquer cliente faz.

Em Node isso é o módulo net, que já vem instalado:

import net from 'node:net';

const soquete = new net.Socket();
soquete.setTimeout(2000);
soquete.once('connect', () => console.log('ABERTA'));
soquete.once('error', () => console.log('fechada?'));
soquete.connect(80, 'exemplo.com');

Funciona. E é exatamente aqui que mora a primeira mentira, porque esse 'fechada?' com interrogação está mais certo do que eu imaginava.

🚦 As três respostas de uma porta (não duas)

Eu escrevi o código acima pensando em dois resultados: abre ou não abre. Está errado. Uma porta responde de três jeitos, e o terceiro é o que interessa:

  • Aberta — o handshake completou. Tem serviço ali.
  • Fechada — o host respondeu RST (ECONNREFUSED). Repare: ele respondeu. A máquina existe, está de pé e disse "aqui não".
  • Filtradasilêncio. Um firewall comeu o pacote e não mandou nem a recusa. Você fica esperando até o tempo limite estourar.

Juntar "fechada" e "filtrada" no mesmo balde de "não aberta" joga fora a informação mais interessante da varredura: a diferença entre "não tem serviço nessa porta" e "tem um firewall aqui na frente".

E o melhor: não precisa acreditar em mim, o relógio conta. Rodei os três casos na minha máquina — uma porta que eu mesma abri, a porta 1 (fechada) e um IP de documentação que descarta pacote em silêncio:

aberta   -> { estado: 'aberta',   ms: 3,    codigo: null }
fechada  -> { estado: 'fechada',  ms: 1,    codigo: 'ECONNREFUSED' }
filtrada -> { estado: 'filtrada', ms: 2010, codigo: null }

🤐 O banner: nem todo serviço fala primeiro

Quando a porta abre, dá para ir além e descobrir o que está escutando. Muitos serviços se apresentam sozinhos assim que você conecta — esse texto é o banner.

Foi aqui que eu tomei o segundo susto. Meu primeiro código só marcava a porta como aberta depois de receber o banner. Resultado: o SSH aparecia lindamente e toda porta HTTP sumia da lista. 😳

O motivo é óbvio depois que você vê: o SSH se apresenta ao conectar, mas o HTTP fica calado esperando você pedir alguma coisa. Ele não vai falar primeiro nunca.

A solução é dar uma janela curta para o banner e seguir a vida se ele não vier. Este é o sondarPorta() inteiro, do repositório:

/**
 * Tenta abrir uma conexao TCP. NUNCA rejeita: porta fechada e um resultado
 * normal desta funcao, nao um erro do programa.
 *
 * Devolve { estado, ms, codigo, banner } com estado em:
 *   'aberta'   - o handshake TCP completou
 *   'fechada'  - o host respondeu RST (ECONNREFUSED). Ha alguem ali, so nao
 *                nessa porta.
 *   'filtrada' - ninguem respondeu ate o tempo limite. Firewall descartando
 *                o pacote em silencio. NAO e a mesma coisa que fechada.
 */
export function sondarPorta(ip, porta, tempoLimiteMs = TEMPO_LIMITE_MS, { lerBanner = false } = {}) {
  return new Promise((resolve) => {
    const inicio = Date.now();
    const soquete = new net.Socket();
    let banner = '';
    let timerBanner = null;

    // 'connect', 'data', 'timeout' e 'error' podem disparar quase juntos.
    // Sem esta trava, o mesmo Promise resolveria duas vezes (a segunda e
    // silenciosamente ignorada) e o soquete ficaria aberto vazando descritor.
    let encerrado = false;
    const encerrar = (estado, codigo = null) => {
      if (encerrado) return;
      encerrado = true;
      if (timerBanner) clearTimeout(timerBanner);
      soquete.destroy();
      resolve({ estado, ms: Date.now() - inicio, codigo, banner: banner.trim() || null });
    };

    soquete.setTimeout(tempoLimiteMs);

    soquete.once('connect', () => {
      if (!lerBanner) return encerrar('aberta');
      // Conectou: a porta ja esta aberta, o banner e bonus. Se o servico nao
      // falar primeiro (HTTP nao fala), o timer fecha como aberta do mesmo jeito.
      timerBanner = setTimeout(() => encerrar('aberta'), ESPERA_BANNER_MS);
    });

    soquete.on('data', (pedaco) => {
      if (banner.length < BANNER_MAX_BYTES) {
        banner += pedaco.toString('latin1').slice(0, BANNER_MAX_BYTES - banner.length);
      }
      encerrar('aberta');
    });

    soquete.once('timeout', () => encerrar('filtrada'));
    soquete.once('error', (erro) => {
      encerrar(erro.code === 'ECONNREFUSED' ? 'fechada' : 'erro', erro.code || null);
    });

    soquete.connect(porta, ip);
  });
}

Três detalhes valem o olhar:

  • A função nunca rejeita. Porta fechada é um resultado normal, não uma exceção do programa. Se ela lançasse, uma varredura de 1024 portas seria 1000 try/catch.
  • A trava encerrado não é paranoia. Os eventos connect, data, timeout e error disparam quase juntos; sem ela o mesmo Promise resolveria duas vezes — a segunda é ignorada em silêncio, mas o soquete fica aberto vazando descritor.
  • Aquele timerBanner é o conserto do bug do HTTP: conectou, a porta já está aberta; o banner é bônus.

🛡️ O guarda: sua ferramenta não pode ser arma dos outros

Se você só vai rodar isso no terminal da sua máquina, pode pular. Mas se essa varredura virar um endpoint — um formulário na web, como o do repositório — preste atenção nesta seção. 🙏

Um servidor que aceita "me diga quais portas estão abertas em X" é um servidor que faz conexão para onde o desconhecido mandar. Se eu digitar 127.0.0.1, ele varre a própria máquina de vocês e me conta o que achou: o Redis sem senha, o banco que só escuta local, o painel interno. Isso tem nome — SSRF — e a sua ferramenta gentil virou o scanner interno do atacante.

A defesa é recusar endereço interno. E aqui vem o detalhe cruel:

A lista de faixas internas é maior do que a gente lembra de cabeça:

/** true se o IP for interno: loopback, rede privada, link-local ou multicast. */
export function ehIpPrivado(ip) {
  if (typeof ip !== 'string' || !ip) return true;
  let endereco = ip.trim().toLowerCase();

  // IPv6 que embrulha um IPv4 (::ffff:127.0.0.1) precisa ser avaliado como IPv4,
  // senao o loopback entra disfarcado.
  const embrulhado = endereco.match(/^::ffff:(\d{1,3}(?:\.\d{1,3}){3})$/);
  if (embrulhado) endereco = embrulhado[1];

  const inteiro = ipParaInteiro(endereco);
  if (inteiro !== null) {
    return (
      dentroDaFaixa(inteiro, '0.0.0.0', 8) ||
      dentroDaFaixa(inteiro, '10.0.0.0', 8) ||
      dentroDaFaixa(inteiro, '127.0.0.0', 8) ||     // loopback
      dentroDaFaixa(inteiro, '169.254.0.0', 16) ||  // link-local: inclui 169.254.169.254
      dentroDaFaixa(inteiro, '172.16.0.0', 12) ||
      dentroDaFaixa(inteiro, '192.168.0.0', 16) ||
      dentroDaFaixa(inteiro, '100.64.0.0', 10) ||   // CGNAT
      dentroDaFaixa(inteiro, '224.0.0.0', 4)        // multicast
    );
  }

  if (endereco === '::1' || endereco === '::') return true;
  if (endereco.startsWith('fe80:')) return true;                  // link-local
  if (endereco.startsWith('fc') || endereco.startsWith('fd')) return true;  // fc00::/7
  if (endereco.startsWith('ff')) return true;                     // multicast

  // Nao parseou como IPv6: na duvida, trata como privado e recusa.
  return !/^[0-9a-f:]+$/.test(endereco);
}

Repare no 169.254.0.0/16 com o comentário. Não está ali por capricho: 169.254.169.254 é o endereço de metadados das nuvens. Numa VM da AWS, do Google ou da Azure, é dele que saem as credenciais temporárias da máquina. É o primeiro lugar onde um SSRF bate.

E o ::ffff:127.0.0.1 no começo da função? É o loopback entrando disfarçado de IPv6. Sem desembrulhar, ele escapa da checagem de IPv4 inteirinho.

Com as faixas na mão, o guarda fica assim:

export async function alvoPublico(host) {
  const h = typeof host === 'string' ? host.trim().toLowerCase() : '';
  if (h === 'localhost') throw erro400('Alvo nao permitido (endereco interno).');
  if (!ehHostValido(h)) throw erro400('Informe um host ou IP valido.');

  let enderecos;
  try {
    const resolvido = await dns.promises.lookup(h, { all: true });
    enderecos = resolvido.map((r) => r.address);
  } catch {
    throw erro400('Nao foi possivel resolver o host.');
  }

  // `some` e nao `every`: se QUALQUER um dos IPs for interno, recusa. Um host
  // com varios registros A poderia colar um interno no meio.
  if (!enderecos.length || enderecos.some(ehIpPrivado)) {
    throw erro400('Alvo nao permitido (endereco interno).');
  }
  return enderecos;
}

Duas escolhas de propósito: o some() — e não every() — porque se qualquer um dos IPs for interno, recusa (um host pode ter vários registros A, com um interno no meio); e o retorno da lista de IPs, para a varredura usar o mesmo endereço que acabou de ser aprovado. Resolver o DNS de novo lá na frente abriria espaço para o alvo trocar de IP entre a checagem e o uso.

Rodando os quatro casos que interessam:

node cli.js localhost 80          # Alvo nao permitido (endereco interno).
node cli.js 127.0.0.1 80          # Alvo nao permitido (endereco interno).
node cli.js 192.168.1.1 80        # Alvo nao permitido (endereco interno).
node cli.js 169.254.169.254 80    # Alvo nao permitido (endereco interno).

⚡ Varrendo 1024 portas sem derrubar a própria máquina

Uma porta é fácil. Mil é outra conversa. Minha primeira versão foi a que todo mundo escreve:

// NAO faca isso
const resultados = await Promise.all(
  portas.map((p) => sondarPorta(ip, p)),
);

Isso abre 1024 soquetes ao mesmo tempo. O sistema operacional recusa (EMFILE, "too many open files") ou o roteador doméstico engasga — e aí vem o resultado que me deixou maluca: porta aberta aparecendo como filtrada. Não era firewall nenhum, era a minha própria máquina não dando conta de responder a tempo. Eu estava medindo o meu gargalo, não o servidor. 🤦‍♀️

O conserto é um punhado de trabalhadores puxando de uma fila comum:

export async function varrerPortas(host, portas, opcoes = {}) {
  const {
    concorrencia = 20,
    tempoLimiteMs = 1500,
    aoAbrir,
    devoParar,
  } = opcoes;

  const [ip] = await alvoPublico(host);
  const lista = [...new Set(portas.filter(ehPortaValida))].slice(0, MAX_PORTAS);

  const abertas = [];
  let indice = 0;
  let varridas = 0;

  async function trabalhador() {
    while (indice < lista.length) {
      if (devoParar && devoParar()) return;
      const porta = lista[indice++];
      const r = await sondarPorta(ip, porta, tempoLimiteMs, { lerBanner: true });
      varridas++;
      if (r.estado === 'aberta') {
        abertas.push({ porta, ms: r.ms, banner: r.banner, servico: SERVICOS[porta] || null });
        if (aoAbrir) aoAbrir({ porta, ip, ms: r.ms, banner: r.banner, servico: SERVICOS[porta] || null });
      }
    }
  }

  await Promise.all(
    Array.from({ length: Math.min(concorrencia, lista.length) }, trabalhador),
  );

  abertas.sort((a, b) => a.porta - b.porta);
  return { ip, abertas, varridas };
}

O indice++ compartilhado entre os trabalhadores parece coisa de quem esqueceu o mutex, mas está certo: JavaScript é single-thread, e o incremento acontece inteiro antes de qualquer await devolver o controle. Dois trabalhadores nunca pegam a mesma porta.

O devoParar existe para a versão web: se o usuário fecha a aba no meio de uma varredura de 1024 portas, os trabalhadores param na próxima volta em vez de gastar soquete para ninguém ver.

📡 Mostrando o resultado ao vivo com NDJSON

Uma varredura das portas comuns leva alguns segundos; de 1024 portas, bem mais. Devolver um JSON gigante no fim é condenar quem usa a olhar para uma tela parada — e eu acho isso péssimo. 😬

A saída é o NDJSON: uma linha JSON por evento, escrita na resposta conforme as coisas acontecem. Sem WebSocket, sem SSE, sem biblioteca — é o mesmo res.write() de sempre:

async function rotaVarrer(req, res) {
  const { host, portas: portasTexto } = await lerCorpo(req);
  const portas = interpretarPortas(portasTexto);

  if (!host) {
    res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
    return res.end(JSON.stringify({ error: 'Informe um host.' }));
  }

  // NDJSON: uma linha JSON por evento. A varredura pode levar dezenas de
  // segundos - o navegador mostra cada porta aberta na hora, em vez de olhar
  // para uma tela parada esperando um JSON gigante no fim.
  res.writeHead(200, {
    'Content-Type': 'application/x-ndjson; charset=utf-8',
    'Cache-Control': 'no-cache, no-transform',
    // Sem isto, um proxy reverso (nginx) segura o corpo em buffer e o
    // streaming vira um bloco unico no final - o efeito ao vivo some.
    'X-Accel-Buffering': 'no',
  });

  // Se o usuario fecha a aba, `shouldStop` para os trabalhadores. Sem isso a
  // varredura continua ate o fim, gastando soquete para ninguem ver.
  let abortado = false;
  req.on('close', () => { abortado = true; });

  const linha = (obj) => {
    if (abortado || res.writableEnded) return;
    res.write(JSON.stringify(obj) + '\n');
  };

  try {
    const total = Math.min(portas.length, MAX_PORTAS);
    linha({ tipo: 'inicio', total });

    const { ip, abertas, varridas } = await varrerPortas(host, portas, {
      concorrencia: 20,
      tempoLimiteMs: 1500,
      devoParar: () => abortado,
      aoAbrir: (p) => linha({ tipo: 'aberta', ...p }),
    });

    if (abortado) return;
    linha({ tipo: 'fim', ip, abertas, varridas });
    res.end();
  } catch (erro) {
    // O guarda so roda depois de o cabecalho 200 ter saido, entao o erro nao
    // pode virar status HTTP: vai como uma linha do proprio stream.
    linha({ tipo: 'erro', erro: erro.message });
    if (!res.writableEnded) res.end();
  }
}

Dois detalhes que só aparecem quando isso vai para o ar:

  • X-Accel-Buffering: no — sem esse cabeçalho, um nginx na frente segura o corpo em buffer e entrega tudo de uma vez no final. Seu streaming funciona lindamente em localhost e morre em produção.
  • O erro vira linha, não status HTTP. O guarda roda depois de o cabeçalho 200 já ter saído — não dá mais para mandar 400. Por isso o catch escreve {'{'} tipo: 'erro' {'}'} dentro do próprio stream.

🖥️ O resultado, rodando

O repositório traz uma página que consome esse stream e mostra cada porta no instante em que ela é encontrada. Rodando contra o scanme.nmap.org — o host que o próprio projeto Nmap mantém para testes:

Varrendo scanme.nmap.org - 11 portas...

  ABERTA     22 (SSH)  406 ms
          banner: SSH-2.0-OpenSSH_6.6.1p1 Ubuntu-2ubuntu2.13
  ABERTA     80 (HTTP)  907 ms
  ABERTA  31337  907 ms

IP resolvido: 2600:3c01::f03c:91ff:fe18:bb2f
Varridas: 11 portas em 1.7s
Abertas: 22, 80, 31337

Essa saída é real, e ela prova sozinha duas coisas que o artigo contou. 🎯

Primeira: a porta 22 veio com banner (SSH-2.0-OpenSSH_6.6.1p1) e a 80 veio sem — o HTTP calado, exatamente como previsto. Segunda: repare que a 80 e a 31337 marcaram 907 ms enquanto a 22 marcou 406 ms. Não é rede lenta: são os 700 ms da janela de banner que elas esperaram até a função desistir e declarar a porta aberta assim mesmo.

E tem um detalhe que eu não tinha planejado: o IP resolvido é IPv6. O dns.lookup devolveu o registro AAAA primeiro e a varredura inteira foi por IPv6, sem eu pedir. Bom lembrete de que "o IP do servidor" pode não ser o que você tem na cabeça — por isso a função devolve o endereço que realmente usou.

No navegador, a mesma coisa com o banner em destaque:

Página escura mostrando a porta 22 aberta com o banner do OpenSSH em amarelo, o IP IPv6 resolvido e o total de 11 portas varridas

🧰 O script do terminal, inteiro

Para quem só quer rodar e ver, o cli.js completo — 50 linhas que amarram tudo:

// Varredura pela linha de comando.
//
//   node cli.js scanme.nmap.org
//   node cli.js scanme.nmap.org 20-100
//   node cli.js exemplo.com 22,80,443

import { varrerPortas, interpretarPortas, MAX_PORTAS } from './scanner.js';

const [host, portasTexto] = process.argv.slice(2);

if (!host) {
  console.log('Uso: node cli.js <host> [portas]');
  console.log('  portas: "comuns" (padrao), "1-1024" ou "22,80,443"');
  process.exit(1);
}

const portas = interpretarPortas(portasTexto);
if (!portas.length) {
  console.error('Nenhuma porta valida na lista informada.');
  process.exit(1);
}

const total = Math.min(portas.length, MAX_PORTAS);
console.log(`Varrendo ${host} - ${total} portas...\n`);

const inicio = Date.now();

try {
  const { ip, abertas, varridas } = await varrerPortas(host, portas, {
    concorrencia: 20,
    tempoLimiteMs: 1500,
    aoAbrir: ({ porta, ms, servico, banner }) => {
      const nome = servico ? ` (${servico})` : '';
      console.log(`  ABERTA  ${String(porta).padStart(5)}${nome}  ${ms} ms`);
      if (banner) console.log(`          banner: ${banner.split('\n')[0].slice(0, 70)}`);
    },
  });

  const segundos = ((Date.now() - inicio) / 1000).toFixed(1);
  console.log(`\nIP resolvido: ${ip}`);
  console.log(`Varridas: ${varridas} portas em ${segundos}s`);
  console.log(
    abertas.length
      ? `Abertas: ${abertas.map((a) => a.porta).join(', ')}`
      : 'Abertas: nenhuma',
  );
} catch (erro) {
  console.error(`Erro: ${erro.message}`);
  process.exit(1);
}

A lista de portas aceita três formatos, porque digitar 22,80,443 é mais rápido que lembrar da lista de comuns:

export function interpretarPortas(texto) {
  const t = String(texto || '').trim().toLowerCase();
  if (!t || t === 'comuns') return PORTAS_COMUNS;

  const portas = [];
  for (const pedaco of t.split(',')) {
    const faixa = pedaco.trim().match(/^(\d+)\s*-\s*(\d+)$/);
    if (faixa) {
      const de = Number(faixa[1]);
      const ate = Number(faixa[2]);
      // Faixa invertida ("443-80") daria lista vazia em silencio: normaliza.
      for (let p = Math.min(de, ate); p <= Math.max(de, ate); p++) portas.push(p);
    } else if (/^\d+$/.test(pedaco.trim())) {
      portas.push(Number(pedaco.trim()));
    }
  }
  return portas.filter(ehPortaValida);
}

Aquele Math.min/Math.max na faixa é um mimo para quem digita 443-80 de trás para frente: sem ele o for não roda nenhuma vez e você recebe zero portas em silêncio, achando que o alvo está todo fechado.

🎁 O código completo

Está tudo no repositório, pronto para clonar e rodar. Não tem npm install — não tem uma dependência sequer, só os módulos que já vêm no Node. 🙌

Exemplos_ChecarPortasAbertas no GitHub scanner.js, o CLI, o servidor NDJSON e a página do navegador. O README ensina a desligar o guarda de propósito para ver o estrago que ele evita. github.com

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

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

Leia também