Недавно знакомый попросил помочь с небольшой задачей по проверке внешнего периметра сети компании. Сразу уточню: речь шла об инфраструктуре, на проверку которой было разрешение.

Под внешним периметром обычно понимают всё, что доступно из интернета: публичные IP-адреса, домены, поддомены, облачные или VPS-серверы, а также сервисы, которые слушают внешние порты.

Задача была простой по формулировке, но интересной технически: нужно понять, какие адреса доступны извне и к каким портам можно подключиться.

Что мы будем делать

В данной статье я покажу, как сделать простой TCP port scanner на Go.

Он будет уметь:

  • Читать IP-адреса и домены из файла

  • Проверять диапазон портов

  • Определять открытые порты и добавлять к ним условную оценку риска

  • Сразу реализуем ограничение параллельности через семафор, чтобы обработка портов была быстрее

Структура проекта и сам код

Проект небольшой, поэтому структура получилась простой. Я разделил код на несколько пакетов, чтобы каждая часть отвечала за свою задачу.

cmd/
  bin/
    main.go

internal/
  input/
    input.go
  report/
    csv.go
  resolver/
    resolver.go
  scanner/
    scanner.go
  services/
    services.go

perimeter.txt
go.mod
go.sum

Коротко пройдёмся по пакетам внутри internal и разберём, за что отвечает каждый из них. Input - отвечает за чтения файла и возвращения массива string с нашими портами:

func ReadTargets(path string) ([]string, error) {
	file, err := os.Open(path)
	if err != nil {
		return nil, err
	}
	defer file.Close()

	return ParseTargets(file)
}

func ParseTargets(reader io.Reader) ([]string, error) {
	targets := make([]string, 0)

	scanner := bufio.NewScanner(reader)

	for scanner.Scan() {
		text := strings.TrimSpace(scanner.Text())
		if text == "" || strings.HasPrefix(text, "#") {
			continue
		}
		targets = append(targets, text)
	}

	if err := scanner.Err(); err != nil {
		return nil, err
	}

	return targets, nil
}

Здесь всё просто: открываем файл, читаем его построчно через bufio.Scanner, пропускаем пустые строки и комментарии, а остальные значения возвращаем как список целей.

Services - данный пакет отвечает за справочную информацию о сервисах по номеру порта:

package services

type Info struct {
	Name string
	Risk string
}

func Lookup(port int) Info {
	switch port {
	case 22:
		return Info{Name: "SSH", Risk: "High"}
	case 80:
		return Info{Name: "HTTP", Risk: "Medium"}
	case 443:
		return Info{Name: "HTTPS", Risk: "Low"}
	case 3306:
		return Info{Name: "MySQL", Risk: "High"}
	case 3389:
		return Info{Name: "RDP", Risk: "High"}
	case 5432:
		return Info{Name: "PostgreSQL", Risk: "High"}
	case 6379:
		return Info{Name: "Redis", Risk: "High"}
	default:
		return Info{Name: "Unknown", Risk: "Unknown"}
	}
}

Lookup не делает fingerprint сервиса. Он просто подсказывает наиболее вероятный сервис по номеру порта.

Структура Info - хранит в себе Name - это названия сервиса, например SSH или HTTP. А Risk - это условный уровень риска (Low, Medium, High, Unknown).

Функция Lookup - получает порт смотрит к какому сервису он относиться и возвращает нам нашу структуру. Тоже довольно просто.

Далее нам в пакете Scanner - надо описать структуру Result в которой как у нас будет вся нужная нам информация:

package scanner

type Result struct {
	Target       string
	IP           string
	Port         int
	Protocol     string
	ServiceGuess string
	Status       string
	Risk         string
	Error        string
}

Данная структура просто формат ответа: какой домен/IP проверяли, какой порт, открыт он или закрыт, какой сервис, какой риск, была ли ошибка.

