feat(scan): диапазон IPv4-подсетей CIDR–CIDR в поле целей (нормализация в список)

Made-with: Cursor
This commit is contained in:
Луценко Андрей Анатольевич
2026-04-10 15:02:19 +10:00
parent 29bd136cea
commit 009aeb5dbf
5 changed files with 183 additions and 9 deletions
+119
View File
@@ -0,0 +1,119 @@
package scans
import (
"encoding/binary"
"errors"
"fmt"
"net"
"regexp"
"strings"
)
// maxCIDRSExpanded — ограничение на число подсетей после раскрытия диапазонов (защита от опечаток).
const maxCIDRSExpanded = 16384
var cidrRangeRE = regexp.MustCompile(`^\s*(?P<left>.+?/\d+)\s*-\s*(?P<right>.+?/\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
}
+55
View File
@@ -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)
}
}
+1 -1
View File
@@ -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)
+6 -6
View File
@@ -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 {
+2 -2
View File
@@ -35,7 +35,7 @@
<strong>Один scan = один полный проход по сети.</strong> Для каждого IP из CIDR (с исключениями): <strong>ping</strong> → для ответивших — проверка <strong>TCP-портов</strong> → если включён SNMP — опрос <strong>системы (sysName и др.)</strong> и сразу обход <strong>LLDP-MIB</strong> (соседи по портам). Отдельной кнопки «только LLDP» нет: соседи попадают в этот же цикл. Кнопка «Создать scan» ставит задачу и сама ждёт завершения, показывая прогресс в строке статуса ниже.
</div>
<div class="row">
<input id="cidrInput" placeholder="CIDR, например 192.168.1.0/24" style="min-width: 320px;" />
<input id="cidrInput" placeholder="CIDR или диапазон: 192.168.160.0/24 - 192.168.190.0/24" style="min-width: 420px;" />
<button id="createScanBtn">Создать scan и ждать</button>
<button id="watchScanBtn">Ждать завершения (текущий scan_id)</button>
</div>
@@ -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…");