Port Scanner TCP em Zig — Tutorial Passo a Passo

Port Scanner TCP em Zig — Tutorial Passo a Passo

Neste tutorial, vamos construir um scanner de portas TCP em Zig. O scanner tenta se conectar a portas de um host e reporta quais estão abertas. Este projeto nos ensina sobre programação de rede, timeouts e detecção de serviços.

O Que Vamos Construir

Nosso port scanner vai:

  • Escanear um range de portas TCP em um host
  • Implementar timeout configurável por conexão
  • Identificar serviços conhecidos (HTTP, SSH, FTP, etc.)
  • Exibir resultados formatados com estado de cada porta
  • Medir o tempo total do scan

Por Que Este Projeto?

Scanners de portas são ferramentas fundamentais para diagnóstico de rede e segurança. Construir um nos ensina como funcionam conexões TCP, handshakes, timeouts e o mapeamento de serviços a portas. O controle de baixo nível de Zig sobre sockets torna isso direto e eficiente.

Pré-requisitos

Passo 1: Estrutura do Projeto

mkdir port-scanner
cd port-scanner
zig init

Passo 2: Mapa de Serviços Conhecidos

Começamos com uma tabela que mapeia portas comuns aos seus serviços. Isso permite que o scanner identifique automaticamente o que provavelmente está rodando em cada porta aberta.

const std = @import("std");
const net = std.net;
const posix = std.posix;
const io = std.io;
const mem = std.mem;
const time = std.time;
const fmt = std.fmt;

/// Resultado do scan de uma porta individual.
const ResultadoPorta = struct {
    porta: u16,
    aberta: bool,
    servico: ?[]const u8,
    tempo_ms: u64,
};

/// Retorna o nome do serviço associado a uma porta conhecida.
fn servicoConhecido(porta: u16) ?[]const u8 {
    return switch (porta) {
        20 => "FTP (dados)",
        21 => "FTP (controle)",
        22 => "SSH",
        23 => "Telnet",
        25 => "SMTP",
        53 => "DNS",
        80 => "HTTP",
        110 => "POP3",
        143 => "IMAP",
        443 => "HTTPS",
        993 => "IMAPS",
        995 => "POP3S",
        3306 => "MySQL",
        5432 => "PostgreSQL",
        6379 => "Redis",
        8080 => "HTTP Alt",
        8443 => "HTTPS Alt",
        27017 => "MongoDB",
        else => null,
    };
}

Passo 3: Lógica de Scan

/// Configuração do scanner.
const ConfigScanner = struct {
    host: []const u8,
    porta_inicio: u16,
    porta_fim: u16,
    timeout_ms: u64,
};

/// Tenta conectar a uma porta específica com timeout.
/// Retorna true se a porta está aberta (aceita conexão).
fn escanearPorta(host: [4]u8, porta: u16, timeout_ns: u64) bool {
    const endereco = net.Address.initIp4(host, porta);

    // Criar socket TCP
    const socket = posix.socket(posix.AF.INET, posix.SOCK.STREAM | posix.SOCK.NONBLOCK, 0) catch return false;
    defer posix.close(socket);

    // Tentar conexão (não-bloqueante)
    _ = posix.connect(socket, &endereco.any, endereco.getOsSockLen()) catch |err| {
        if (err != error.WouldBlock) return false;
    };

    // Usar poll para esperar a conexão com timeout
    var pollfds = [_]posix.pollfd{.{
        .fd = socket,
        .events = posix.POLL.OUT,
        .revents = 0,
    }};

    const timeout_ms: i32 = @intCast(timeout_ns / time.ns_per_ms);
    const resultado = posix.poll(&pollfds, timeout_ms) catch return false;

    if (resultado == 0) return false; // timeout

    // Verificar se a conexão foi estabelecida
    if (pollfds[0].revents & posix.POLL.OUT != 0) {
        // Verificar erro do socket
        var err_code: i32 = 0;
        const err_bytes = mem.asBytes(&err_code);
        _ = posix.getsockopt(socket, posix.SOL.SOCKET, posix.SO.ERROR, err_bytes) catch return false;
        return err_code == 0;
    }

    return false;
}

