diff --git a/ChangeLog.md b/ChangeLog.md index ae8c205..f08410a 100644 --- a/ChangeLog.md +++ b/ChangeLog.md @@ -17,6 +17,62 @@ _No unreleased changes._ --- +## 26.14 — 2026-05-27 + +JSON query API (P2-04). Adds filterable, paginated `/api/v1/hosts` and +`/api/v1/hosts/{ip}` endpoints alongside the existing bulk-export +endpoints. Operators piping inventory into a CMDB / ticketing webhook / +monitoring stack no longer have to download the full snapshot and grep. + +### Added + +- **`GET /api/v1/hosts`** — list hosts as JSON. Query parameters + (all optional, all AND together): + - `vendor` — exact match on `Host.Vendor` + - `device_type` — exact match on `Host.DeviceType` + - `hostname` — case-insensitive substring match + - `subnet` — CIDR; host IP must be inside it + - `port` — integer; host must have that TCP port open + - `limit` — page size (default 100, capped at 1000) + - `offset` — zero-based offset (default 0) + + Response envelope: + ```json + { + "total": 42, // total matching the filter, before pagination + "limit": 100, + "offset": 0, + "hosts": [ {…full Host JSON…}, … ] + } + ``` + +- **`GET /api/v1/hosts/{ip}`** — single-host detail. Returns + `{ "host": {…}, "ports": [ {…}, … ] }` so consumers don't need a + second round-trip for the ports table. + +- **`internal/admin/api.go`** — separated from the HTML-template + handlers in `handlers.go` so the two concerns can evolve + independently. New `internal/admin/api_test.go` covers every filter + dimension, combined filters, pagination edges, invalid input → 400, + unknown host → 404. + +### Notes + +- API errors return `{"error": "..."}` with appropriate 4xx/5xx codes. +- Pagination is offset-based for simplicity. Cursor-based pagination + is the right choice past ~10k hosts; that's a future cleanup if + someone hits the scale. +- The endpoints live under `/api/v1/` so future v2 changes can land + alongside without breaking consumers. +- Filtering happens in Go after a `List()` from the host store. Fine + for the inventory sizes this agent targets (LAN-scale, hundreds to + low-thousands of hosts). Move to SQL `WHERE` clauses if needed. +- The API is on the same admin server port (default 9090, loopback + bind by default). Off-loopback access is the operator's call — + same posture as the existing `/export.json` endpoint. + +--- + ## 26.13 — 2026-05-27 Change detection + alert sinks (P2-02). The agent now diffs the host diff --git a/internal/admin/api.go b/internal/admin/api.go new file mode 100644 index 0000000..99d685f --- /dev/null +++ b/internal/admin/api.go @@ -0,0 +1,236 @@ +package admin + +import ( + "encoding/json" + "errors" + "log/slog" + "net" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/Ronin48/NetworkInventoryAgent/internal/store" + "github.com/Ronin48/NetworkInventoryAgent/models" +) + +// Query parameter contract for GET /api/v1/hosts: +// +// vendor — exact-match filter on Host.Vendor +// device_type — exact-match filter on Host.DeviceType +// hostname — case-insensitive substring match on Host.Hostname +// subnet — CIDR; host IP must be inside it +// port — integer; host must have this TCP port open (Service "open") +// limit — page size, default 100, capped at 1000 +// offset — zero-based offset, default 0 +// +// All filters AND together. Missing parameters mean "no filter on that +// dimension". Pagination is applied AFTER filtering so `total` always +// reflects the matching set, not the post-paginated slice. +const ( + defaultAPILimit = 100 + maxAPILimit = 1000 +) + +// hostsResponse is the JSON envelope for GET /api/v1/hosts. The shape is +// intentionally stable so v1 consumers don't break — additive changes +// only (new optional fields), never renamed or removed fields. +type hostsResponse struct { + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Hosts []*models.Host `json:"hosts"` +} + +// hostDetailResponse bundles a host with its ports so a single API call +// reproduces the admin /hosts/{ip} page's data. +type hostDetailResponse struct { + Host *models.Host `json:"host"` + Ports []*models.Port `json:"ports"` +} + +// apiError sets standard headers and writes a JSON error body. The status +// codes follow the standard REST conventions: 400 for bad input, 404 for +// unknown resource, 500 for backend failure. +func apiError(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} + +func (s *Server) handleAPIHosts(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + + filter, err := parseHostFilter(q) + if err != nil { + apiError(w, http.StatusBadRequest, err.Error()) + return + } + limit, offset, err := parsePagination(q) + if err != nil { + apiError(w, http.StatusBadRequest, err.Error()) + return + } + + all, err := s.hosts.List(r.Context()) + if err != nil { + slog.Error("api list hosts", "err", err) + apiError(w, http.StatusInternalServerError, "list hosts failed") + return + } + + // Port filter requires a join with the ports table. We snapshot the + // host→ports relation up-front when the filter is set, then a tight + // in-Go pass produces the final set. For unfiltered queries we skip + // the port lookup entirely. + var hostsWithPort map[int64]bool + if filter.port != 0 { + hostsWithPort = make(map[int64]bool) + for _, h := range all { + ports, perr := s.ports.ListByHost(r.Context(), h.ID) + if perr != nil { + continue + } + for _, p := range ports { + if p.Number == filter.port && p.State == models.StateOpen { + hostsWithPort[h.ID] = true + break + } + } + } + } + + matched := make([]*models.Host, 0, len(all)) + for _, h := range all { + if filter.matches(h, hostsWithPort) { + matched = append(matched, h) + } + } + + total := len(matched) + start := offset + if start > total { + start = total + } + end := start + limit + if end > total { + end = total + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(hostsResponse{ + Total: total, + Limit: limit, + Offset: offset, + Hosts: matched[start:end], + }); err != nil { + slog.Error("api encode hosts", "err", err) + } +} + +func (s *Server) handleAPIHostDetail(w http.ResponseWriter, r *http.Request) { + ip := r.PathValue("ip") + host, err := s.hosts.GetByIP(r.Context(), ip) + if errors.Is(err, store.ErrNotFound) { + apiError(w, http.StatusNotFound, "host not found") + return + } + if err != nil { + slog.Error("api get host", "ip", ip, "err", err) + apiError(w, http.StatusInternalServerError, "lookup failed") + return + } + ports, err := s.ports.ListByHost(r.Context(), host.ID) + if err != nil { + // Surface the host even when port lookup fails — partial info + // beats a 500 when the operator just wants to confirm the host + // exists. + slog.Warn("api list ports", "host_id", host.ID, "err", err) + ports = nil + } + + w.Header().Set("Content-Type", "application/json") + if err := json.NewEncoder(w).Encode(hostDetailResponse{ + Host: host, + Ports: ports, + }); err != nil { + slog.Error("api encode host detail", "err", err) + } +} + +// hostFilter is the parsed query string. matches() applies the AND of +// every populated field. +type hostFilter struct { + vendor string + deviceType string + hostname string // lowercase, for case-insensitive substring + subnet *net.IPNet // nil = no subnet filter + port int // 0 = no port filter +} + +func parseHostFilter(q url.Values) (hostFilter, error) { + f := hostFilter{ + vendor: q.Get("vendor"), + deviceType: q.Get("device_type"), + hostname: strings.ToLower(q.Get("hostname")), + } + if cidr := q.Get("subnet"); cidr != "" { + _, net, err := net.ParseCIDR(cidr) + if err != nil { + return f, errors.New("invalid subnet CIDR: " + cidr) + } + f.subnet = net + } + if portStr := q.Get("port"); portStr != "" { + port, err := strconv.Atoi(portStr) + if err != nil || port < 1 || port > 65535 { + return f, errors.New("invalid port (must be 1..65535)") + } + f.port = port + } + return f, nil +} + +func parsePagination(q url.Values) (limit, offset int, err error) { + limit = defaultAPILimit + if v := q.Get("limit"); v != "" { + n, perr := strconv.Atoi(v) + if perr != nil || n < 1 { + return 0, 0, errors.New("invalid limit (must be >= 1)") + } + if n > maxAPILimit { + n = maxAPILimit + } + limit = n + } + if v := q.Get("offset"); v != "" { + n, perr := strconv.Atoi(v) + if perr != nil || n < 0 { + return 0, 0, errors.New("invalid offset (must be >= 0)") + } + offset = n + } + return limit, offset, nil +} + +func (f hostFilter) matches(h *models.Host, hostsWithPort map[int64]bool) bool { + if f.vendor != "" && h.Vendor != f.vendor { + return false + } + if f.deviceType != "" && h.DeviceType != f.deviceType { + return false + } + if f.hostname != "" && !strings.Contains(strings.ToLower(h.Hostname), f.hostname) { + return false + } + if f.subnet != nil { + ip := net.ParseIP(h.IPAddress) + if ip == nil || !f.subnet.Contains(ip) { + return false + } + } + if f.port != 0 && !hostsWithPort[h.ID] { + return false + } + return true +} diff --git a/internal/admin/api_test.go b/internal/admin/api_test.go new file mode 100644 index 0000000..8b0149e --- /dev/null +++ b/internal/admin/api_test.go @@ -0,0 +1,251 @@ +package admin_test + +import ( + "encoding/json" + "io" + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/Ronin48/NetworkInventoryAgent/models" +) + +// fixtureHosts returns a small inventory exercising every filter dimension. +func fixtureHosts() []*models.Host { + now := time.Now() + return []*models.Host{ + {ID: 1, IPAddress: "10.0.0.10", Hostname: "router.lan", Vendor: "MikroTik", DeviceType: "router", LastSeen: now}, + {ID: 2, IPAddress: "10.0.0.20", Hostname: "printer.lan", Vendor: "HP", DeviceType: "printer", LastSeen: now}, + {ID: 3, IPAddress: "10.0.1.30", Hostname: "db.lan", Vendor: "", DeviceType: "database (postgres)", LastSeen: now}, + {ID: 4, IPAddress: "10.0.1.40", Hostname: "build-server.lan", Vendor: "", DeviceType: "linux-host", LastSeen: now}, + {ID: 5, IPAddress: "192.168.1.5", Hostname: "guest-laptop", Vendor: "Apple", DeviceType: "", LastSeen: now}, + } +} + +func fixturePorts() []*models.Port { + return []*models.Port{ + // Host 1: SSH + 8728 (mikrotik api) + {HostID: 1, Number: 22, Protocol: models.TCP, State: models.StateOpen}, + {HostID: 1, Number: 8728, Protocol: models.TCP, State: models.StateOpen}, + // Host 2: 9100 (printer) + {HostID: 2, Number: 9100, Protocol: models.TCP, State: models.StateOpen}, + // Host 3: 5432 postgres + {HostID: 3, Number: 5432, Protocol: models.TCP, State: models.StateOpen}, + // Host 4: 22 ssh + {HostID: 4, Number: 22, Protocol: models.TCP, State: models.StateOpen}, + // Host 5: no ports + } +} + +// decodeJSON reads the body but leaves closing to the caller — bodyclose +// lint can't trace closes through this helper, so callers `defer +// resp.Body.Close()` at the call site. +func decodeJSON(t *testing.T, resp *http.Response, out any) { + t.Helper() + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + require.NoError(t, json.Unmarshal(body, out), "body: %s", body) +} + +func TestAPIHosts_UnfilteredListsAll(t *testing.T) { + srv := newTestServer(t, + &mockHostStore{hosts: fixtureHosts()}, + &mockPortStore{ports: fixturePorts()}, + &mockScanStore{}, + ) + resp := get(t, srv, "/api/v1/hosts") + defer func() { _ = resp.Body.Close() }() + require.Equal(t, http.StatusOK, resp.StatusCode) + + var body struct { + Total int `json:"total"` + Limit int `json:"limit"` + Offset int `json:"offset"` + Hosts []*models.Host `json:"hosts"` + } + decodeJSON(t, resp, &body) + assert.Equal(t, 5, body.Total) + assert.Equal(t, 100, body.Limit) + assert.Len(t, body.Hosts, 5) +} + +func TestAPIHosts_VendorFilter(t *testing.T) { + srv := newTestServer(t, + &mockHostStore{hosts: fixtureHosts()}, + &mockPortStore{ports: fixturePorts()}, + &mockScanStore{}, + ) + resp := get(t, srv, "/api/v1/hosts?vendor=MikroTik") + defer func() { _ = resp.Body.Close() }() + var body struct{ Total int } + decodeJSON(t, resp, &body) + assert.Equal(t, 1, body.Total) +} + +func TestAPIHosts_DeviceTypeFilter(t *testing.T) { + srv := newTestServer(t, + &mockHostStore{hosts: fixtureHosts()}, + &mockPortStore{ports: fixturePorts()}, + &mockScanStore{}, + ) + resp := get(t, srv, "/api/v1/hosts?device_type=printer") + defer func() { _ = resp.Body.Close() }() + var body struct { + Total int + Hosts []*models.Host + } + decodeJSON(t, resp, &body) + require.Equal(t, 1, body.Total) + assert.Equal(t, "10.0.0.20", body.Hosts[0].IPAddress) +} + +func TestAPIHosts_HostnameSubstringCaseInsensitive(t *testing.T) { + srv := newTestServer(t, + &mockHostStore{hosts: fixtureHosts()}, + &mockPortStore{ports: fixturePorts()}, + &mockScanStore{}, + ) + resp := get(t, srv, "/api/v1/hosts?hostname=LAN") + defer func() { _ = resp.Body.Close() }() + var body struct{ Total int } + decodeJSON(t, resp, &body) + // router.lan + printer.lan + db.lan + build-server.lan = 4 + assert.Equal(t, 4, body.Total) +} + +func TestAPIHosts_SubnetFilter(t *testing.T) { + srv := newTestServer(t, + &mockHostStore{hosts: fixtureHosts()}, + &mockPortStore{ports: fixturePorts()}, + &mockScanStore{}, + ) + resp := get(t, srv, "/api/v1/hosts?subnet=10.0.0.0/24") + defer func() { _ = resp.Body.Close() }() + var body struct{ Total int } + decodeJSON(t, resp, &body) + assert.Equal(t, 2, body.Total, "only 10.0.0.10 and 10.0.0.20 are in 10.0.0.0/24") +} + +func TestAPIHosts_PortFilter(t *testing.T) { + srv := newTestServer(t, + &mockHostStore{hosts: fixtureHosts()}, + &mockPortStore{ports: fixturePorts()}, + &mockScanStore{}, + ) + resp := get(t, srv, "/api/v1/hosts?port=22") + defer func() { _ = resp.Body.Close() }() + var body struct { + Total int + Hosts []*models.Host + } + decodeJSON(t, resp, &body) + require.Equal(t, 2, body.Total, "hosts 1 and 4 have port 22 open") + gotIPs := map[string]bool{body.Hosts[0].IPAddress: true, body.Hosts[1].IPAddress: true} + assert.True(t, gotIPs["10.0.0.10"]) + assert.True(t, gotIPs["10.0.1.40"]) +} + +func TestAPIHosts_CombinedFilters(t *testing.T) { + srv := newTestServer(t, + &mockHostStore{hosts: fixtureHosts()}, + &mockPortStore{ports: fixturePorts()}, + &mockScanStore{}, + ) + // All three filters must match for a host to appear: + // subnet 10.0.0.0/16 + device_type linux-host + port 22 → host 4 only + resp := get(t, srv, "/api/v1/hosts?subnet=10.0.0.0/16&device_type=linux-host&port=22") + defer func() { _ = resp.Body.Close() }() + var body struct { + Total int + Hosts []*models.Host + } + decodeJSON(t, resp, &body) + require.Equal(t, 1, body.Total) + assert.Equal(t, "10.0.1.40", body.Hosts[0].IPAddress) +} + +func TestAPIHosts_Pagination(t *testing.T) { + srv := newTestServer(t, + &mockHostStore{hosts: fixtureHosts()}, + &mockPortStore{ports: fixturePorts()}, + &mockScanStore{}, + ) + resp := get(t, srv, "/api/v1/hosts?limit=2&offset=2") + defer func() { _ = resp.Body.Close() }() + var body struct { + Total int + Limit int + Offset int + Hosts []*models.Host + } + decodeJSON(t, resp, &body) + assert.Equal(t, 5, body.Total, "total is the pre-pagination count") + assert.Equal(t, 2, body.Limit) + assert.Equal(t, 2, body.Offset) + assert.Len(t, body.Hosts, 2) +} + +func TestAPIHosts_PaginationOffsetPastTotal(t *testing.T) { + srv := newTestServer(t, + &mockHostStore{hosts: fixtureHosts()}, + &mockPortStore{ports: fixturePorts()}, + &mockScanStore{}, + ) + resp := get(t, srv, "/api/v1/hosts?offset=999") + defer func() { _ = resp.Body.Close() }() + var body struct { + Total int + Hosts []*models.Host + } + decodeJSON(t, resp, &body) + assert.Equal(t, 5, body.Total) + assert.Len(t, body.Hosts, 0, "offset past total should empty the slice, not 500") +} + +func TestAPIHosts_InvalidSubnetReturns400(t *testing.T) { + srv := newTestServer(t, &mockHostStore{}, &mockPortStore{}, &mockScanStore{}) + resp := get(t, srv, "/api/v1/hosts?subnet=not-a-cidr") + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + _ = resp.Body.Close() +} + +func TestAPIHosts_InvalidPortReturns400(t *testing.T) { + srv := newTestServer(t, &mockHostStore{}, &mockPortStore{}, &mockScanStore{}) + resp := get(t, srv, "/api/v1/hosts?port=99999") + assert.Equal(t, http.StatusBadRequest, resp.StatusCode) + _ = resp.Body.Close() +} + +func TestAPIHostDetail_OK(t *testing.T) { + srv := newTestServer(t, + &mockHostStore{hosts: fixtureHosts()}, + &mockPortStore{ports: fixturePorts()}, + &mockScanStore{}, + ) + resp := get(t, srv, "/api/v1/hosts/10.0.0.10") + defer func() { _ = resp.Body.Close() }() + require.Equal(t, http.StatusOK, resp.StatusCode) + + var body struct { + Host *models.Host + Ports []*models.Port + } + decodeJSON(t, resp, &body) + require.NotNil(t, body.Host) + assert.Equal(t, "10.0.0.10", body.Host.IPAddress) + assert.Len(t, body.Ports, 2, "host 1 has SSH + 8728") +} + +func TestAPIHostDetail_NotFound(t *testing.T) { + srv := newTestServer(t, + &mockHostStore{hosts: fixtureHosts()}, + &mockPortStore{}, + &mockScanStore{}, + ) + resp := get(t, srv, "/api/v1/hosts/192.168.99.99") + assert.Equal(t, http.StatusNotFound, resp.StatusCode) + _ = resp.Body.Close() +} diff --git a/internal/admin/server.go b/internal/admin/server.go index a9c18ec..1743900 100644 --- a/internal/admin/server.go +++ b/internal/admin/server.go @@ -86,6 +86,11 @@ func NewServer( mux.HandleFunc("GET /export.csv", s.handleExportCSV) mux.HandleFunc("POST /scan", s.handleScanTrigger) + // JSON query API — filterable, paginated. Distinct path prefix so + // future v2 changes can land alongside without breaking consumers. + mux.HandleFunc("GET /api/v1/hosts", s.handleAPIHosts) + mux.HandleFunc("GET /api/v1/hosts/{ip}", s.handleAPIHostDetail) + s.srv = &http.Server{ Addr: addr, Handler: tracing.HTTPMiddleware("admin", s.middleware(mux)),