Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<Version>0.4.0</Version>
<Version>0.5.0</Version>
<Authors>Manfred Heil Junior</Authors>
<Company>MT - Manfred Tecnologia</Company>
<Product>MapNet - MT</Product>
Expand Down
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | [#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).

Expand Down Expand Up @@ -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.
Expand Down
7 changes: 7 additions & 0 deletions docs/superpowers/pendencias.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
23 changes: 22 additions & 1 deletion docs/superpowers/plans/2026-09-26-mapnet-1.0-plano-geral.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
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.
2 changes: 1 addition & 1 deletion public/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ <h2>Por que ele existe</h2>
<h2>O que ele faz</h2>
<div class="itens">
<div><h3>Todos os equipamentos da sub-rede</h3><p>Por ping e por ARP. Quem bloqueia ping também aparece.</p></div>
<div><h3>Fabricante e nome</h3><p>Pelo MAC, na tabela do IEEE, e por DNS, NetBIOS e mDNS.</p></div>
<div><h3>Fabricante, nome e tipo</h3><p>Pelo MAC, na tabela do IEEE, por DNS, NetBIOS e mDNS, e um palpite do tipo: roteador, impressora, câmera, computador.</p></div>
<div><h3>De onde vem a rede</h3><p>DHCP, gateway, DNS, Wi-Fi e, com um clique, o IP público.</p></div>
<div><h3>Detalhe de cada equipamento</h3><p>Nomes, TTL, portas abertas, serviços e atalhos para ping, navegador e área de trabalho remota.</p></div>
<div><h3>Ferramentas de rede</h3><p>Ping, tracert, DNS, ARP, conexões e rotas, com a saída em português.</p></div>
Expand Down
214 changes: 214 additions & 0 deletions src/mapnet.nucleo/classificacao/classificador.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
namespace MapNet.Nucleo;

/// <summary>Tipo provável do equipamento. A ordem desempata: em pontuação igual, vale o que vem antes.</summary>
public enum TipoEquipamento
{
Desconhecido,
Roteador,
Impressora,
Camera,
Nas,
ComputadorWindows,
Linux,
AccessPoint,
TvMidia,
Console,
Celular,
Automacao,
}

/// <summary>Tipo provável e os sinais que levaram a ele, para o técnico conferir.</summary>
public sealed record Classificacao(TipoEquipamento Tipo, IReadOnlyList<string> Motivos)
{
public static readonly Classificacao Nenhuma = new(TipoEquipamento.Desconhecido, []);

public string Nome => Classificador.Nome(Tipo);

/// <summary>"Impressora (porta 9100 aberta, fabricante Brother)".</summary>
public string Texto => Tipo == TipoEquipamento.Desconhecido ? "não deu para saber" : $"{Nome} ({string.Join(", ", Motivos)})";
}

/// <summary>
/// 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.
/// </summary>
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<TipoEquipamento, int>();
var motivos = new Dictionary<TipoEquipamento, List<string>>();

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;
}

/// <summary>Palavra inteira, para "tv" não achar "tvbox" nem "nas" achar "nascimento".</summary>
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] + "...";
}
1 change: 1 addition & 0 deletions src/mapnet.nucleo/painel/detalhe-host.cs
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@ public static IReadOnlyList<ItemMinhaMaquina> 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"),
Expand Down
10 changes: 10 additions & 0 deletions src/mapnet.nucleo/painel/linha-host.cs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,15 @@ public LinhaHost(HostEncontrado host)

public string Observacao => string.Join(", ", _host.Marcas);

/// <summary>Tipo provável para a coluna. Vazio quando não deu para saber.</summary>
public string Tipo => _host.Classificacao is { Tipo: not TipoEquipamento.Desconhecido } c ? c.Nome : string.Empty;

/// <summary>Dica da coluna Tipo: os sinais que levaram ao palpite.</summary>
public string? TipoDica => _host.Classificacao is { Tipo: not TipoEquipamento.Desconhecido } c ? "Tipo provável: " + c.Texto : null;

/// <summary>Dica da coluna Nome: o nome e de onde ele veio.</summary>
public string? NomeDica => Nome.Length > 0 ? $"{Nome} (pelo {OrigemNome})" : null;

/// <summary>O texto mais útil que os serviços deram: modelo, título da página, certificado ou banner.</summary>
public string Servico => _host.ServicoResumo;

Expand Down Expand Up @@ -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);
}
Expand Down
Loading
Loading