diff --git a/internal/scans/cidr_parse.go b/internal/scans/cidr_parse.go new file mode 100644 index 0000000..9f0f36f --- /dev/null +++ b/internal/scans/cidr_parse.go @@ -0,0 +1,119 @@ +package scans + +import ( + "encoding/binary" + "errors" + "fmt" + "net" + "regexp" + "strings" +) + +// maxCIDRSExpanded — ограничение на число подсетей после раскрытия диапазонов (защита от опечаток). +const maxCIDRSExpanded = 16384 + +var cidrRangeRE = regexp.MustCompile(`^\s*(?P.+?/\d+)\s*-\s*(?P.+?/\d+)\s*$`) + +// expandCIDRSFromRequest раскрывает каждую строку: один CIDR или диапазон "start/end mask - end/end mask". +func expandCIDRSFromRequest(inputs []string) ([]string, error) { + var all []string + for _, raw := range inputs { + raw = strings.TrimSpace(raw) + if raw == "" { + continue + } + part, err := expandCIDREntry(raw) + if err != nil { + return nil, err + } + all = append(all, part...) + } + if len(all) == 0 { + return nil, errors.New("cidrs must not be empty") + } + seen := make(map[string]struct{}, len(all)) + var out []string + for _, c := range all { + if _, ok := seen[c]; ok { + continue + } + seen[c] = struct{}{} + out = append(out, c) + } + if len(out) > maxCIDRSExpanded { + return nil, fmt.Errorf("слишком много подсетей после раскрытия (%d), максимум %d", len(out), maxCIDRSExpanded) + } + return out, nil +} + +func expandCIDREntry(s string) ([]string, error) { + if m := cidrRangeRE.FindStringSubmatch(s); m != nil { + left, right := strings.TrimSpace(m[1]), strings.TrimSpace(m[2]) + return expandCIDRRange(left, right) + } + if _, _, err := net.ParseCIDR(strings.TrimSpace(s)); err != nil { + return nil, fmt.Errorf("некорректный CIDR: %w", err) + } + return []string{strings.TrimSpace(s)}, nil +} + +func expandCIDRRange(a, b string) ([]string, error) { + _, na, err := net.ParseCIDR(a) + if err != nil { + return nil, fmt.Errorf("начало диапазона: %w", err) + } + _, nb, err := net.ParseCIDR(b) + if err != nil { + return nil, fmt.Errorf("конец диапазона: %w", err) + } + + bitsA, _ := na.Mask.Size() + bitsB, _ := nb.Mask.Size() + if bitsA != bitsB { + return nil, fmt.Errorf("маски начала и конца должны совпадать (/ %d и / %d)", bitsA, bitsB) + } + ones := bitsA + if ones < 0 || ones > 32 { + return nil, errors.New("диапазон сетей поддерживается только для IPv4 с маской /0–/32") + } + + ipA := na.IP.To4() + ipB := nb.IP.To4() + if ipA == nil || ipB == nil { + return nil, errors.New("диапазон вида CIDR–CIDR пока поддерживается только для IPv4") + } + + netA := ipv4NetworkBase(ipA, na.Mask) + netB := ipv4NetworkBase(ipB, nb.Mask) + sa := binary.BigEndian.Uint32(netA) + sb := binary.BigEndian.Uint32(netB) + if sb < sa { + return nil, errors.New("конечная сеть в диапазоне раньше начальной") + } + + var step uint64 = 1 << (32 - uint(ones)) + if ones == 32 { + step = 1 + } + if uint64(sa)%step != 0 || uint64(sb)%step != 0 { + return nil, errors.New("границы диапазона должны быть адресами сетей (кратны размеру подсети)") + } + + var out []string + for cur := uint64(sa); cur <= uint64(sb); cur += step { + if len(out) >= maxCIDRSExpanded { + return nil, fmt.Errorf("диапазон даёт больше %d подсетей", maxCIDRSExpanded) + } + ip := make(net.IP, 4) + binary.BigEndian.PutUint32(ip, uint32(cur)) + out = append(out, fmt.Sprintf("%s/%d", ip.String(), ones)) + } + return out, nil +} + +func ipv4NetworkBase(ip net.IP, mask net.IPMask) net.IP { + n := ip.Mask(mask) + out := make(net.IP, 4) + copy(out, n.To4()) + return out +} diff --git a/internal/scans/cidr_parse_test.go b/internal/scans/cidr_parse_test.go new file mode 100644 index 0000000..2c341f5 --- /dev/null +++ b/internal/scans/cidr_parse_test.go @@ -0,0 +1,55 @@ +package scans + +import ( + "testing" +) + +func TestExpandCIDRRangeContiguous24(t *testing.T) { + out, err := expandCIDRRange("192.168.160.0/24", "192.168.190.0/24") + if err != nil { + t.Fatal(err) + } + if len(out) != 31 { + t.Fatalf("ожидали 31 подсеть /24, получили %d", len(out)) + } + if out[0] != "192.168.160.0/24" || out[len(out)-1] != "192.168.190.0/24" { + t.Fatalf("границы: первый %q последний %q", out[0], out[len(out)-1]) + } +} + +func TestExpandCIDRRangeSingle(t *testing.T) { + out, err := expandCIDRRange("10.0.0.0/16", "10.0.0.0/16") + if err != nil { + t.Fatal(err) + } + if len(out) != 1 || out[0] != "10.0.0.0/16" { + t.Fatalf("получили %v", out) + } +} + +func TestExpandCIDRRangeMaskMismatch(t *testing.T) { + _, err := expandCIDRRange("10.0.0.0/24", "10.0.1.0/16") + if err == nil { + t.Fatal("ожидали ошибку при разных масках") + } +} + +func TestExpandCIDREntryRangeWithSpaces(t *testing.T) { + out, err := expandCIDREntry(" 192.168.1.0/24 - 192.168.3.0/24 ") + if err != nil { + t.Fatal(err) + } + if len(out) != 3 { + t.Fatalf("получили %d: %v", len(out), out) + } +} + +func TestExpandCIDRSFromRequestDedup(t *testing.T) { + out, err := expandCIDRSFromRequest([]string{"192.168.1.0/24", "192.168.1.0/24"}) + if err != nil { + t.Fatal(err) + } + if len(out) != 1 { + t.Fatalf("дедуп: %v", out) + } +} diff --git a/internal/scans/postgres_store.go b/internal/scans/postgres_store.go index 8d80bf5..1c13e53 100644 --- a/internal/scans/postgres_store.go +++ b/internal/scans/postgres_store.go @@ -18,7 +18,7 @@ func NewPostgresStore(db *sql.DB) *PostgresStore { } func (s *PostgresStore) CreateScan(req CreateScanRequest) (ScanJob, error) { - if err := validateCreateScanRequest(req); err != nil { + if err := validateCreateScanRequest(&req); err != nil { return ScanJob{}, err } applyDefaultOptions(&req.Options) diff --git a/internal/scans/store.go b/internal/scans/store.go index 306e07e..07e9674 100644 --- a/internal/scans/store.go +++ b/internal/scans/store.go @@ -121,7 +121,7 @@ func NewMemoryStore() *MemoryStore { } func (s *MemoryStore) CreateScan(req CreateScanRequest) (ScanJob, error) { - if err := validateCreateScanRequest(req); err != nil { + if err := validateCreateScanRequest(&req); err != nil { return ScanJob{}, err } @@ -340,16 +340,16 @@ func applyDefaultOptions(opts *ScanOptions) { } } -func validateCreateScanRequest(req CreateScanRequest) error { +func validateCreateScanRequest(req *CreateScanRequest) error { if len(req.CIDRs) == 0 { return errors.New("cidrs must not be empty") } - for _, cidr := range req.CIDRs { - if _, _, err := net.ParseCIDR(cidr); err != nil { - return errors.New("invalid cidr: " + cidr) - } + expanded, err := expandCIDRSFromRequest(req.CIDRs) + if err != nil { + return err } + req.CIDRs = expanded for _, ip := range req.ExcludeIPs { if net.ParseIP(ip) == nil { diff --git a/internal/webui/index.html b/internal/webui/index.html index ba5a91d..d62b4be 100644 --- a/internal/webui/index.html +++ b/internal/webui/index.html @@ -35,7 +35,7 @@ Один scan = один полный проход по сети. Для каждого IP из CIDR (с исключениями): ping → для ответивших — проверка TCP-портов → если включён SNMP — опрос системы (sysName и др.) и сразу обход LLDP-MIB (соседи по портам). Отдельной кнопки «только LLDP» нет: соседи попадают в этот же цикл. Кнопка «Создать scan» ставит задачу и сама ждёт завершения, показывая прогресс в строке статуса ниже.
- +
@@ -793,7 +793,7 @@ async function createScan() { const cidr = cidrInput.value.trim(); if (!cidr) { - setStatus("Введите CIDR (подсеть для сканирования)."); + setStatus("Введите CIDR или диапазон подсетей (IPv4), например 192.168.1.0/24 или 192.168.160.0/24 - 192.168.190.0/24."); return; } setStatus("Отправка запроса на сервер: создание задачи scan…");