From dbd53c666a8ae33f7c0654b678129ec5e95f4357 Mon Sep 17 00:00:00 2001 From: Manfred Heil Junior Date: Sat, 26 Sep 2026 13:34:54 -0300 Subject: [PATCH 1/2] Classifica o tipo provavel de cada equipamento Fatia 9, que sai como v0.5.0, escrita com o plano no mesmo PR porque o Manfred pediu para seguir direto ate o fim da versao 1.0. Cada sinal que as outras etapas ja levantaram (gateway, este computador, MAC aleatorio, fabricante, nomes, TTL, portas e servicos) soma pontos para um tipo e deixa o motivo escrito. Ganha o tipo com mais pontos, a partir de 3; abaixo disso, fica Desconhecido. Nada vai para a rede. A coluna Tipo entra no lugar de Origem, que passa para a dica do Nome e continua no detalhe. Detalhe, relatorio e linha de comando mostram o tipo e os motivos, e o relatorio conta os hosts por tipo. Conferido na rede do Manfred: roteador, camera, TV, videogame, automacao e Windows sairam certos. Autores: Manfred Heil Junior --- Directory.Build.props | 2 +- README.md | 4 +- docs/superpowers/pendencias.md | 7 + .../2026-09-26-mapnet-1.0-plano-geral.md | 23 +- public/index.html | 2 +- .../classificacao/classificador.cs | 214 +++++++++++++++++ src/mapnet.nucleo/painel/detalhe-host.cs | 1 + src/mapnet.nucleo/painel/linha-host.cs | 10 + .../relatorios/relatorio-html.cs | 22 +- src/mapnet.nucleo/varredura/modelos.cs | 3 + src/mapnet/app.manifest | 2 +- src/mapnet/janela-principal.xaml | 18 +- src/mapnet/modo-linha-de-comando.cs | 3 +- testes/mapnet.testes/classificacao-testes.cs | 222 ++++++++++++++++++ testes/mapnet.testes/portas-testes.cs | 2 +- 15 files changed, 522 insertions(+), 13 deletions(-) create mode 100644 src/mapnet.nucleo/classificacao/classificador.cs create mode 100644 testes/mapnet.testes/classificacao-testes.cs diff --git a/Directory.Build.props b/Directory.Build.props index 75ca962..d93c178 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -4,7 +4,7 @@ enable enable true - 0.4.0 + 0.5.0 Manfred Heil Junior MT - Manfred Tecnologia MapNet - MT diff --git a/README.md b/README.md index 0b23155..8070edb 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,8 @@ As regras do projeto estão no [`AGENTS.md`](AGENTS.md) e o desenho em [`docs/su | 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-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 | +| 9 | Classificação por tipo de equipamento, com os motivos à vista (sai como v0.5.0) | `classificacao` | [PREENCHER] | Em revisão | +| 10 | Relatório também em XML e CSV (sai como 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). @@ -112,6 +113,7 @@ Se o valor bater com o do `.txt`, o executável é exatamente o que foi publicad - **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. +- **Tipo provável (desde a v0.5.0):** roteador, impressora, câmera ou gravador, NAS, computador Windows, Linux, access point, TV ou mídia, videogame, celular ou tablet, automação ou desconhecido. É um palpite pelo que as outras etapas levantaram (fabricante, nomes, TTL, portas e serviços), sem mandar nada para a rede, e o detalhe mostra os motivos. - **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 d40f739..a25e0cc 100644 --- a/docs/superpowers/pendencias.md +++ b/docs/superpowers/pendencias.md @@ -73,6 +73,13 @@ Fechado em parte na fatia 3 (0.2.2). No tamanho padrão da janela, IP, Origem, M | 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 | +## Fatia 9: classificação + +| Item | Motivo | O que fecha | +|---|---|---| +| Tabela de sinais | Os fabricantes e as palavras que apontam cada tipo saíram do que é comum em escritório e do teste na rede do Manfred. Equipamento de marca que não está na lista cai em Desconhecido | Acrescentar fabricante ou palavra quando o técnico achar um caso errado no campo | +| Switch gerenciável | Switch quase não se mostra: não costuma ter nome, porta aberta diferente nem UPnP. A fatia não arrisca esse tipo | Rever se aparecer sinal confiável, como título de página de switch conhecido | + ## Página de privacidade | Item | Motivo | O que fecha | diff --git a/docs/superpowers/plans/2026-09-26-mapnet-1.0-plano-geral.md b/docs/superpowers/plans/2026-09-26-mapnet-1.0-plano-geral.md index 70476d4..f2f1020 100644 --- a/docs/superpowers/plans/2026-09-26-mapnet-1.0-plano-geral.md +++ b/docs/superpowers/plans/2026-09-26-mapnet-1.0-plano-geral.md @@ -135,4 +135,25 @@ Recomendação: a identificação vem junto com **Verificar portas**, numa caixa 1. Varrer a rede de teste com portas e identificação: o roteador mostra o título do painel, a impressora o modelo, o NAS o nome pelo UPnP. 2. Varrer só com portas, sem identificação, e ver a coluna Serviço vazia. -3. `mapnet --varrer --portas --identificar --abrir` e conferir o relatório. \ No newline at end of file +3. `mapnet --varrer --portas --identificar --abrir` e conferir o relatório. + +## Fatia 9: classificação por tipo de equipamento + +Sai como v0.5.0. Escrita junto com o código, porque o Manfred pediu em 26/09/2026 para seguir direto até o fim da versão 1.0. + +### O que entra + +- **Tipo provável de cada host**, com os motivos à vista: Roteador, Impressora, Câmera ou gravador, NAS, Computador Windows, Linux, Access point, TV ou mídia, Videogame, Celular ou tablet, Automação e Desconhecido. +- **Não manda nada para a rede.** Usa só o que as outras etapas já levantaram: gateway, este computador, MAC aleatório, fabricante, nomes (DNS, NetBIOS, mDNS), TTL, portas abertas e o que os serviços disseram (modelo UPnP, título, servidor). +- **Pontuação:** cada sinal soma pontos para um tipo e deixa o motivo escrito. Ganha o tipo com mais pontos, a partir de 3. Abaixo disso, fica Desconhecido. Um sinal fraco sozinho, como um fabricante que faz de tudo, não basta. +- **Palavras só inteiras:** "tv" não acha "tvbox", "nas" não acha "nascimento". + +### Onde aparece + +- **Tabela:** coluna **Tipo** no lugar de **Origem**, com os motivos na dica. A origem do nome passa para a dica da coluna Nome e continua no detalhe. +- **Detalhe e relatório:** linha **Tipo provável** com os motivos. O relatório ganha a coluna Tipo e a tabela **Tipos prováveis**, com a contagem. +- **Linha de comando:** o tipo sai na lista de hosts. O filtro da tela também acha host pelo tipo. + +### Testes + +Cada tipo pelos seus sinais, o mínimo para arriscar, o sinal mais forte ganhando, a palavra inteira, este computador como Windows, e o tipo na tabela, no detalhe e no relatório. \ No newline at end of file diff --git a/public/index.html b/public/index.html index f4ad030..09516b4 100644 --- a/public/index.html +++ b/public/index.html @@ -46,7 +46,7 @@

Por que ele existe

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.

+

Fabricante, nome e tipo

Pelo MAC, na tabela do IEEE, por DNS, NetBIOS e mDNS, e um palpite do tipo: roteador, impressora, câmera, computador.

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, 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.

diff --git a/src/mapnet.nucleo/classificacao/classificador.cs b/src/mapnet.nucleo/classificacao/classificador.cs new file mode 100644 index 0000000..4d09d80 --- /dev/null +++ b/src/mapnet.nucleo/classificacao/classificador.cs @@ -0,0 +1,214 @@ +namespace MapNet.Nucleo; + +/// Tipo provável do equipamento. A ordem desempata: em pontuação igual, vale o que vem antes. +public enum TipoEquipamento +{ + Desconhecido, + Roteador, + Impressora, + Camera, + Nas, + ComputadorWindows, + Linux, + AccessPoint, + TvMidia, + Console, + Celular, + Automacao, +} + +/// Tipo provável e os sinais que levaram a ele, para o técnico conferir. +public sealed record Classificacao(TipoEquipamento Tipo, IReadOnlyList Motivos) +{ + public static readonly Classificacao Nenhuma = new(TipoEquipamento.Desconhecido, []); + + public string Nome => Classificador.Nome(Tipo); + + /// "Impressora (porta 9100 aberta, fabricante Brother)". + public string Texto => Tipo == TipoEquipamento.Desconhecido ? "não deu para saber" : $"{Nome} ({string.Join(", ", Motivos)})"; +} + +/// +/// Classificação por heurística, com o que as outras etapas já levantaram: fabricante, nomes, +/// TTL, portas, serviços e marcas. Não manda nada para a rede. Cada sinal soma pontos para um +/// tipo e deixa o motivo escrito. Ganha o tipo com mais pontos, a partir de um mínimo; abaixo +/// dele, fica Desconhecido. É um palpite com os motivos à vista, não uma certeza. +/// +public static class Classificador +{ + private const int Minimo = 3; + + public static string Nome(TipoEquipamento tipo) => tipo switch + { + TipoEquipamento.Roteador => "Roteador", + TipoEquipamento.Impressora => "Impressora", + TipoEquipamento.Camera => "Câmera ou gravador", + TipoEquipamento.Nas => "NAS", + TipoEquipamento.ComputadorWindows => "Computador Windows", + TipoEquipamento.Linux => "Linux", + TipoEquipamento.AccessPoint => "Access point", + TipoEquipamento.TvMidia => "TV ou mídia", + TipoEquipamento.Console => "Videogame", + TipoEquipamento.Celular => "Celular ou tablet", + TipoEquipamento.Automacao => "Automação", + _ => "Desconhecido", + }; + + private static readonly (string Trecho, TipoEquipamento Tipo, int Pontos)[] _fabricantes = + [ + ("Hewlett Packard", TipoEquipamento.Impressora, 2), ("HP Inc", TipoEquipamento.Impressora, 2), ("Brother", TipoEquipamento.Impressora, 3), + ("Seiko Epson", TipoEquipamento.Impressora, 3), ("Canon", TipoEquipamento.Impressora, 2), ("Lexmark", TipoEquipamento.Impressora, 3), + ("Xerox", TipoEquipamento.Impressora, 3), ("Ricoh", TipoEquipamento.Impressora, 3), ("Kyocera", TipoEquipamento.Impressora, 3), + ("Hikvision", TipoEquipamento.Camera, 3), ("Dahua", TipoEquipamento.Camera, 3), ("Axis Communications", TipoEquipamento.Camera, 3), + ("Synology", TipoEquipamento.Nas, 4), ("QNAP", TipoEquipamento.Nas, 4), + ("Ubiquiti", TipoEquipamento.AccessPoint, 3), ("Ruckus", TipoEquipamento.AccessPoint, 3), ("Aruba", TipoEquipamento.AccessPoint, 2), + ("MikroTik", TipoEquipamento.Roteador, 2), ("Routerboard", TipoEquipamento.Roteador, 2), + ("Tuya", TipoEquipamento.Automacao, 4), ("Espressif", TipoEquipamento.Automacao, 3), ("Shelly", TipoEquipamento.Automacao, 4), + ("Philips Lighting", TipoEquipamento.Automacao, 4), ("Signify", TipoEquipamento.Automacao, 4), ("iTEAD", TipoEquipamento.Automacao, 4), + ("Sony Interactive", TipoEquipamento.Console, 4), ("Nintendo", TipoEquipamento.Console, 4), + ("Roku", TipoEquipamento.TvMidia, 4), ("Raspberry Pi", TipoEquipamento.Linux, 2), + ]; + + private static readonly (string Trecho, TipoEquipamento Tipo, int Pontos)[] _palavras = + [ + ("impressora", TipoEquipamento.Impressora, 3), ("printer", TipoEquipamento.Impressora, 3), ("laserjet", TipoEquipamento.Impressora, 4), + ("officejet", TipoEquipamento.Impressora, 4), ("deskjet", TipoEquipamento.Impressora, 4), ("ecotank", TipoEquipamento.Impressora, 4), + ("câmera", TipoEquipamento.Camera, 3), ("camera", TipoEquipamento.Camera, 3), ("nvr", TipoEquipamento.Camera, 3), ("dvr", TipoEquipamento.Camera, 3), + ("gravador", TipoEquipamento.Camera, 2), ("ipc", TipoEquipamento.Camera, 1), + ("roteador", TipoEquipamento.Roteador, 3), ("router", TipoEquipamento.Roteador, 3), ("gateway", TipoEquipamento.Roteador, 2), + ("access point", TipoEquipamento.AccessPoint, 3), ("unifi", TipoEquipamento.AccessPoint, 3), + ("nas", TipoEquipamento.Nas, 2), ("diskstation", TipoEquipamento.Nas, 4), + ("webos", TipoEquipamento.TvMidia, 4), ("tv", TipoEquipamento.TvMidia, 2), ("chromecast", TipoEquipamento.TvMidia, 4), ("tizen", TipoEquipamento.TvMidia, 3), + ("ps4", TipoEquipamento.Console, 4), ("ps5", TipoEquipamento.Console, 4), ("xbox", TipoEquipamento.Console, 4), + ("iphone", TipoEquipamento.Celular, 4), ("ipad", TipoEquipamento.Celular, 4), ("android", TipoEquipamento.Celular, 3), ("galaxy", TipoEquipamento.Celular, 3), + ]; + + public static Classificacao Classificar(HostEncontrado h) + { + var pontos = new Dictionary(); + var motivos = new Dictionary>(); + + void Somar(TipoEquipamento tipo, int valor, string motivo) + { + pontos[tipo] = pontos.GetValueOrDefault(tipo) + valor; + if (!motivos.TryGetValue(tipo, out var lista)) + { + motivos[tipo] = lista = []; + } + + if (!lista.Contains(motivo)) + { + lista.Add(motivo); + } + } + + bool Aberta(int porta) => h.PortasAbertas.Contains(porta); + + if (h.EhGateway) + { + Somar(TipoEquipamento.Roteador, 5, "é o gateway da rede"); + } + + if (h.EhEsteComputador) + { + Somar(TipoEquipamento.ComputadorWindows, 6, "é este computador, e o MapNet roda no Windows"); + } + else if (h.MacAleatorio) + { + Somar(TipoEquipamento.Celular, 3, "MAC aleatório"); + } + + foreach (var (trecho, tipo, valor) in _fabricantes) + { + if (h.Fabricante.Contains(trecho, StringComparison.OrdinalIgnoreCase)) + { + Somar(tipo, valor, $"fabricante {h.Fabricante}"); + } + } + + // Nomes e textos dos serviços: nome do host, modelo UPnP, título da página, certificado. + var textos = new[] { h.NomeDns, h.NomeNetBios, h.NomeMdns } + .Concat(h.Servicos.SelectMany(s => new[] { s.Modelo, s.Titulo, s.Servidor })) + .Where(t => !string.IsNullOrWhiteSpace(t)) + .Select(t => t!) + .ToList(); + foreach (var (trecho, tipo, valor) in _palavras) + { + var achado = textos.FirstOrDefault(t => ContemPalavra(t, trecho)); + if (achado != null) + { + Somar(tipo, valor, $"\"{Cortar(achado)}\""); + } + } + + if (Aberta(9100) || Aberta(515) || Aberta(631)) + { + Somar(TipoEquipamento.Impressora, 4, $"porta de impressão aberta ({string.Join(", ", new[] { 9100, 515, 631 }.Where(Aberta))})"); + } + + if (Aberta(554) || Aberta(37777)) + { + Somar(TipoEquipamento.Camera, 4, $"porta de câmera aberta ({string.Join(", ", new[] { 554, 37777 }.Where(Aberta))})"); + } + + if (Aberta(445) || Aberta(135) || Aberta(3389)) + { + Somar(TipoEquipamento.ComputadorWindows, 3, $"portas do Windows abertas ({string.Join(", ", new[] { 135, 139, 445, 3389 }.Where(Aberta))})"); + } + + if (Aberta(5000) && (Aberta(445) || Aberta(139))) + { + Somar(TipoEquipamento.Nas, 2, "portas 5000 e de compartilhamento abertas"); + } + + if (Aberta(8291)) + { + Somar(TipoEquipamento.Roteador, 4, "porta 8291 (Winbox) aberta"); + } + + if (Aberta(53) && (Aberta(80) || Aberta(443))) + { + Somar(TipoEquipamento.Roteador, 2, "DNS e página web abertos"); + } + + if (h.Servicos.Any(s => s.Protocolo == "SSH") && !Aberta(445)) + { + Somar(TipoEquipamento.Linux, 2, "SSH aberto"); + } + + if (h.Ttl is > 64 and <= 128 && h.NomeNetBios != null) + { + Somar(TipoEquipamento.ComputadorWindows, 2, "TTL 128 e nome NetBIOS"); + } + + if (pontos.Count == 0) + { + return Classificacao.Nenhuma; + } + + var melhor = pontos.OrderByDescending(p => p.Value).ThenBy(p => (int)p.Key).First(); + return melhor.Value >= Minimo ? new Classificacao(melhor.Key, motivos[melhor.Key]) : Classificacao.Nenhuma; + } + + /// Palavra inteira, para "tv" não achar "tvbox" nem "nas" achar "nascimento". + private static bool ContemPalavra(string texto, string palavra) + { + var i = 0; + while ((i = texto.IndexOf(palavra, i, StringComparison.OrdinalIgnoreCase)) >= 0) + { + var antes = i == 0 || !char.IsLetterOrDigit(texto[i - 1]); + var fim = i + palavra.Length; + var depois = fim >= texto.Length || !char.IsLetterOrDigit(texto[fim]); + if (antes && depois) + { + return true; + } + + i = fim; + } + + return false; + } + + private static string Cortar(string texto) => texto.Length <= 40 ? texto : texto[..37] + "..."; +} diff --git a/src/mapnet.nucleo/painel/detalhe-host.cs b/src/mapnet.nucleo/painel/detalhe-host.cs index 9803baa..78acf4d 100644 --- a/src/mapnet.nucleo/painel/detalhe-host.cs +++ b/src/mapnet.nucleo/painel/detalhe-host.cs @@ -107,6 +107,7 @@ public static IReadOnlyList Itens(HostEncontrado h) new("IP", h.Ip.ToString()), new("MAC", h.MacTexto.Length > 0 ? h.MacTexto : "não obtido"), new("Fabricante", h.Fabricante.Length > 0 ? h.Fabricante : "não identificado"), + new("Tipo provável", h.Classificacao.Texto), new("Nome pelo DNS reverso", h.NomeDns ?? "sem resposta"), new("Nome NetBIOS", h.NomeNetBios ?? "sem resposta"), new("Grupo de trabalho ou domínio", h.GrupoNetBios ?? "sem resposta"), diff --git a/src/mapnet.nucleo/painel/linha-host.cs b/src/mapnet.nucleo/painel/linha-host.cs index ece0426..b86e32c 100644 --- a/src/mapnet.nucleo/painel/linha-host.cs +++ b/src/mapnet.nucleo/painel/linha-host.cs @@ -39,6 +39,15 @@ public LinhaHost(HostEncontrado host) public string Observacao => string.Join(", ", _host.Marcas); + /// Tipo provável para a coluna. Vazio quando não deu para saber. + public string Tipo => _host.Classificacao is { Tipo: not TipoEquipamento.Desconhecido } c ? c.Nome : string.Empty; + + /// Dica da coluna Tipo: os sinais que levaram ao palpite. + public string? TipoDica => _host.Classificacao is { Tipo: not TipoEquipamento.Desconhecido } c ? "Tipo provável: " + c.Texto : null; + + /// Dica da coluna Nome: o nome e de onde ele veio. + public string? NomeDica => Nome.Length > 0 ? $"{Nome} (pelo {OrigemNome})" : null; + /// O texto mais útil que os serviços deram: modelo, título da página, certificado ou banner. public string Servico => _host.ServicoResumo; @@ -86,6 +95,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) + || Tipo.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/relatorios/relatorio-html.cs b/src/mapnet.nucleo/relatorios/relatorio-html.cs index d4834ed..f7a9c4f 100644 --- a/src/mapnet.nucleo/relatorios/relatorio-html.cs +++ b/src/mapnet.nucleo/relatorios/relatorio-html.cs @@ -44,6 +44,12 @@ public static string Gerar(ResultadoVarredura r) .OrderByDescending(g => g.Count()) .ThenBy(g => g.Key, StringComparer.CurrentCultureIgnoreCase) .ToList(); + var tipos = hosts + .GroupBy(h => h.Classificacao.Nome) + .OrderBy(g => g.Key == Classificador.Nome(TipoEquipamento.Desconhecido)) + .ThenByDescending(g => g.Count()) + .ThenBy(g => g.Key, StringComparer.CurrentCultureIgnoreCase) + .ToList(); var i = r.Interface; var html = new StringBuilder(64 * 1024); @@ -119,6 +125,7 @@ public static string Gerar(ResultadoVarredura r) Nome MAC Fabricante + Tipo Ping (ms) Portas Serviço @@ -138,6 +145,14 @@ public static string Gerar(ResultadoVarredura r) """); + html.Append("

Tipos prováveis

Palpite pelo fabricante, pelos nomes, pelas portas e pelos serviços. O detalhe de cada host diz o motivo.

"); + foreach (var grupo in tipos) + { + html.Append($""); + } + + html.Append("
TipoHosts
{C(grupo.Key)}{grupo.Count()}
"); + html.Append("

Fabricantes

"); foreach (var grupo in fabricantes) { @@ -180,7 +195,7 @@ private static void AcrescentarMaquina(StringBuilder html, IReadOnlyList s.Texto))) + var busca = string.Join(' ', h.Ip, h.Nome, h.NomeDns, h.NomeNetBios, h.NomeMdns, h.GrupoNetBios, h.MacTexto, h.Fabricante, string.Join(' ', marcas), h.PortasTexto, string.Join(' ', h.Servicos.Select(s => s.Texto)), h.Classificacao.Nome) .ToLowerInvariant(); var classe = h.EhGateway ? " class=\"host gateway\"" : h.EhEsteComputador ? " class=\"host proprio\"" : " class=\"host\""; @@ -189,16 +204,19 @@ private static void AcrescentarHost(StringBuilder html, HostEncontrado h) html.Append($""); html.Append($""); html.Append($""); + var classificacao = h.Classificacao; + html.Append($""); html.Append($""); html.Append($""); html.Append($""); html.Append($""); html.Append(""); - html.Append("", html); + Assert.Contains("

Tipos prováveis

", html); + Assert.Contains("", html); + Assert.Contains("", html); + } + + [Fact] + public void Desconhecido_fica_vazio_na_tabela() + { + var linha = new LinhaHost(Host()); + + Assert.Equal(string.Empty, linha.Tipo); + Assert.Null(linha.TipoDica); + } + + [Fact] + public void Nome_traz_a_origem_na_dica() + { + var h = Host(); + h.NomeNetBios = "IMPRESSORA-RECEPCAO"; + + Assert.Equal("IMPRESSORA-RECEPCAO (pelo NetBIOS)", new LinhaHost(h).NomeDica); + } + + [Fact] + public void Demonstracao_tem_varios_tipos() + { + var dependencias = Demonstracao.Dependencias(); + var i = dependencias.ListarInterfaces()[0]; + + var tipos = Demonstracao.Hosts(i).Select(h => h.Classificacao.Tipo).Distinct().ToList(); + + Assert.Contains(TipoEquipamento.Roteador, tipos); + Assert.Contains(TipoEquipamento.Celular, tipos); + } + + private static HostEncontrado Host( + string ip = "192.0.2.5", + string fabricante = "", + int[]? portas = null, + bool gateway = false, + int? ttl = 64, + byte[]? mac = null) => new() + { + Ip = IPAddress.Parse(ip), + Mac = mac ?? [0x00, 0x11, 0x22, 0x33, 0x44, 0x55], + Fabricante = fabricante, + RespondeuPing = true, + TempoPingMs = 1, + Ttl = ttl, + EhGateway = gateway, + PortasAbertas = portas ?? [], + PortasTestadas = ListaPortas.Padrao, + PortasVerificadas = portas != null, + }; + + private static ResultadoVarredura Resultado(params HostEncontrado[] hosts) + { + var i = new InterfaceRede + { + Id = "teste", + Nome = "Ethernet", + Descricao = "Placa de teste", + Tipo = TipoInterface.Cabo, + Ip = IPAddress.Parse("192.0.2.10"), + Prefixo = 24, + Gateway = IPAddress.Parse("192.0.2.1"), + Dns = [IPAddress.Parse("192.0.2.1")], + }; + var inicio = new DateTimeOffset(2026, 9, 26, 10, 0, 0, TimeSpan.Zero); + var r = new ResultadoVarredura { Interface = i, SubRedeVarrida = i.SubRede, Inicio = inicio, Fim = inicio.AddSeconds(30) }; + r.Hosts.AddRange(hosts); + return r; + } +} diff --git a/testes/mapnet.testes/portas-testes.cs b/testes/mapnet.testes/portas-testes.cs index 9aa1338..c3681bb 100644 --- a/testes/mapnet.testes/portas-testes.cs +++ b/testes/mapnet.testes/portas-testes.cs @@ -234,7 +234,7 @@ public void Relatorio_tem_coluna_de_portas_e_diz_quais_foram_verificadas() Assert.Contains("title=\"80 (HTTP)\">80", html); Assert.Contains("2 porta(s) TCP, só abrindo e fechando a conexão: 80 (HTTP), 443 (HTTPS)", html); Assert.Contains("com verificação de portas por conexão TCP", html); - Assert.Contains("colspan=\"8\"", html); + Assert.Contains("colspan=\"9\"", html); } [Fact] From 08cf79e2a6b49ddae64a19ad0bd70a05300110c0 Mon Sep 17 00:00:00 2001 From: Manfred Heil Junior Date: Sat, 26 Sep 2026 13:35:22 -0300 Subject: [PATCH 2/2] Aponta o PR da fatia 9 no README O numero do PR so existe depois de aberto. Autores: Manfred Heil Junior --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8070edb..741bfc4 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,7 @@ As regras do projeto estão no [`AGENTS.md`](AGENTS.md) e o desenho em [`docs/su | 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-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 | Classificação por tipo de equipamento, com os motivos à vista (sai como v0.5.0) | `classificacao` | [PREENCHER] | Em revisão | +| 9 | Classificação por tipo de equipamento, com os motivos à vista (sai como v0.5.0) | `classificacao` | [#35](https://github.com/manfredjr/mapnet/pull/35) | Em revisão | | 10 | Relatório também em XML e CSV (sai como 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).
FabricanteHosts
{C(h.Nome)}{C(h.MacTexto)}{C(h.Fabricante)}{C(classificacao.Tipo == TipoEquipamento.Desconhecido ? string.Empty : classificacao.Nome)}{(h.TempoPingMs.HasValue ? h.TempoPingMs.Value.ToString(_ptBr) : "-")} 0 ? ListaPortas.Texto(h.PortasAbertas) : null)}\">{C(h.PortasTexto)} s.Texto)))}\">{C(h.ServicoResumo)}{string.Concat(marcas.Select(m => $"{C(m)}"))}