Далее по списку нужно сделать функцию которая будем превращать домены в IP адреса и это функция будет лежать у нас в пакете resolver:

package resolver

import "net"

func ResolveTarget(target string) ([]string, error) {
	parsedIP := net.ParseIP(target)
	if parsedIP != nil {
		if parsedIP.To4() == nil {
			return nil, nil
		}
		return []string{parsedIP.String()}, nil
	}

	ips, err := net.LookupIP(target)
	if err != nil {
		return nil, err
	}

	targets := make([]string, 0)
	for _, ip := range ips {
		if ip.To4() != nil {
			targets = append(targets, ip.String())
		}
	}

	return targets, nil
}

Что здесь происходит, наша функция ResolveTarget принимает наши "Цели" - и смотрит является ли они IP адресами, если нет преобразует в IP адрес и возвращает.

ResolveTarget принимает строку из файла. Если это уже IPv4-адрес, функция сразу возвращает его. Если это домен, она делает DNS-lookup через net.LookupIP и возвращает найденные IPv4-адреса.

Теперь вернемся к нашему пакет Scanner - тут мы должны описать функцию ScanPort, сначала покажу а потом объясню:

func ScanPort(target string, ip string, port int, timeout time.Duration) Result {
	info := services.Lookup(port)

	result := Result{
		Target:       target,
		IP:           ip,
		Port:         port,
		Protocol:     "tcp",
		ServiceGuess: info.Name,
		Risk:         info.Risk,
	}

	address := net.JoinHostPort(ip, strconv.Itoa(port))

	conn, err := net.DialTimeout("tcp", address, timeout)
	if err != nil {
		if netErr, ok := err.(net.Error); ok && netErr.Timeout() {
			result.Status = "filtered"
			result.Error = netErr.Error()
			return result
		}

		result.Status = "closed"
		result.Error = err.Error()
		return result
	}

	conn.Close()
	result.Status = "open"
	return result
}

Функция выглядит по сложней чем другие, но уверяю вас тут все легко.

ScanPort получает цель, IP, порт и timeout. Сначала мы получаем информацию о предполагаемом сервисе через services.Lookup. Затем собираем адрес через net.JoinHostPort — это безопаснее, чем склеивать ip + ":" + port вручную.

После этого вызываем net.DialTimeout. Если соединение удалось, считаем порт открытым. Если произошла ошибка, считаем порт закрытым. Если ошибка связана с timeout, помечаем статус как filtered.

Статус filtered здесь условный: я использую его для случаев, когда соединение не было явно отклонено, а завершилось по timeout.

Ну и если ошибки не было просто говорим что статус = открыто и возвращаем на результат.

Последний технический кусок — пакет report. Он отвечает за сохранение результатов в CSV-файл.

package report

import (
	"encoding/csv"
	"io"
	"os"
	"perimeter-audit/internal/scanner"
	"strconv"
)

func WriteCSV(results []scanner.Result, path string) error {
	file, err := os.Create(path)
	if err != nil {
		return err
	}
	defer file.Close()

	return WriteCSVWriter(file, results)
}

func WriteCSVWriter(writer io.Writer, results []scanner.Result) error {
	csvWriter := csv.NewWriter(writer)

	err := csvWriter.Write([]string{"target", "ip", "port", "protocol", "service_guess", "status", "risk", "error"})
	if err != nil {
		return err
	}

	for _, result := range results {
		if err := csvWriter.Write([]string{result.Target, result.IP, strconv.Itoa(result.Port), result.Protocol, result.ServiceGuess, result.Status, result.Risk, result.Error}); err != nil {
			return err
		}
	}

	csvWriter.Flush()

	if err := csvWriter.Error(); err != nil {
		return err
	}

	return nil
}

Тут у нас report сохраняет результаты сканирования в CSV-файл: создает файл, записывает заголовки колонок и добавляет по строке на каждый результат.