/// Resolve um hostname para endereço IPv4.
fn resolverHost(host: []const u8) ?[4]u8 {
    // Tentar parsear como IP direto primeiro
    var ip: [4]u8 = undefined;
    var it = mem.splitScalar(u8, host, '.');
    var i: usize = 0;
    while (it.next()) |parte| {
        if (i >= 4) return null;
        ip[i] = fmt.parseInt(u8, parte, 10) catch return null;
        i += 1;
    }
    if (i == 4) return ip;
    return null;
}

Passo 4: Execução do Scan e Relatório

/// Executa o scan completo e retorna os resultados.
fn executarScan(
    config: ConfigScanner,
    allocator: mem.Allocator,
    writer: anytype,
) !std.ArrayList(ResultadoPorta) {
    var resultados = std.ArrayList(ResultadoPorta).init(allocator);

    const ip = resolverHost(config.host) orelse {
        try writer.print("Erro: nao foi possivel resolver '{s}'\n", .{config.host});
        return resultados;
    };

    const total_portas = @as(u32, config.porta_fim) - config.porta_inicio + 1;
    try writer.print("\nEscaneando {s} ({d}.{d}.{d}.{d})\n", .{
        config.host, ip[0], ip[1], ip[2], ip[3],
    });
    try writer.print("Portas: {d}-{d} ({d} portas)\n", .{
        config.porta_inicio, config.porta_fim, total_portas,
    });
    try writer.print("Timeout: {d}ms\n\n", .{config.timeout_ms});

    const inicio_total = time.milliTimestamp();
    var portas_abertas: u32 = 0;

    var porta: u32 = config.porta_inicio;
    while (porta <= config.porta_fim) : (porta += 1) {
        const p: u16 = @intCast(porta);
        const inicio = time.milliTimestamp();
        const aberta = escanearPorta(ip, p, config.timeout_ms * time.ns_per_ms);
        const duracao: u64 = @intCast(time.milliTimestamp() - inicio);

        if (aberta) {
            const servico = servicoConhecido(p);
            try resultados.append(.{
                .porta = p,
                .aberta = true,
                .servico = servico,
                .tempo_ms = duracao,
            });
            portas_abertas += 1;

            // Exibir imediatamente quando encontra porta aberta
            if (servico) |s| {
                try writer.print("  ABERTA  {d:>5}/tcp  {s}  ({d}ms)\n", .{ p, s, duracao });
            } else {
                try writer.print("  ABERTA  {d:>5}/tcp  desconhecido  ({d}ms)\n", .{ p, duracao });
            }
        }

        // Barra de progresso simples
        if (porta % 100 == 0 or porta == config.porta_fim) {
            const progresso = (porta - config.porta_inicio) * 100 / total_portas;
            try writer.print("\r  Progresso: {d}%", .{progresso});
        }
    }

    const duracao_total: u64 = @intCast(time.milliTimestamp() - inicio_total);

    try writer.print("\r                              \r", .{}); // limpar progresso
    try writer.print(
        \\
        \\--- Relatorio ---
        \\  Host:           {s}
        \\  Portas testadas: {d}
        \\  Portas abertas:  {d}
        \\  Tempo total:     {d}ms ({d:.1}s)
        \\
    , .{
        config.host,
        total_portas,
        portas_abertas,
        duracao_total,
        @as(f64, @floatFromInt(duracao_total)) / 1000.0,
    });

    return resultados;
}

Passo 5: Função Main com CLI

