diff --git a/Directory.Build.props b/Directory.Build.props
index 08bf641..75ca962 100644
--- a/Directory.Build.props
+++ b/Directory.Build.props
@@ -4,7 +4,7 @@
enableenabletrue
- 0.3.0
+ 0.4.0Manfred Heil JuniorMT - Manfred TecnologiaMapNet - MT
diff --git a/README.md b/README.md
index c300232..0b23155 100644
--- a/README.md
+++ b/README.md
@@ -6,7 +6,7 @@ Página do programa: **https://mapnet.manfred.com.br**. Download da versão mais
Software livre, sob licença [GPL-3.0](LICENSE).
-Não é scanner de vulnerabilidade: o programa não testa senha, não contorna autenticação e não explora vulnerabilidade. Para achar os equipamentos, ele manda ping, pedidos ARP e consultas de nome (DNS, NetBIOS e mDNS) aos endereços da sub-rede escolhida. Com a opção **Verificar portas** ligada, ele também abre e fecha uma conexão TCP em cada porta da lista, nos equipamentos que encontrou, sem enviar dados.
+Não é scanner de vulnerabilidade: o programa não testa senha, não contorna autenticação e não explora vulnerabilidade. Para achar os equipamentos, ele manda ping, pedidos ARP e consultas de nome (DNS, NetBIOS e mDNS) aos endereços da sub-rede escolhida. Com a opção **Verificar portas** ligada, ele também abre e fecha uma conexão TCP em cada porta da lista, nos equipamentos que encontrou, sem enviar dados. Com **Identificar serviços**, que vem marcado junto com as portas, ele pede a página inicial dos serviços web que estiverem abertos (só um GET, sem senha), lê o certificado HTTPS e a primeira linha de SSH, FTP e SMTP, e faz uma busca UPnP na rede local.
**Uso autorizado.** O MapNet - MT serve para inventariar equipamentos em redes nas quais você tem autorização para fazer a verificação. Confira o intervalo de endereços antes de iniciar e respeite as regras da rede e dos dispositivos examinados.
@@ -25,7 +25,7 @@ As regras do projeto estão no [`AGENTS.md`](AGENTS.md) e o desenho em [`docs/su
| 5 | Ações de manutenção com elevação sob demanda, na aba Manutenção do console (sai como v0.2.4) | `manutencao` | [#18](https://github.com/manfredjr/mapnet/pull/18) | Publicada na v0.2.5 |
| 6 | Painel de detalhe do host, com ping, tracert, navegador, área de trabalho remota e pasta compartilhada (sai como v0.2.5) | `detalhe-host` | [#19](https://github.com/manfredjr/mapnet/pull/19) | Publicada na v0.2.5 |
| 7 | Portas TCP comuns abertas, com lista configurável, desligadas por padrão e com confirmação por rede (sai como v0.3.0) | `portas` | Plano em [#26](https://github.com/manfredjr/mapnet/pull/26), código em [#31](https://github.com/manfredjr/mapnet/pull/31) | Publicada na v0.3.0 |
-| 8 | Identificação leve dos serviços nas portas abertas: título da página, certificado HTTPS, banner SSH, FTP e SMTP, e UPnP (sai como v0.4.0) | `identificacao` | Plano em [#32](https://github.com/manfredjr/mapnet/pull/32) | Plano em revisão |
+| 8 | Identificação leve dos serviços nas portas abertas: título da página, certificado HTTPS, banner SSH, FTP e SMTP, e UPnP (sai como v0.4.0) | `identificacao-servicos` | Plano em [#32](https://github.com/manfredjr/mapnet/pull/32), código em [#34](https://github.com/manfredjr/mapnet/pull/34) | Em revisão |
| 9 e 10 | Classificação por tipo de equipamento e relatório também em XML e CSV (até a v1.0.0) | a definir | - | A fazer |
A versão 0.2 segue o desenho em [`docs/superpowers/specs/2026-09-25-mapnet-0.2-painel-do-tecnico-design.md`](docs/superpowers/specs/2026-09-25-mapnet-0.2-painel-do-tecnico-design.md).
@@ -41,7 +41,7 @@ O programa é um arquivo só, `mapnet.exe`. Não precisa instalar nada no comput
1. Abra o `mapnet.exe`.
2. Na faixa verde do topo, escolha a interface de rede. A primeira da lista é a que tem gateway, quase sempre a certa. A coluna **Minha máquina** mostra o computador (nome, grupo de trabalho ou domínio, usuário), a placa (tipo, MAC, velocidade, MTU), os endereços (IPv4, IPv6, gateway, DNS, sufixo e a sub-rede que vai ser varrida) e o DHCP com a validade da concessão. No Wi-Fi, mostra também a rede, a banda, o canal e o sinal.
3. O botão **Consultar IP público**, na faixa do topo, pergunta o IP ao serviço `1.1.1.1`, do Cloudflare, e só roda com o clique. Se o relatório for gravado depois da consulta, o IP público vai junto.
-4. Para saber também as portas abertas de cada equipamento, marque **Verificar portas** acima da tabela. A **Lista** vem com `padrão`, as 24 portas comuns (a dica do campo mostra quais são), ou aceita até 100 portas separadas por vírgula, como `80,443,9100`. Os aparelhos com MAC aleatório, quase sempre celulares e notebooks pessoais, ficam de fora, a não ser que você marque **Incluir aparelhos com MAC aleatório**. Na primeira varredura com portas em cada rede, o programa diz o que vai enviar e pede confirmação. Use só em rede que você tem autorização para verificar.
+4. Para saber também as portas abertas de cada equipamento, marque **Verificar portas** acima da tabela. A **Lista** vem com `padrão`, as 24 portas comuns (a dica do campo mostra quais são), ou aceita até 100 portas separadas por vírgula, como `80,443,9100`. Os aparelhos com MAC aleatório, quase sempre celulares e notebooks pessoais, ficam de fora, a não ser que você marque **Incluir aparelhos com MAC aleatório**. **Identificar serviços** vem marcado e preenche a coluna **Serviço** com o que o equipamento diz de si: modelo pelo UPnP, título da página, certificado ou banner. Na primeira varredura com portas em cada rede, o programa diz o que vai enviar e pede confirmação. Use só em rede que você tem autorização para verificar.
5. Clique em **Iniciar varredura**. Clicar numa linha da tabela abre, ao lado, o detalhe do host: MAC, fabricante, os nomes pelo DNS reverso, NetBIOS e mDNS, o grupo de trabalho, o ping com o TTL, uma pista do sistema pelo TTL e as portas abertas, com o serviço de cada uma. Os botões do detalhe fazem ping contínuo e tracert no console, abrem http e https no navegador (na 8080, 8000 ou 8443 quando só ela está aberta, e desligados quando as portas web foram verificadas e estão fechadas), a área de trabalho remota e a pasta compartilhada, e copiam os dados. Um novo clique na mesma linha, a tecla Esc ou o botão **Fechar** fecham o detalhe. A tabela vai se enchendo enquanto os hosts respondem, e o mesmo botão vira **Cancelar**. O campo **Filtrar** procura por IP, nome, MAC, fabricante, observação ou número de porta aberta (`9100` lista as impressoras), e cada coluna ordena com um clique no título.
6. O console embaixo registra o andamento e os avisos, e a barra de estado mostra o resumo. O console tem também uma aba para cada ferramenta: Ping (normal ou contínuo), Tracert, DNS com escolha do servidor, ARP, Conexões e Rotas. Os campos de host e de servidor DNS já vêm com o gateway e o DNS da interface. Enquanto a ferramenta roda, **Executar** vira **Parar**. **Copiar** e **Limpar** valem para a aba aberta.
7. No fim, clique em **Salvar relatório** e escolha onde gravar o arquivo HTML. A janela abre em `Documentos\MapNet - MT` com um nome pronto, e da segunda vez abre na pasta usada por último. O programa não grava sozinho: fechar a janela com a varredura sem salvar pergunta antes. O botão **Abrir** abre o relatório salvo no navegador ou a pasta dele.
@@ -69,6 +69,7 @@ mapnet --ajuda
| `--sem-arp` | Descobre hosts só pelo ping |
| `--portas [lista]` | Verifica as portas TCP dos hosts encontrados, só abrindo e fechando a conexão. Sem lista, usa as 24 portas comuns; com lista, até 100 portas, como `22,80,443`. Desligado sem esta opção |
| `--incluir-mac-aleatorio` | Com `--portas`, inclui os aparelhos com MAC aleatório, que ficam de fora por padrão |
+| `--sem-identificar` | Com `--portas`, não identifica os serviços das portas abertas |
O `.exe` é de janela, então o Prompt de Comando devolve o cursor antes de o programa terminar. Para esperar o fim, use `start /wait mapnet --varrer` no Prompt de Comando ou termine a linha com `| Out-Host` no PowerShell. Redirecionar para arquivo (`> saida.txt`) funciona direto, em UTF-8.
@@ -110,7 +111,8 @@ Se o valor bater com o do `.txt`, o executável é exatamente o que foi publicad
- **Nome:** DNS reverso, NetBIOS (o mesmo do `nbtstat -A`, que traz também o grupo de trabalho ou domínio) e mDNS (nomes `.local` de celulares, Macs, impressoras e aparelhos Linux).
- **Relatório HTML:** arquivo único, com resumo da varredura, cartões de contagem, tabela filtrável e ordenável, detalhe de cada host ao clicar na linha e a lista de fabricantes.
-- **Portas (desde a v0.3.0, desligadas por padrão):** para cada host encontrado, abre e fecha a conexão TCP em cada porta da lista, sem enviar nenhum dado, com até 128 conexões ao mesmo tempo, 8 por host e 800 ms de espera por porta. Porta que aceita a conexão aparece como aberta. Este computador e os aparelhos com MAC aleatório ficam de fora, e o motivo aparece no detalhe. O relatório ganha a coluna Portas e diz quais portas foram verificadas. A identificação do serviço (título da página, banner, certificado) fica para a v0.4.0.
+- **Portas (desde a v0.3.0, desligadas por padrão):** para cada host encontrado, abre e fecha a conexão TCP em cada porta da lista, sem enviar nenhum dado, com até 128 conexões ao mesmo tempo, 8 por host e 800 ms de espera por porta. Porta que aceita a conexão aparece como aberta. Este computador e os aparelhos com MAC aleatório ficam de fora, e o motivo aparece no detalhe. O relatório ganha a coluna Portas e diz quais portas foram verificadas.
+- **Serviços (desde a v0.4.0, com as portas):** só nas portas abertas. Nas web (80, 8000, 8080, 443, 8443), um `GET /` para ler o título e o servidor, sem seguir redirecionamento, e o certificado HTTPS, aceitando autoassinado e vencido. Em SSH, FTP e SMTP, só lê a primeira linha que o servidor manda. O UPnP é um `M-SEARCH` na rede local, e a descrição do equipamento é lida dele mesmo. Telnet fica de fora. No máximo 16 ao mesmo tempo, 3 s por serviço e 64 KB lidos por resposta.
Sub-rede maior que /22 (1022 endereços) é varrida só no bloco /22 em volta do IP do computador, com aviso no relatório. A faixa manual fica para uma próxima versão.
diff --git a/docs/superpowers/pendencias.md b/docs/superpowers/pendencias.md
index 98d4732..d40f739 100644
--- a/docs/superpowers/pendencias.md
+++ b/docs/superpowers/pendencias.md
@@ -65,6 +65,14 @@ Fechado em parte na fatia 3 (0.2.2). No tamanho padrão da janela, IP, Origem, M
| Portas numa rede de verdade | A etapa foi testada com sonda simulada e no modo de demonstração. Não rodou numa rede de verdade no teste do agente | O Manfred varrer a rede da MT com as portas ligadas e conferir, por exemplo, a 80 ou 443 no roteador, a 9100 numa impressora e a 445 num Windows com compartilhamento |
| Confirmação vale para a sessão | A pergunta aparece uma vez por sub-rede enquanto o programa está aberto. Fechar e abrir de novo pergunta outra vez | Guardar a confirmação, se o Manfred achar a pergunta repetitiva no uso |
+## Fatia 8: identificação de serviços
+
+| Item | Motivo | O que fecha |
+|---|---|---|
+| Linha de comando com a identificação ligada por padrão | O plano previa `--identificar` para ligar. Com a decisão de 26/09/2026 de deixar **Identificar serviços** marcado na janela, a linha de comando segue o mesmo padrão: `--portas` já identifica e `--sem-identificar` desliga | O Manfred confirmar ou pedir `--identificar` |
+| HTTP pedido pelo IP | O `GET /` vai com o IP no cabeçalho `Host`. Servidor que hospeda vários sites por nome pode mostrar uma página genérica | Usar o nome do DNS reverso no `Host`, se fizer falta no campo |
+| Janela da identificação vista só no modo de demonstração | A identificação de verdade rodou na rede do Manfred pela linha de comando, com roteador, câmera, TV e Windows respondendo. A janela foi conferida com dados de exemplo | O Manfred varrer pela janela com as portas ligadas |
+
## Página de privacidade
| Item | Motivo | O que fecha |
diff --git a/public/index.html b/public/index.html
index 905581a..f4ad030 100644
--- a/public/index.html
+++ b/public/index.html
@@ -48,7 +48,7 @@
O que ele faz
Todos os equipamentos da sub-rede
Por ping e por ARP. Quem bloqueia ping também aparece.
Fabricante e nome
Pelo MAC, na tabela do IEEE, e por DNS, NetBIOS e mDNS.
De onde vem a rede
DHCP, gateway, DNS, Wi-Fi e, com um clique, o IP público.
-
Detalhe de cada equipamento
Nomes, TTL, portas abertas e atalhos para ping, navegador e área de trabalho remota.
+
Detalhe de cada equipamento
Nomes, TTL, portas abertas, serviços e atalhos para ping, navegador e área de trabalho remota.
Ferramentas de rede
Ping, tracert, DNS, ARP, conexões e rotas, com a saída em português.
Manutenção
Limpar o cache DNS, renovar o IP, limpar o ARP e resetar o Winsock.
Relatório em HTML
Arquivo único para guardar com a documentação do cliente.
@@ -74,7 +74,7 @@
Como usar
Uso autorizado
O MapNet serve para inventariar equipamentos em redes nas quais você tem autorização para fazer a verificação.
-
O que ele envia: ping, pedidos ARP e consultas de nome (DNS, NetBIOS e mDNS) aos endereços da sub-rede escolhida, as mesmas mensagens que os computadores de uma rede trocam entre si. Se você ligar a verificação de portas, ele também abre e fecha uma conexão TCP em cada porta da lista, só nos equipamentos encontrados e sem enviar dados.
+
O que ele envia: ping, pedidos ARP e consultas de nome (DNS, NetBIOS e mDNS) aos endereços da sub-rede escolhida, as mesmas mensagens que os computadores de uma rede trocam entre si. Se você ligar a verificação de portas, ele também abre e fecha uma conexão TCP em cada porta da lista, só nos equipamentos encontrados e sem enviar dados. Com a identificação de serviços, pede a página inicial dos serviços web abertos (só um GET, sem senha), lê o certificado HTTPS e a primeira linha de SSH, FTP e SMTP, e faz uma busca UPnP na rede local.
O que ele não faz: não testa senhas, não contorna autenticação, não explora vulnerabilidades e não muda configuração de nada na rede. A manutenção mexe só no computador de quem usa, e só com o clique.
Sua parte: confira o intervalo de endereços antes de iniciar e respeite as regras da rede e dos dispositivos examinados.
diff --git a/src/mapnet.nucleo/identificacao/etapa-identificacao.cs b/src/mapnet.nucleo/identificacao/etapa-identificacao.cs
new file mode 100644
index 0000000..b01292c
--- /dev/null
+++ b/src/mapnet.nucleo/identificacao/etapa-identificacao.cs
@@ -0,0 +1,114 @@
+using System.Net;
+
+namespace MapNet.Nucleo;
+
+///
+/// Etapa de identificação, depois das portas. Só conversa com as portas que a etapa de portas
+/// achou abertas, e só nos hosts que ela verificou: nunca sonda porta nem host novo. O UPnP é uma
+/// busca só na rede local, e das respostas ficam só as dos hosts verificados.
+///
+public static class EtapaIdentificacao
+{
+ public const string Nome = "Identificação de serviços";
+
+ /// Protocolo usado em cada porta. Porta fora desta lista não é identificada (Telnet fica de fora de propósito).
+ public static ProtocoloServico? Protocolo(int porta) => porta switch
+ {
+ 80 or 8000 or 8080 => ProtocoloServico.Http,
+ 443 or 8443 => ProtocoloServico.Https,
+ 21 or 22 or 25 => ProtocoloServico.Banner,
+ _ => null,
+ };
+
+ public static string NomeBanner(int porta) => porta switch
+ {
+ 21 => "FTP",
+ 22 => "SSH",
+ 25 => "SMTP",
+ _ => "TCP",
+ };
+
+ public static async Task IdentificarAsync(
+ IReadOnlyList hosts,
+ IPAddress origem,
+ OpcoesVarredura opcoes,
+ IIdentificador identificador,
+ IProgress? progresso,
+ CancellationToken cancelamento)
+ {
+ var verificados = hosts.Where(h => h.PortasVerificadas).ToList();
+ var tarefas = verificados
+ .SelectMany(h => h.PortasAbertas.Where(p => Protocolo(p) != null).Select(p => (Host: h, Porta: p)))
+ .ToList();
+ var total = tarefas.Count + 1;
+ var concluidos = 0;
+ progresso?.Report(new ProgressoVarredura(Nome, 0, total));
+
+ using var vagas = new SemaphoreSlim(Math.Max(1, opcoes.IdentificacoesSimultaneas));
+ var servicos = tarefas.Select(async t =>
+ {
+ await vagas.WaitAsync(cancelamento).ConfigureAwait(false);
+ try
+ {
+ var s = await identificador.IdentificarAsync(t.Host.Ip, t.Porta, Protocolo(t.Porta)!.Value, opcoes.TempoIdentificacaoMs, cancelamento)
+ .ConfigureAwait(false);
+ if (s != null)
+ {
+ lock (t.Host)
+ {
+ t.Host.Servicos = [.. t.Host.Servicos, s];
+ }
+ }
+ }
+ finally
+ {
+ vagas.Release();
+ }
+
+ progresso?.Report(new ProgressoVarredura(Nome, Interlocked.Increment(ref concluidos), total, t.Host));
+ });
+
+ var upnp = UpnpAsync(verificados, origem, opcoes, identificador, cancelamento);
+ await Task.WhenAll(servicos.Append(upnp)).ConfigureAwait(false);
+ progresso?.Report(new ProgressoVarredura(Nome, Interlocked.Increment(ref concluidos), total));
+
+ foreach (var h in verificados)
+ {
+ h.Servicos = [.. h.Servicos.OrderBy(s => s.Porta)];
+ h.ServicosIdentificados = true;
+ }
+ }
+
+ private static async Task UpnpAsync(
+ IReadOnlyList verificados,
+ IPAddress origem,
+ OpcoesVarredura opcoes,
+ IIdentificador identificador,
+ CancellationToken cancelamento)
+ {
+ var porIp = verificados.ToDictionary(h => h.IpNumero);
+ var respostas = await identificador.BuscarUpnpAsync(origem, opcoes.TempoIdentificacaoMs, cancelamento).ConfigureAwait(false);
+
+ // Um equipamento responde várias vezes, uma por serviço: fica a primeira de cada IP.
+ var unicas = respostas
+ .Where(r => r.Origem.AddressFamily == System.Net.Sockets.AddressFamily.InterNetwork && porIp.ContainsKey(SubRede.ParaNumero(r.Origem)))
+ .GroupBy(r => SubRede.ParaNumero(r.Origem))
+ .Select(g => g.First());
+
+ await Task.WhenAll(unicas.Select(async r =>
+ {
+ var host = porIp[SubRede.ParaNumero(r.Origem)];
+ string? modelo = null;
+ if (Ssdp.DescricaoDoProprio(r) is { } endereco)
+ {
+ modelo = Ssdp.Modelo(await identificador.LerDescricaoAsync(endereco, opcoes.TempoIdentificacaoMs, cancelamento).ConfigureAwait(false));
+ }
+
+ var s = new ServicoIdentificado { Porta = 0, Protocolo = "UPnP", Modelo = modelo, Servidor = r.Servidor };
+ lock (host)
+ {
+ host.Servicos = [.. host.Servicos, s];
+ }
+ })).ConfigureAwait(false);
+ }
+}
diff --git a/src/mapnet.nucleo/identificacao/identificador.cs b/src/mapnet.nucleo/identificacao/identificador.cs
new file mode 100644
index 0000000..7b324ec
--- /dev/null
+++ b/src/mapnet.nucleo/identificacao/identificador.cs
@@ -0,0 +1,218 @@
+using System.Net;
+using System.Net.Security;
+using System.Net.Sockets;
+using System.Security.Authentication;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+
+namespace MapNet.Nucleo;
+
+/// Conversa com os serviços. Os testes trocam por uma versão simulada.
+public interface IIdentificador
+{
+ /// Identifica o serviço de uma porta aberta. Null quando ele não respondeu no tempo.
+ Task IdentificarAsync(IPAddress ip, int porta, ProtocoloServico protocolo, int tempoMs, CancellationToken cancelamento);
+
+ /// Manda um M-SEARCH pela interface de origem e junta as respostas que chegarem no tempo.
+ Task> BuscarUpnpAsync(IPAddress origem, int tempoMs, CancellationToken cancelamento);
+
+ /// Lê a descrição UPnP do próprio equipamento, por http.
+ Task LerDescricaoAsync(Uri endereco, int tempoMs, CancellationToken cancelamento);
+}
+
+///
+/// Identificação pela rede, sem administrador. HTTP e HTTPS mandam só um GET na raiz; SSH, FTP e
+/// SMTP não recebem nada, o programa só lê o que o servidor diz ao conectar.
+///
+public sealed class IdentificadorRede : IIdentificador
+{
+ public async Task IdentificarAsync(IPAddress ip, int porta, ProtocoloServico protocolo, int tempoMs, CancellationToken cancelamento)
+ {
+ using var limite = CancellationTokenSource.CreateLinkedTokenSource(cancelamento);
+ limite.CancelAfter(tempoMs);
+ try
+ {
+ using var cliente = new TcpClient(ip.AddressFamily) { NoDelay = true };
+ await cliente.ConnectAsync(ip, porta, limite.Token).ConfigureAwait(false);
+ var rede = cliente.GetStream();
+ return protocolo switch
+ {
+ ProtocoloServico.Http => await HttpAsync(rede, ip, porta, "HTTP", null, limite.Token, cancelamento).ConfigureAwait(false),
+ ProtocoloServico.Https => await HttpsAsync(rede, ip, porta, limite.Token, cancelamento).ConfigureAwait(false),
+ _ => await BannerAsync(rede, porta, limite.Token).ConfigureAwait(false),
+ };
+ }
+ catch (OperationCanceledException) when (!cancelamento.IsCancellationRequested)
+ {
+ return null;
+ }
+ catch (Exception e) when (e is SocketException or IOException or AuthenticationException)
+ {
+ return null;
+ }
+ }
+
+ public async Task> BuscarUpnpAsync(IPAddress origem, int tempoMs, CancellationToken cancelamento)
+ {
+ var respostas = new List();
+ using var limite = CancellationTokenSource.CreateLinkedTokenSource(cancelamento);
+ limite.CancelAfter(tempoMs);
+ try
+ {
+ using var cliente = new UdpClient(new IPEndPoint(origem, 0));
+ await cliente.SendAsync(Ssdp.Pedido(), Ssdp.Grupo, limite.Token).ConfigureAwait(false);
+ while (true)
+ {
+ var r = await cliente.ReceiveAsync(limite.Token).ConfigureAwait(false);
+ if (Ssdp.Interpretar(r.RemoteEndPoint.Address, r.Buffer) is { } resposta)
+ {
+ respostas.Add(resposta);
+ }
+ }
+ }
+ catch (OperationCanceledException) when (!cancelamento.IsCancellationRequested)
+ {
+ return respostas;
+ }
+ catch (SocketException)
+ {
+ return respostas;
+ }
+ }
+
+ public async Task LerDescricaoAsync(Uri endereco, int tempoMs, CancellationToken cancelamento)
+ {
+ using var limite = CancellationTokenSource.CreateLinkedTokenSource(cancelamento);
+ limite.CancelAfter(tempoMs);
+ try
+ {
+ using var cliente = new TcpClient(AddressFamily.InterNetwork) { NoDelay = true };
+ await cliente.ConnectAsync(endereco.Host, endereco.Port, limite.Token).ConfigureAwait(false);
+ var rede = cliente.GetStream();
+ await rede.WriteAsync(RespostaHttp.Pedido(endereco.Authority, endereco.PathAndQuery), limite.Token).ConfigureAwait(false);
+ var dados = RespostaHttp.Interpretar(await LerAsync(rede, limite.Token, cancelamento, ateTitulo: false).ConfigureAwait(false));
+ return dados.Codigo == 200 ? dados.Corpo : null;
+ }
+ catch (OperationCanceledException) when (!cancelamento.IsCancellationRequested)
+ {
+ return null;
+ }
+ catch (Exception e) when (e is SocketException or IOException)
+ {
+ return null;
+ }
+ }
+
+ private static async Task HttpAsync(Stream rede, IPAddress ip, int porta, string protocolo, ServicoIdentificado? base_, CancellationToken cancelamento, CancellationToken usuario)
+ {
+ await rede.WriteAsync(RespostaHttp.Pedido(ip.ToString(), "/"), cancelamento).ConfigureAwait(false);
+ var dados = RespostaHttp.Interpretar(await LerAsync(rede, cancelamento, usuario, ateTitulo: true).ConfigureAwait(false));
+ var s = base_ ?? new ServicoIdentificado { Porta = porta, Protocolo = protocolo };
+ s.CodigoHttp = dados.Codigo;
+ s.Titulo = dados.Titulo;
+ s.Servidor = dados.Servidor;
+ s.Redireciona = dados.Codigo is >= 300 and < 400 ? dados.Localizacao : null;
+ return s;
+ }
+
+ ///
+ /// Começa o TLS aceitando qualquer certificado: equipamento de rede usa quase sempre um
+ /// autoassinado ou vencido, e aqui o objetivo é ler o certificado, não confiar nele.
+ ///
+ private static async Task HttpsAsync(Stream rede, IPAddress ip, int porta, CancellationToken cancelamento, CancellationToken usuario)
+ {
+ X509Certificate2? certificado = null;
+ await using var tls = new SslStream(rede, leaveInnerStreamOpen: false, (_, cert, _, _) =>
+ {
+ if (cert != null)
+ {
+ certificado = new X509Certificate2(cert);
+ }
+
+ return true;
+ });
+ await tls.AuthenticateAsClientAsync(new SslClientAuthenticationOptions { TargetHost = ip.ToString() }, cancelamento).ConfigureAwait(false);
+ var s = new ServicoIdentificado { Porta = porta, Protocolo = "HTTPS" };
+ if (certificado != null)
+ {
+ s.CertificadoNome = TextoRede.Limpar(certificado.GetNameInfo(X509NameType.SimpleName, forIssuer: false));
+ s.CertificadoEmissor = TextoRede.Limpar(certificado.GetNameInfo(X509NameType.SimpleName, forIssuer: true));
+ s.CertificadoValidade = new DateTimeOffset(certificado.NotAfter);
+ certificado.Dispose();
+ }
+
+ try
+ {
+ return await HttpAsync(tls, ip, porta, "HTTPS", s, cancelamento, usuario).ConfigureAwait(false);
+ }
+ catch (Exception e) when (e is IOException || (e is OperationCanceledException && !usuario.IsCancellationRequested))
+ {
+ // O certificado já foi lido; a página pode ter fechado a conexão ou não respondido no tempo.
+ return s;
+ }
+ }
+
+ /// Só lê a primeira linha que o servidor manda ao conectar. Não envia nada.
+ private static async Task BannerAsync(Stream rede, int porta, CancellationToken cancelamento)
+ {
+ var buffer = new byte[512];
+ var lidos = 0;
+ while (lidos < buffer.Length)
+ {
+ var n = await rede.ReadAsync(buffer.AsMemory(lidos), cancelamento).ConfigureAwait(false);
+ if (n == 0)
+ {
+ break;
+ }
+
+ lidos += n;
+ if (Array.IndexOf(buffer, (byte)'\n', 0, lidos) >= 0)
+ {
+ break;
+ }
+ }
+
+ var linha = Encoding.UTF8.GetString(buffer, 0, lidos).Split('\n')[0];
+ return new ServicoIdentificado { Porta = porta, Protocolo = EtapaIdentificacao.NomeBanner(porta), Banner = TextoRede.Limpar(linha) };
+ }
+
+ ///
+ /// Lê até o servidor fechar, até o limite de 64 KB ou, na página, até o fim do título. Se o
+ /// tempo acabar no meio, fica o que já chegou. Só o cancelamento de quem usa interrompe.
+ ///
+ private static async Task LerAsync(Stream rede, CancellationToken limite, CancellationToken usuario, bool ateTitulo)
+ {
+ var buffer = new byte[RespostaHttp.MaximoBytes];
+ var lidos = 0;
+ try
+ {
+ while (lidos < buffer.Length)
+ {
+ var n = await rede.ReadAsync(buffer.AsMemory(lidos), limite).ConfigureAwait(false);
+ if (n == 0)
+ {
+ break;
+ }
+
+ lidos += n;
+ if (ateTitulo && TemFimDoTitulo(buffer.AsSpan(0, lidos)))
+ {
+ break;
+ }
+ }
+ }
+ catch (OperationCanceledException) when (lidos > 0 && !usuario.IsCancellationRequested)
+ {
+ // O tempo acabou com a resposta pela metade: fica o que chegou.
+ }
+ catch (IOException) when (lidos > 0)
+ {
+ // Servidor fechou de um jeito brusco depois de mandar a resposta: fica o que chegou.
+ }
+
+ return buffer[..lidos];
+ }
+
+ private static bool TemFimDoTitulo(ReadOnlySpan dados) =>
+ dados.IndexOf("= 0 || dados.IndexOf("= 0 || dados.IndexOf("= 0;
+}
\ No newline at end of file
diff --git a/src/mapnet.nucleo/identificacao/servicos.cs b/src/mapnet.nucleo/identificacao/servicos.cs
new file mode 100644
index 0000000..2d2dfde
--- /dev/null
+++ b/src/mapnet.nucleo/identificacao/servicos.cs
@@ -0,0 +1,351 @@
+using System.Globalization;
+using System.Net;
+using System.Text;
+using System.Text.RegularExpressions;
+using System.Xml;
+
+namespace MapNet.Nucleo;
+
+/// Como o programa conversa com cada porta na identificação.
+public enum ProtocoloServico
+{
+ Http,
+ Https,
+ Banner,
+}
+
+/// O que um serviço disse de si. Todo texto aqui veio da rede e já passou por .
+public sealed class ServicoIdentificado
+{
+ public required int Porta { get; init; }
+
+ /// Nome curto do serviço: HTTP, HTTPS, SSH, FTP, SMTP ou UPnP.
+ public required string Protocolo { get; init; }
+
+ public int? CodigoHttp { get; set; }
+
+ public string? Titulo { get; set; }
+
+ public string? Servidor { get; set; }
+
+ /// Endereço do redirecionamento, anotado sem seguir.
+ public string? Redireciona { get; set; }
+
+ public string? CertificadoNome { get; set; }
+
+ public string? CertificadoEmissor { get; set; }
+
+ public DateTimeOffset? CertificadoValidade { get; set; }
+
+ public string? Banner { get; set; }
+
+ /// Nome amigável, fabricante e modelo pela descrição UPnP.
+ public string? Modelo { get; set; }
+
+ /// Uma linha para o detalhe: 80 HTTP: título "Painel", servidor lighttpd.
+ public string Texto
+ {
+ get
+ {
+ var partes = new List();
+ if (Modelo != null)
+ {
+ partes.Add(Modelo);
+ }
+
+ if (Titulo != null)
+ {
+ partes.Add($"título \"{Titulo}\"");
+ }
+
+ if (Servidor != null)
+ {
+ partes.Add($"servidor {Servidor}");
+ }
+
+ if (Redireciona != null)
+ {
+ partes.Add($"redireciona para {Redireciona}");
+ }
+
+ if (CertificadoNome != null)
+ {
+ var validade = CertificadoValidade is { } v ? $", válido até {v.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture)}" : string.Empty;
+ partes.Add($"certificado {CertificadoNome}" + (CertificadoEmissor != null && CertificadoEmissor != CertificadoNome ? $" emitido por {CertificadoEmissor}" : string.Empty) + validade);
+ }
+
+ if (Banner != null)
+ {
+ partes.Add(Banner);
+ }
+
+ var porta = Porta > 0 ? $"{Porta.ToString(CultureInfo.InvariantCulture)} " : string.Empty;
+ return $"{porta}{Protocolo}: " + (partes.Count > 0 ? string.Join(", ", partes) : "respondeu sem se identificar");
+ }
+ }
+}
+
+/// Limpeza do texto que vem da rede, que pode trazer qualquer coisa.
+public static class TextoRede
+{
+ public const int Maximo = 200;
+
+ /// Tira caractere de controle, junta espaços, apara e corta em . Vazio vira null.
+ public static string? Limpar(string? texto, int maximo = Maximo)
+ {
+ if (string.IsNullOrEmpty(texto))
+ {
+ return null;
+ }
+
+ var sb = new StringBuilder(Math.Min(texto.Length, maximo + 1));
+ var espaco = false;
+ foreach (var c in texto)
+ {
+ if (char.IsWhiteSpace(c) || char.IsControl(c))
+ {
+ espaco = sb.Length > 0;
+ continue;
+ }
+
+ if (char.GetUnicodeCategory(c) == UnicodeCategory.Format)
+ {
+ continue;
+ }
+
+ if (espaco)
+ {
+ sb.Append(' ');
+ espaco = false;
+ }
+
+ sb.Append(c);
+ if (sb.Length >= maximo)
+ {
+ break;
+ }
+ }
+
+ return sb.Length > 0 ? sb.ToString() : null;
+ }
+}
+
+/// Leitura de uma resposta HTTP já recebida: código, cabeçalhos e título da página.
+public static partial class RespostaHttp
+{
+ public const int MaximoBytes = 64 * 1024;
+
+ public sealed record Dados(int? Codigo, string? Servidor, string? Localizacao, string? Titulo, string? Corpo);
+
+ /// Pedido que o programa manda: só GET, sem cookie, sem credencial.
+ public static byte[] Pedido(string host, string caminho) =>
+ Encoding.ASCII.GetBytes(
+ $"GET {caminho} HTTP/1.1\r\nHost: {host}\r\nUser-Agent: MapNet-MT/{ResultadoVarredura.VersaoPrograma}\r\n"
+ + "Accept: text/html,application/xml;q=0.9,*/*;q=0.5\r\nConnection: close\r\n\r\n");
+
+ public static Dados Interpretar(ReadOnlySpan resposta)
+ {
+ var bytes = resposta.Length > MaximoBytes ? resposta[..MaximoBytes] : resposta;
+ var fim = bytes.IndexOf("\r\n\r\n"u8);
+ var cabecalho = Encoding.Latin1.GetString(fim >= 0 ? bytes[..fim] : bytes);
+ var corpo = fim >= 0 ? bytes[(fim + 4)..] : [];
+ var linhas = cabecalho.Split("\r\n");
+
+ int? codigo = null;
+ var primeira = linhas[0].Split(' ', 3);
+ if (primeira.Length >= 2 && primeira[0].StartsWith("HTTP/", StringComparison.Ordinal)
+ && int.TryParse(primeira[1], NumberStyles.None, CultureInfo.InvariantCulture, out var c))
+ {
+ codigo = c;
+ }
+
+ string? servidor = null, localizacao = null, tipo = null;
+ foreach (var linha in linhas.Skip(1))
+ {
+ var dois = linha.IndexOf(':');
+ if (dois <= 0)
+ {
+ continue;
+ }
+
+ var nome = linha[..dois].Trim();
+ var valor = linha[(dois + 1)..].Trim();
+ if (nome.Equals("Server", StringComparison.OrdinalIgnoreCase))
+ {
+ servidor = valor;
+ }
+ else if (nome.Equals("Location", StringComparison.OrdinalIgnoreCase))
+ {
+ localizacao = valor;
+ }
+ else if (nome.Equals("Content-Type", StringComparison.OrdinalIgnoreCase))
+ {
+ tipo = valor;
+ }
+ }
+
+ var texto = Decodificar(corpo, tipo);
+ return new Dados(codigo, TextoRede.Limpar(servidor), TextoRede.Limpar(localizacao), Titulo(texto), texto);
+ }
+
+ /// Título da página, com as entidades HTML resolvidas.
+ public static string? Titulo(string? html)
+ {
+ if (html is null)
+ {
+ return null;
+ }
+
+ var m = TagTitulo().Match(html);
+ return m.Success ? TextoRede.Limpar(WebUtility.HtmlDecode(m.Groups[1].Value)) : null;
+ }
+
+ /// Usa o charset do cabeçalho, depois o da página, e por fim UTF-8.
+ private static string Decodificar(ReadOnlySpan corpo, string? tipo)
+ {
+ var nome = CharsetDe(tipo);
+ if (nome is null)
+ {
+ var inicio = Encoding.Latin1.GetString(corpo[..Math.Min(corpo.Length, 2048)]);
+ var meta = MetaCharset().Match(inicio);
+ nome = meta.Success ? meta.Groups[1].Value : null;
+ }
+
+ var codificacao = Encoding.UTF8;
+ if (nome != null)
+ {
+ try
+ {
+ codificacao = nome.Equals("iso-8859-1", StringComparison.OrdinalIgnoreCase) || nome.Equals("latin1", StringComparison.OrdinalIgnoreCase)
+ ? Encoding.Latin1
+ : Encoding.GetEncoding(nome);
+ }
+ catch (ArgumentException)
+ {
+ // Charset que o .NET não conhece: fica o UTF-8.
+ }
+ }
+
+ return codificacao.GetString(corpo);
+ }
+
+ private static string? CharsetDe(string? tipo)
+ {
+ if (tipo is null)
+ {
+ return null;
+ }
+
+ var m = CharsetCabecalho().Match(tipo);
+ return m.Success ? m.Groups[1].Value : null;
+ }
+
+ [GeneratedRegex(@"]*>(.*?)", RegexOptions.IgnoreCase | RegexOptions.Singleline)]
+ private static partial Regex TagTitulo();
+
+ [GeneratedRegex(@"]+charset\s*=\s*[""']?([A-Za-z0-9_\-]+)", RegexOptions.IgnoreCase)]
+ private static partial Regex MetaCharset();
+
+ [GeneratedRegex(@"charset\s*=\s*[""']?([A-Za-z0-9_\-]+)", RegexOptions.IgnoreCase)]
+ private static partial Regex CharsetCabecalho();
+}
+
+/// Resposta de um equipamento ao M-SEARCH do UPnP.
+public sealed record RespostaSsdp(IPAddress Origem, string? Servidor, string? Localizacao);
+
+/// UPnP: o pedido M-SEARCH, a leitura das respostas e a descrição XML do equipamento.
+public static class Ssdp
+{
+ public static readonly IPEndPoint Grupo = new(IPAddress.Parse("239.255.255.250"), 1900);
+
+ public static byte[] Pedido() => Encoding.ASCII.GetBytes(
+ "M-SEARCH * HTTP/1.1\r\nHOST: 239.255.255.250:1900\r\nMAN: \"ssdp:discover\"\r\nMX: 2\r\nST: upnp:rootdevice\r\n\r\n");
+
+ public static RespostaSsdp? Interpretar(IPAddress origem, ReadOnlySpan pacote)
+ {
+ var texto = Encoding.Latin1.GetString(pacote);
+ var linhas = texto.Split("\r\n");
+ if (!linhas[0].StartsWith("HTTP/1.1 200", StringComparison.Ordinal))
+ {
+ return null;
+ }
+
+ string? servidor = null, local = null;
+ foreach (var linha in linhas.Skip(1))
+ {
+ var dois = linha.IndexOf(':');
+ if (dois <= 0)
+ {
+ continue;
+ }
+
+ var nome = linha[..dois].Trim();
+ var valor = linha[(dois + 1)..].Trim();
+ if (nome.Equals("SERVER", StringComparison.OrdinalIgnoreCase))
+ {
+ servidor = TextoRede.Limpar(valor);
+ }
+ else if (nome.Equals("LOCATION", StringComparison.OrdinalIgnoreCase))
+ {
+ local = valor;
+ }
+ }
+
+ return new RespostaSsdp(origem, servidor, local);
+ }
+
+ ///
+ /// Endereço da descrição, só quando aponta para o próprio equipamento que respondeu, por http.
+ /// Assim a leitura nunca vai a outro host.
+ ///
+ public static Uri? DescricaoDoProprio(RespostaSsdp r) =>
+ Uri.TryCreate(r.Localizacao, UriKind.Absolute, out var uri)
+ && uri.Scheme == Uri.UriSchemeHttp
+ && IPAddress.TryParse(uri.Host, out var ip) && ip.Equals(r.Origem)
+ ? uri
+ : null;
+
+ /// "Nome amigável (fabricante modelo)" da descrição UPnP. DTD é recusado, o que barra entidade externa.
+ public static string? Modelo(string? xml)
+ {
+ if (string.IsNullOrWhiteSpace(xml))
+ {
+ return null;
+ }
+
+ var ajustes = new XmlReaderSettings { DtdProcessing = DtdProcessing.Prohibit, XmlResolver = null, MaxCharactersInDocument = RespostaHttp.MaximoBytes };
+ string? amigavel = null, fabricante = null, modelo = null;
+ try
+ {
+ using var leitor = XmlReader.Create(new StringReader(xml), ajustes);
+ leitor.Read();
+ while (!leitor.EOF)
+ {
+ // ReadElementContentAsString já anda para o nó seguinte, então não chama Read depois dele.
+ if (leitor.NodeType == XmlNodeType.Element && leitor.LocalName == "friendlyName" && amigavel is null)
+ {
+ amigavel = TextoRede.Limpar(leitor.ReadElementContentAsString());
+ }
+ else if (leitor.NodeType == XmlNodeType.Element && leitor.LocalName == "manufacturer" && fabricante is null)
+ {
+ fabricante = TextoRede.Limpar(leitor.ReadElementContentAsString());
+ }
+ else if (leitor.NodeType == XmlNodeType.Element && leitor.LocalName == "modelName" && modelo is null)
+ {
+ modelo = TextoRede.Limpar(leitor.ReadElementContentAsString());
+ }
+ else
+ {
+ leitor.Read();
+ }
+ }
+ }
+ catch (XmlException)
+ {
+ return null;
+ }
+
+ var complemento = string.Join(' ', new[] { fabricante, modelo }.Where(s => s != null));
+ return TextoRede.Limpar(amigavel is null ? complemento : complemento.Length > 0 ? $"{amigavel} ({complemento})" : amigavel);
+ }
+}
diff --git a/src/mapnet.nucleo/linha-de-comando/argumentos.cs b/src/mapnet.nucleo/linha-de-comando/argumentos.cs
index 919d405..8292bb2 100644
--- a/src/mapnet.nucleo/linha-de-comando/argumentos.cs
+++ b/src/mapnet.nucleo/linha-de-comando/argumentos.cs
@@ -38,6 +38,10 @@ mapnet abre a janela
com lista, por exemplo 22,80,443 (até 100 portas)
--incluir-mac-aleatorio inclui nas portas os aparelhos com MAC aleatório, quase
sempre pessoais, que ficam de fora por padrão
+ --sem-identificar com --portas, não identifica os serviços. Sem esta
+ opção, nas portas abertas o programa pede a página
+ inicial web, lê o certificado HTTPS e o banner de SSH,
+ FTP e SMTP, e faz uma busca UPnP na rede local
Exemplos:
mapnet --varrer --interface Wi-Fi --saida C:\Relatorios --abrir
@@ -64,6 +68,7 @@ public static ArgumentosCli Interpretar(IReadOnlyList args)
{
var a = new ArgumentosCli();
var comandos = 0;
+ var semIdentificar = false;
for (var i = 0; i < args.Count; i++)
{
@@ -115,6 +120,10 @@ public static ArgumentosCli Interpretar(IReadOnlyList args)
case "--incluir-mac-aleatorio":
a.Opcoes.PortasEmMacAleatorio = true;
break;
+ case "--sem-identificar":
+ a.Opcoes.IdentificarServicos = false;
+ semIdentificar = true;
+ break;
case "--tempo-ping":
if (Numero(Valor(args, ref i, arg, a.Erros), 100, 10000, arg, a.Erros) is int tempo)
{
@@ -151,6 +160,11 @@ public static ArgumentosCli Interpretar(IReadOnlyList args)
a.Erros.Add("A opção --incluir-mac-aleatorio só vale junto com --portas.");
}
+ if (semIdentificar && !a.Opcoes.OlharPortas)
+ {
+ a.Erros.Add("A opção --sem-identificar só vale junto com --portas.");
+ }
+
return a;
}
diff --git a/src/mapnet.nucleo/painel/demonstracao.cs b/src/mapnet.nucleo/painel/demonstracao.cs
index 02ab1f1..d208d70 100644
--- a/src/mapnet.nucleo/painel/demonstracao.cs
+++ b/src/mapnet.nucleo/painel/demonstracao.cs
@@ -250,6 +250,26 @@ HostEncontrado Host(byte final, string mac, long? ping, Action?
[142] = [554, 8000],
};
+ /// Serviços de exemplo, com nomes e modelos inventados, pelo último número do IP.
+ private static IReadOnlyList ServicosExemplo(byte final) => final switch
+ {
+ 1 =>
+ [
+ new() { Porta = 0, Protocolo = "UPnP", Modelo = "Roteador do escritório (Exemplo Redes RX-100)" },
+ new() { Porta = 80, Protocolo = "HTTP", CodigoHttp = 302, Redireciona = "https://192.0.2.1/" },
+ new() { Porta = 443, Protocolo = "HTTPS", CodigoHttp = 200, Titulo = "Painel do roteador", CertificadoNome = "roteador.escritorio.example", CertificadoEmissor = "roteador.escritorio.example", CertificadoValidade = new DateTimeOffset(2030, 1, 1, 0, 0, 0, TimeSpan.Zero) },
+ ],
+ 5 =>
+ [
+ new() { Porta = 0, Protocolo = "UPnP", Modelo = "Impressora da recepção (Exemplo Print P-200)" },
+ new() { Porta = 80, Protocolo = "HTTP", CodigoHttp = 200, Titulo = "Impressora P-200", Servidor = "exemplo-httpd/1.0" },
+ ],
+ 12 => [new() { Porta = 22, Protocolo = "SSH", Banner = "SSH-2.0-OpenSSH_9.2p1" }, new() { Porta = 80, Protocolo = "HTTP", CodigoHttp = 200, Titulo = "Painel de senhas" }],
+ 57 => [new() { Porta = 0, Protocolo = "UPnP", Modelo = "Servidor de arquivos (Exemplo NAS N-4)" }],
+ 142 => [new() { Porta = 8000, Protocolo = "HTTP", CodigoHttp = 200, Titulo = "Gravador de câmeras", Servidor = "exemplo-nvr" }],
+ _ => [],
+ };
+
///
/// Varredura de mentira: anda pelos 254 endereços em uns 4 segundos e relata os hosts de
/// exemplo. Com as portas ligadas, preenche as portas de exemplo, com a mesma regra de quem
@@ -293,6 +313,19 @@ private static async Task VarrerAsync(InterfaceRede i, Opcoe
progresso.Report(new ProgressoVarredura(EtapaPortas.Nome, n + 1, alvos.Count, h));
await Task.Delay(120, CancellationToken.None).ConfigureAwait(true);
}
+
+ if (opcoes.IdentificarServicos)
+ {
+ resultado.IdentificacaoFeita = true;
+ for (var n = 0; n < alvos.Count; n++)
+ {
+ var h = alvos[n];
+ h.Servicos = ServicosExemplo(h.Ip.GetAddressBytes()[3]).Where(s => s.Porta == 0 || h.PortasAbertas.Contains(s.Porta)).ToList();
+ h.ServicosIdentificados = true;
+ progresso.Report(new ProgressoVarredura(EtapaIdentificacao.Nome, n + 1, alvos.Count, h));
+ await Task.Delay(80, CancellationToken.None).ConfigureAwait(true);
+ }
+ }
}
resultado.Fim = DateTimeOffset.Now;
diff --git a/src/mapnet.nucleo/painel/detalhe-host.cs b/src/mapnet.nucleo/painel/detalhe-host.cs
index d6acd7c..9803baa 100644
--- a/src/mapnet.nucleo/painel/detalhe-host.cs
+++ b/src/mapnet.nucleo/painel/detalhe-host.cs
@@ -85,6 +85,10 @@ public static string TextoPortas(HostEncontrado h) =>
? (h.PortasAbertas.Count > 0 ? ListaPortas.Texto(h.PortasAbertas) : "nenhuma das portas verificadas está aberta")
: h.MotivoSemPortas ?? "não verificadas nesta varredura";
+ /// Uma linha por serviço, para o detalhe e o relatório.
+ public static string TextoServicos(HostEncontrado h) =>
+ h.Servicos.Count > 0 ? string.Join(Environment.NewLine, h.Servicos.Select(s => s.Texto)) : "nenhum serviço se identificou";
+
/// Pista do sistema pelo TTL da resposta ao ping.
public static string? PistaDoTtl(int? ttl) => ttl switch
{
@@ -116,6 +120,10 @@ public static IReadOnlyList Itens(HostEncontrado h)
itens.Add(new("ARP", h.RespondeuArp ? "respondeu" : h.EhEsteComputador ? "não se aplica (este computador)" : "não respondeu"));
itens.Add(new("Portas abertas", TextoPortas(h)));
+ if (h.ServicosIdentificados)
+ {
+ itens.Add(new("Serviços", TextoServicos(h)));
+ }
if (h.Marcas.Count > 0)
{
itens.Add(new("Observação", string.Join(", ", h.Marcas)));
diff --git a/src/mapnet.nucleo/painel/linha-host.cs b/src/mapnet.nucleo/painel/linha-host.cs
index 63e7189..ece0426 100644
--- a/src/mapnet.nucleo/painel/linha-host.cs
+++ b/src/mapnet.nucleo/painel/linha-host.cs
@@ -39,6 +39,12 @@ public LinhaHost(HostEncontrado host)
public string Observacao => string.Join(", ", _host.Marcas);
+ /// O texto mais útil que os serviços deram: modelo, título da página, certificado ou banner.
+ public string Servico => _host.ServicoResumo;
+
+ /// Dica da coluna Serviço: uma linha por serviço identificado.
+ public string? ServicoDica => _host.Servicos.Count > 0 ? string.Join(Environment.NewLine, _host.Servicos.Select(s => s.Texto)) : null;
+
/// Portas abertas, só os números: "22, 80, 443".
public string Portas => _host.PortasTexto;
@@ -80,6 +86,7 @@ public bool Contem(string texto)
|| (macBusca.Length > 0 && Mac.Replace(":", string.Empty).Contains(macBusca, StringComparison.OrdinalIgnoreCase))
|| Fabricante.Contains(t, StringComparison.OrdinalIgnoreCase)
|| Observacao.Contains(t, StringComparison.OrdinalIgnoreCase)
+ || _host.Servicos.Any(s => s.Texto.Contains(t, StringComparison.OrdinalIgnoreCase))
|| _host.PortasAbertas.Any(p => p.ToString(System.Globalization.CultureInfo.InvariantCulture) == t);
}
diff --git a/src/mapnet.nucleo/painel/painel-varredura.cs b/src/mapnet.nucleo/painel/painel-varredura.cs
index 0fed974..756b9c0 100644
--- a/src/mapnet.nucleo/painel/painel-varredura.cs
+++ b/src/mapnet.nucleo/painel/painel-varredura.cs
@@ -637,16 +637,36 @@ public bool PortasEmMacAleatorio
}
}
+ /// Identifica os serviços das portas abertas. Marcado ao abrir, e só vale com as portas ligadas.
+ public bool IdentificarServicos
+ {
+ get => _dep.Opcoes.IdentificarServicos;
+ set
+ {
+ if (_dep.Opcoes.IdentificarServicos == value || !PodeTrocarInterface)
+ {
+ return;
+ }
+
+ _dep.Opcoes.IdentificarServicos = value;
+ Avisar();
+ }
+ }
+
public string DicaPortasPadrao => "Portas da lista padrão: " + ListaPortas.Texto(ListaPortas.Padrao);
///
/// Pergunta feita antes da primeira verificação de portas em cada sub-rede. Diz o que vai ser
/// enviado e lembra da autorização, como recomenda a análise jurídica de 26/09/2026.
///
- public static string PerguntaPortas(SubRede subRede, int quantidade, bool incluiMacAleatorio) =>
+ public static string PerguntaPortas(SubRede subRede, int quantidade, bool incluiMacAleatorio, bool identificar = false) =>
$"Verificar portas em {subRede}?\n\n"
+ $"Para cada equipamento encontrado, o MapNet abre e fecha uma conexão TCP em {quantidade} porta(s), sem enviar dados. "
+ "Não testa senha nem explora falha.\n\n"
+ + (identificar
+ ? "Com Identificar serviços, nas portas que estiverem abertas ele também pede a página inicial dos serviços web (só GET, sem senha), "
+ + "lê o certificado HTTPS e a primeira linha de SSH, FTP e SMTP, e faz uma busca UPnP na rede local.\n\n"
+ : string.Empty)
+ (incluiMacAleatorio
? "Os aparelhos com MAC aleatório, quase sempre celulares e notebooks pessoais, também entram.\n\n"
: "Os aparelhos com MAC aleatório, quase sempre pessoais, ficam de fora.\n\n")
@@ -792,6 +812,10 @@ public async Task VarrerAsync()
if (_dep.Opcoes.OlharPortas)
{
Console.Escrever($"Portas: {_dep.Opcoes.Portas.Count} porta(s) TCP em cada host encontrado, só abrindo e fechando a conexão.");
+ if (_dep.Opcoes.IdentificarServicos)
+ {
+ Console.Escrever("Serviços: página inicial web, certificado HTTPS, banner de SSH, FTP e SMTP e busca UPnP, só nas portas abertas.");
+ }
}
if (aviso != null)
@@ -870,13 +894,13 @@ private bool PrepararPortas(InterfaceRede interfaceRede)
_dep.Opcoes.Portas = portas;
var subRede = Varredor.SubRedeAVarrer(interfaceRede, _dep.PrefixoMinimo, out _);
- var chave = $"{subRede}|{_dep.Opcoes.PortasEmMacAleatorio}";
+ var chave = $"{subRede}|{_dep.Opcoes.PortasEmMacAleatorio}|{_dep.Opcoes.IdentificarServicos}";
if (_redesConfirmadas.Contains(chave))
{
return true;
}
- if (_dep.Confirmar?.Invoke(PerguntaPortas(subRede, portas.Count, _dep.Opcoes.PortasEmMacAleatorio)) != true)
+ if (_dep.Confirmar?.Invoke(PerguntaPortas(subRede, portas.Count, _dep.Opcoes.PortasEmMacAleatorio, _dep.Opcoes.IdentificarServicos)) != true)
{
Console.Escrever("Varredura não iniciada: a verificação de portas não foi confirmada.");
return false;
diff --git a/src/mapnet.nucleo/relatorios/relatorio-html.cs b/src/mapnet.nucleo/relatorios/relatorio-html.cs
index 78f3f55..d4834ed 100644
--- a/src/mapnet.nucleo/relatorios/relatorio-html.cs
+++ b/src/mapnet.nucleo/relatorios/relatorio-html.cs
@@ -100,6 +100,7 @@ public static string Gerar(ResultadoVarredura r)
{{C(r.PortasVerificadas is { } portas ? $"{portas.Count} porta(s) TCP, só abrindo e fechando a conexão: {ListaPortas.Texto(portas)}" : "não verificadas nesta varredura")}}
+
Identificação de serviços
{{C(r.IdentificacaoFeita ? "feita nas portas abertas: página inicial web, certificado HTTPS, banner de SSH, FTP e SMTP e busca UPnP" : "não feita nesta varredura")}}