Теперь осталось связать все части в main.go: прочитать путь к файлу через флаг -input, загрузить цели, просканировать их и сохранить результат в CSV.

package main

var defaultPorts = []int{21, 22, 23, 25, 53, 80, 110, 143, 443, 445, 1433, 3306, 3389, 5432, 5900, 6379, 8080, 8443, 9200, 27017}

func main() {
	inputFlag := flag.String("input", "", "path to targets file")
	outputFlag := flag.String("output", "report.csv", "path to CSV report")
	flag.Parse()

	if *inputFlag == "" {
		fmt.Fprintln(os.Stderr, "input flag is required")
		os.Exit(1)
	}

	targets, err := input.ReadTargets(*inputFlag)
	if err != nil {
		fmt.Fprintf(os.Stderr, "Error reading targets: %v\n", err)
		os.Exit(1)
	}

	results := scanTargets(targets, defaultPorts, 2*time.Second, 50)
	if err := report.WriteCSV(results, *outputFlag); err != nil {
		fmt.Fprintf(os.Stderr, "Error writing report: %v\n", err)
		os.Exit(1)
	}

	fmt.Printf("Results written to %s\n", *outputFlag)
}

func scanTargets(targets []string, ports []int, timeout time.Duration, maxConcurrency int) []scanner.Result {
	resultCh := make(chan scanner.Result)
	var wg sync.WaitGroup
	sem := make(chan struct{}, maxConcurrency)
	results := make([]scanner.Result, 0)

	for _, target := range targets {
		ips, err := resolver.ResolveTarget(target)
		if err != nil {
			results = append(results, scanner.Result{
				Target: target,
				Status: "error",
				Error:  err.Error(),
			})
			continue
		}

		for _, ip := range ips {
			for _, port := range ports {
				wg.Add(1)
				go func(target string, ip string, port int) {
					defer wg.Done()

					sem <- struct{}{}
					defer func() {
						<-sem
					}()

					resultCh <- scanner.ScanPort(target, ip, port, timeout)
				}(target, ip, port)
			}
		}
	}

	go func() {
		wg.Wait()
		close(resultCh)
	}()

	for result := range resultCh {
		results = append(results, result)
	}

	sort.Slice(results, func(i int, j int) bool {
		if results[i].Target != results[j].Target {
			return results[i].Target < results[j].Target
		}
		if results[i].IP != results[j].IP {
			return results[i].IP < results[j].IP
		}
		return results[i].Port < results[j].Port
	})

	return results
}

В main программа просто управляет всем процессом: берет путь к файлу с целями, читает эти цели, запускает сканирование, а потом сохраняет результат в CSV-файл.

Если по шагам, то получается так: сначала проверяем, что пользователь передал -input, потом читаем список доменов или IP из файла, дальше для каждой цели получаем IP- адреса, проверяем нужные порты и в конце записываем все найденное в отчет.

Семафор здесь нужен как ограничитель. Мы запускаем много проверок портов параллельно, но не хотим, чтобы их одновременно было слишком много. Поэтому семафором говорим: “одновременно можно выполнять максимум 50 проверок”. Когда одна проверка закончилась, она освобождает место, и запускается следующая.

Отдельно я разобрал работу семафора на схеме и пошаговом примере — ссылку оставлю в конце статьи.

По сути, семафор защищает программу от ситуации, когда она сама себя перегрузит слишком большим количеством сетевых подключений.

В итоге получился небольшой TCP-сканер, который читает список целей из файла, резолвит домены в IP-адреса, проверяет набор портов с ограничением параллельности и сохраняет результат в CSV. Проект небольшой, но на нём хорошо видно, как в Go можно работать с сетью, timeout, goroutine, WaitGroup и семафором.

Ещё раз: такой инструмент стоит использовать только для своей инфраструктуры или с разрешения владельца.

Дополнительно: схема работы семафора на примере этой программы — https://t.me/walkerinit