pub fn main() !void {
    var gpa = std.heap.GeneralPurposeAllocator(.{}){};
    defer _ = gpa.deinit();
    const allocator = gpa.allocator();

    const stdout = io.getStdOut().writer();
    const args = try std.process.argsAlloc(allocator);
    defer std.process.argsFree(allocator, args);

    // Valores padrão
    var config = ConfigScanner{
        .host = "127.0.0.1",
        .porta_inicio = 1,
        .porta_fim = 1024,
        .timeout_ms = 200,
    };

    // Parsing de argumentos
    if (args.len < 2) {
        try stdout.print(
            \\
            \\  ==========================================
            \\     PORT SCANNER - Zig
            \\  ==========================================
            \\  Uso: portscan <host> [porta_inicio] [porta_fim] [timeout_ms]
            \\
            \\  Exemplos:
            \\    portscan 127.0.0.1
            \\    portscan 192.168.1.1 1 1024
            \\    portscan 10.0.0.1 80 443 500
            \\  ==========================================
            \\
            \\  Executando scan padrao em localhost (1-1024)...
            \\
        , .{});
    } else {
        config.host = args[1];
        if (args.len >= 3) {
            config.porta_inicio = try fmt.parseInt(u16, args[2], 10);
        }
        if (args.len >= 4) {
            config.porta_fim = try fmt.parseInt(u16, args[3], 10);
        }
        if (args.len >= 5) {
            config.timeout_ms = try fmt.parseInt(u64, args[4], 10);
        }
    }

    // Validação
    if (config.porta_inicio > config.porta_fim) {
        try stdout.print("Erro: porta_inicio ({d}) > porta_fim ({d})\n", .{
            config.porta_inicio, config.porta_fim,
        });
        return;
    }

    var resultados = try executarScan(config, allocator, stdout);
    defer resultados.deinit();

    // Resumo final das portas abertas
    if (resultados.items.len > 0) {
        try stdout.print("\n  Portas abertas encontradas:\n", .{});
        try stdout.print("  {s:<8} {s:<12} {s:<20} {s}\n", .{ "ESTADO", "PORTA", "SERVICO", "TEMPO" });
        try stdout.print("  {s:-<50}\n", .{""});
        for (resultados.items) |r| {
            try stdout.print("  {s:<8} {d:<12}/tcp {s:<20} {d}ms\n", .{
                "ABERTA", r.porta, r.servico orelse "desconhecido", r.tempo_ms,
            });
        }
    }
}

Testes

test "servico conhecido - HTTP" {
    try std.testing.expectEqualStrings("HTTP", servicoConhecido(80).?);
}

test "servico conhecido - SSH" {
    try std.testing.expectEqualStrings("SSH", servicoConhecido(22).?);
}

test "servico desconhecido" {
    try std.testing.expect(servicoConhecido(12345) == null);
}

test "resolver IP valido" {
    const ip = resolverHost("127.0.0.1");
    try std.testing.expect(ip != null);
    try std.testing.expectEqual(@as(u8, 127), ip.?[0]);
    try std.testing.expectEqual(@as(u8, 0), ip.?[1]);
    try std.testing.expectEqual(@as(u8, 0), ip.?[2]);
    try std.testing.expectEqual(@as(u8, 1), ip.?[3]);
}

test "resolver IP invalido" {
    try std.testing.expect(resolverHost("nao.um.ip.valido") == null);
    try std.testing.expect(resolverHost("256.1.1.1") == null);
}

Compilando e Executando

# Compilar e executar scan padrão
zig build run

# Escanear host específico
zig build run -- 192.168.1.1

# Escanear range de portas
zig build run -- 127.0.0.1 80 443

# Com timeout customizado (em ms)
zig build run -- 10.0.0.1 1 65535 100

# Rodar testes
zig build test

Conceitos Aprendidos

  • Sockets TCP com conexão não-bloqueante
  • Poll para implementar timeout de conexão
  • Switch expressions para mapeamento de dados
  • Formatação de saída tabulada
  • Medição de tempo com std.time

Próximos Passos

Continue aprendendo Zig

Explore mais tutoriais e artigos em português para dominar a linguagem Zig.