From 284794ec8d7e4fcb595b75d0ed2c6608910362a0 Mon Sep 17 00:00:00 2001 From: Andrey Lutsenko Date: Thu, 9 Apr 2026 21:40:04 +1000 Subject: [PATCH] Initial scaffold and technical specification Made-with: Cursor --- .env.example | 1 + README.md | 104 +++++++++++++++++++++++++++++++ TECH_SPEC.md | 90 +++++++++++++++++++++++++++ cmd/server/main.go | 26 ++++++++ deploy/nettopo-go.service | 15 +++++ deploy/web.config.example | 13 ++++ go.mod | 3 + internal/api/handler.go | 89 ++++++++++++++++++++++++++ internal/config/config.go | 18 ++++++ internal/scans/store.go | 128 ++++++++++++++++++++++++++++++++++++++ 10 files changed, 487 insertions(+) create mode 100644 .env.example create mode 100644 README.md create mode 100644 TECH_SPEC.md create mode 100644 cmd/server/main.go create mode 100644 deploy/nettopo-go.service create mode 100644 deploy/web.config.example create mode 100644 go.mod create mode 100644 internal/api/handler.go create mode 100644 internal/config/config.go create mode 100644 internal/scans/store.go diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b1ad038 --- /dev/null +++ b/.env.example @@ -0,0 +1 @@ +HTTP_ADDR=:8080 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f4d300e --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +# nettopo-go + +Минимальный старт проекта сетового инвентаризатора и топологии на Go без Docker. + +## Что уже есть + +- `GET /health` - проверка состояния API. +- `POST /api/scans` - создание задания скана. +- `GET /api/scans/{id}` - просмотр созданного задания. +- Валидация CIDR и exclude IP. +- Хранилище в памяти (для старта). БД подключим следующим шагом. + +## Требования + +- Go 1.22+ + +Проверка версии: + +```bash +go version +``` + +## Запуск локально + +1. Перейдите в проект: + +```bash +cd nettopo-go +``` + +2. Скопируйте переменные окружения: + +```bash +cp .env.example .env +``` + +3. Запустите API: + +```bash +HTTP_ADDR=:8080 go run ./cmd/server +``` + +## Быстрая проверка API + +Проверка health: + +```bash +curl -s http://localhost:8080/health +``` + +Создать scan: + +```bash +curl -s -X POST http://localhost:8080/api/scans \ + -H "Content-Type: application/json" \ + -d '{ + "name":"Office scan", + "cidrs":["192.168.1.0/24"], + "exclude_ips":["192.168.1.1"], + "options":{ + "ping_timeout_ms":700, + "ping_retries":1, + "max_parallel_hosts":128, + "port_scan_enabled":true, + "ports":[22,80,443,161] + } + }' +``` + +Получить scan по id: + +```bash +curl -s http://localhost:8080/api/scans/ +``` + +## Запуск как сервис на Linux (systemd) + +1. Собрать бинарник: + +```bash +go build -o nettopo-server ./cmd/server +``` + +2. Скопировать unit-файл: + +```bash +sudo cp deploy/nettopo-go.service /etc/systemd/system/ +sudo systemctl daemon-reload +sudo systemctl enable --now nettopo-go +``` + +3. Проверить статус: + +```bash +systemctl status nettopo-go +``` + +## Размещение за IIS на Windows + +- Запускать `nettopo-server.exe` как Windows Service. +- На IIS настроить Reverse Proxy к `http://localhost:8080`. +- Требуются URL Rewrite + ARR. + +Следующим шагом добавим готовый шаблон `web.config` для IIS и миграции PostgreSQL. diff --git a/TECH_SPEC.md b/TECH_SPEC.md new file mode 100644 index 0000000..e90f1d2 --- /dev/null +++ b/TECH_SPEC.md @@ -0,0 +1,90 @@ +# Техническое задание (v1) + +## Цель + +Создать кроссплатформенное веб-приложение на Go для: + +- обнаружения устройств в сети; +- базовой идентификации устройств; +- получения LLDP-соседей; +- построения карты связей между устройствами. + +## Платформы и запуск + +- Backend: Go 1.22+ +- ОС: Linux и Windows +- Запуск: стандартными средствами (без Docker) +- Reverse proxy: Nginx (Linux) или IIS (Windows, опционально) + +## Функциональные требования v1 + +- ручной запуск скана по заданным CIDR; +- ping sweep по диапазонам; +- проверка базовых TCP-портов (`22, 80, 443, 161`); +- SNMP v2c: `sysName`, `sysDescr`, `sysObjectID`; +- сбор LLDP соседей по SNMP; +- сохранение результатов скана в БД; +- построение графа связей устройств; +- история сканов и сравнение изменений. + +## Нефункциональные требования + +- кроссплатформенная сборка (Linux/Windows); +- устойчивость к частичным ошибкам в сети; +- конфигурируемые таймауты и параллелизм; +- разграничение прав (`admin`, `viewer`); +- логирование ошибок и действий. + +## Архитектура + +- `cmd/server` — запуск HTTP API +- `internal/api` — обработчики и роуты +- `internal/scans` — логика создания/ведения scan jobs +- `internal/scanner/*` — ping, ports, snmp, lldp (поэтапно) +- `internal/store` — PostgreSQL-репозиторий +- `web` — интерфейс (следующий этап) + +## Минимальные API + +- `GET /health` +- `POST /api/scans` +- `GET /api/scans/{id}` +- `GET /api/topology` +- `GET /api/topology/diff?from=&to=` + +## Модель данных (минимум) + +- `users` +- `snmp_credentials` +- `scan_jobs` +- `devices` +- `open_ports` +- `lldp_neighbors` +- `links` + +## SNMP OID минимум + +- `1.3.6.1.2.1.1.5.0` (`sysName`) +- `1.3.6.1.2.1.1.1.0` (`sysDescr`) +- `1.3.6.1.2.1.1.2.0` (`sysObjectID`) +- `1.0.8802.1.1.2.1.4.1.1.5` (`lldpRemChassisId`) +- `1.0.8802.1.1.2.1.4.1.1.7` (`lldpRemPortId`) +- `1.0.8802.1.1.2.1.4.1.1.9` (`lldpRemSysName`) + +## Этапы + +1. Каркас API и scan jobs +2. PostgreSQL и миграции +3. Ping + port scan +4. SNMP system + LLDP +5. Граф топологии в UI +6. История и diff + +## Критерии приемки v1 + +- скан по CIDR создается и завершается; +- живые узлы фиксируются в результатах; +- SNMP-доступные устройства читаются; +- LLDP-связи строятся минимум на тестовой сети; +- топология отображается в интерфейсе; +- приложение запускается на Linux и Windows. diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..36f4f50 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,26 @@ +package main + +import ( + "log" + "net/http" + + "nettopo-go/internal/api" + "nettopo-go/internal/config" + "nettopo-go/internal/scans" +) + +func main() { + cfg := config.FromEnv() + store := scans.NewMemoryStore() + handler := api.NewHandler(store) + + server := &http.Server{ + Addr: cfg.HTTPAddr, + Handler: handler.Routes(), + } + + log.Printf("nettopo API is starting on %s", cfg.HTTPAddr) + if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("server failed: %v", err) + } +} diff --git a/deploy/nettopo-go.service b/deploy/nettopo-go.service new file mode 100644 index 0000000..be59b16 --- /dev/null +++ b/deploy/nettopo-go.service @@ -0,0 +1,15 @@ +[Unit] +Description=nettopo-go API service +After=network.target + +[Service] +Type=simple +User=www-data +WorkingDirectory=/opt/nettopo-go +Environment=HTTP_ADDR=:8080 +ExecStart=/opt/nettopo-go/nettopo-server +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/web.config.example b/deploy/web.config.example new file mode 100644 index 0000000..5b7cb02 --- /dev/null +++ b/deploy/web.config.example @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0f64359 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module nettopo-go + +go 1.22 diff --git a/internal/api/handler.go b/internal/api/handler.go new file mode 100644 index 0000000..68d1269 --- /dev/null +++ b/internal/api/handler.go @@ -0,0 +1,89 @@ +package api + +import ( + "encoding/json" + "net/http" + "strings" + "time" + + "nettopo-go/internal/scans" +) + +type Handler struct { + store scans.Store +} + +func NewHandler(store scans.Store) *Handler { + return &Handler{store: store} +} + +func (h *Handler) Routes() http.Handler { + mux := http.NewServeMux() + mux.HandleFunc("GET /health", h.health) + mux.HandleFunc("POST /api/scans", h.createScan) + mux.HandleFunc("GET /api/scans/{id}", h.getScan) + return withJSON(mux) +} + +func (h *Handler) health(w http.ResponseWriter, _ *http.Request) { + writeJSON(w, http.StatusOK, map[string]any{ + "status": "ok", + "time": time.Now().UTC(), + }) +} + +func (h *Handler) createScan(w http.ResponseWriter, r *http.Request) { + var req scans.CreateScanRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid json body") + return + } + + req.Name = strings.TrimSpace(req.Name) + if req.Name == "" { + req.Name = "manual-scan" + } + + job, err := h.store.CreateScan(req) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusCreated, map[string]any{ + "scan_id": job.ID, + "status": job.Status, + }) +} + +func (h *Handler) getScan(w http.ResponseWriter, r *http.Request) { + id := r.PathValue("id") + if id == "" { + writeError(w, http.StatusBadRequest, "scan id is required") + return + } + + job, ok := h.store.GetScan(id) + if !ok { + writeError(w, http.StatusNotFound, "scan not found") + return + } + + writeJSON(w, http.StatusOK, job) +} + +func withJSON(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + next.ServeHTTP(w, r) + }) +} + +func writeJSON(w http.ResponseWriter, status int, payload any) { + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..8edbe96 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,18 @@ +package config + +import "os" + +type Config struct { + HTTPAddr string +} + +func FromEnv() Config { + addr := os.Getenv("HTTP_ADDR") + if addr == "" { + addr = ":8080" + } + + return Config{ + HTTPAddr: addr, + } +} diff --git a/internal/scans/store.go b/internal/scans/store.go new file mode 100644 index 0000000..2f42e94 --- /dev/null +++ b/internal/scans/store.go @@ -0,0 +1,128 @@ +package scans + +import ( + "crypto/rand" + "encoding/hex" + "errors" + "net" + "sync" + "time" +) + +type CreateScanRequest struct { + Name string `json:"name"` + CIDRs []string `json:"cidrs"` + ExcludeIPs []string `json:"exclude_ips"` + SNMPCredentialsID string `json:"snmp_credentials_id"` + Options ScanOptions `json:"options"` +} + +type ScanOptions struct { + PingTimeoutMS int `json:"ping_timeout_ms"` + PingRetries int `json:"ping_retries"` + MaxParallelHosts int `json:"max_parallel_hosts"` + PortScanEnabled bool `json:"port_scan_enabled"` + Ports []int `json:"ports"` +} + +type ScanJob struct { + ID string `json:"id"` + Name string `json:"name"` + Status string `json:"status"` + CIDRs []string `json:"cidrs"` + ExcludeIPs []string `json:"exclude_ips"` + SNMPCredentialsID string `json:"snmp_credentials_id"` + Options ScanOptions `json:"options"` + CreatedAt time.Time `json:"created_at"` + StartedAt time.Time `json:"started_at,omitempty"` + FinishedAt time.Time `json:"finished_at,omitempty"` +} + +type Store interface { + CreateScan(CreateScanRequest) (ScanJob, error) + GetScan(id string) (ScanJob, bool) +} + +type MemoryStore struct { + mu sync.RWMutex + jobs map[string]ScanJob +} + +func NewMemoryStore() *MemoryStore { + return &MemoryStore{ + jobs: make(map[string]ScanJob), + } +} + +func (s *MemoryStore) CreateScan(req CreateScanRequest) (ScanJob, error) { + if len(req.CIDRs) == 0 { + return ScanJob{}, errors.New("cidrs must not be empty") + } + + for _, cidr := range req.CIDRs { + if _, _, err := net.ParseCIDR(cidr); err != nil { + return ScanJob{}, errors.New("invalid cidr: " + cidr) + } + } + + for _, ip := range req.ExcludeIPs { + if net.ParseIP(ip) == nil { + return ScanJob{}, errors.New("invalid exclude ip: " + ip) + } + } + + applyDefaultOptions(&req.Options) + + id, err := newID() + if err != nil { + return ScanJob{}, err + } + + now := time.Now().UTC() + job := ScanJob{ + ID: id, + Name: req.Name, + Status: "queued", + CIDRs: req.CIDRs, + ExcludeIPs: req.ExcludeIPs, + SNMPCredentialsID: req.SNMPCredentialsID, + Options: req.Options, + CreatedAt: now, + } + + s.mu.Lock() + s.jobs[id] = job + s.mu.Unlock() + + return job, nil +} + +func (s *MemoryStore) GetScan(id string) (ScanJob, bool) { + s.mu.RLock() + job, ok := s.jobs[id] + s.mu.RUnlock() + return job, ok +} + +func applyDefaultOptions(opts *ScanOptions) { + if opts.PingTimeoutMS <= 0 { + opts.PingTimeoutMS = 700 + } + if opts.PingRetries < 0 { + opts.PingRetries = 0 + } + if opts.MaxParallelHosts <= 0 { + opts.MaxParallelHosts = 128 + } + if len(opts.Ports) == 0 { + opts.Ports = []int{22, 80, 443, 161} + } +} + +func newID() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil +}