All notable, user-visible changes are recorded here.
The project uses YY.NN date-based versioning — YY is the two-digit
calendar year and NN is a zero-padded incrementing release number within
that year. Versions bump once per merged PR.
Each entry links the recommendation number from
Planning.md when applicable.
No unreleased changes.
Documentation catch-up. No behaviour change — closes the gap between README and the ten sprints (26.07–26.16) of features that landed since.
- README config reference table — was missing 23 fields shipped
between 26.06 and 26.16:
scanner.profiles[].*,scanner.probe_ports,scanner.deep_probe,scanner.deep_probe_ports,scanner.udp_ports,scanner.enrich_arp,scanner.host_ttl,health.auth_token,health.tls_cert_path,health.tls_key_path,health.client_ca_path,watchdog.peer_token,watchdog.tls.*(4 fields),tracing.endpoint,alerts.webhook.*(2 fields),alerts.syslog.*(3 fields). Table is now grouped by subsystem for readability. - README endpoints table — was missing 7 of 9 HTTP routes the
agent serves. Now lists
/health,/status,/metrics(health server) and/,/hosts,/hosts/{ip},/scans,/watchdog,/export.{json,csv},/api/v1/hosts,/api/v1/hosts/{ip},POST /scan(admin server) in two separate tables by listener. - README package layout — was missing the five packages added
since 26.07:
alerts/,metrics/,tracing/,tlsutil/, plus thebanner.go/classify.go/arp.gomodules underscanner/. - README Features bullet list — was current as of ~26.06 — now reflects banner-grab service identification, the device-type classifier, MAC/vendor enrichment, per-subnet scan profiles, change detection alerts, JSON query API, Prometheus metrics, OpenTelemetry tracing, signed multi-platform releases.
- Per-subnet profile example in the config section, showing aggressive-infra + lazy-guest tuning in one config.
- No code change.
go test ./...green andgolangci-lint run ./...unchanged (still 0 issues). - This is the last "we shipped features but the docs lagged" sprint; the canonical config and endpoint surface is now accurate against the code.
Listener-address environment variable overrides. Containerised deployments can now repoint the health + admin listeners without rewriting the JSON config file.
INVENTORY_HEALTH_ADDR— overrideshealth.addr.INVENTORY_ADMIN_ADDR— overridesadmin.addr.
Existing config validation still applies: an off-loopback bind
without a corresponding INVENTORY_AUTH_TOKEN is refused at boot,
regardless of which surface (file or env) supplied the address.
- README env-var table now lists
INVENTORY_AUTH_TOKENandINVENTORY_PEER_TOKEN, which the code has supported since 26.06 but the docs never advertised.
Per-subnet scan profiles (P2-05). Operators can now run aggressive hourly deep scans on critical infrastructure while leaving the guest network on a lazy daily liveness sweep — all from one config file, one agent process. Closes the original P2 operator-feedback batch.
config.SubnetProfile— per-subnet overrides forScanInterval,Timeout,ProbePorts,DeepProbe,DeepProbePorts,UDPPorts,EnrichARP. Bool fields are*boolso a profile can explicitly disable deep probing even when the global default is on (the zero value would be ambiguous).config.ScannerConfig.Profiles []SubnetProfile— the new per-subnet list. Mutually exclusive with the legacySubnetsfield; both are validated at boot.config.ResolvedProfile+ScannerConfig.Resolve()— flattens Subnets + Profiles into one fully-defaulted list. Every field is populated from either the profile override or the global default, so the agent's runtime path has no further fallback logic.scanner.SubnetOptions— new struct passed toScan(ctx, subnet, opts). Carries the per-call probe configuration so the scanner can serve multiple profiles without per-scan reconstruction.config.True()/config.False()— bool-pointer helpers for building profiles programmatically.
scanner.Scan(ctx, subnet)→scanner.Scan(ctx, subnet, SubnetOptions{}). In-tree callers updated; out-of-tree callers passSubnetOptions{}to retain pre-26.15 behaviour.scanner.probe,deepScan,udpScanhelpers now take their timeout + port-list parameters explicitly rather than reading from the Scanner struct. The Scanner-level fields remain as defaults consulted byresolve()at the top of Scan.agent.Newnow returns(*Agent, error)—Resolve()runs at construction so config errors (duplicate subnets, mutually-exclusive flat list + profiles) surface at boot, not on the first scan tick. Test suites updated.- Agent scheduling: the single global
ScanIntervalticker is replaced with one that ticks at the shortest per-profile interval. Each profile keeps its ownnextDuetimestamp; only profiles past their due time get scanned on each tick. The housekeeping pass (prune, change-detect diff, tracker updates) runs every tick regardless, so zero-profile deployments — watchdog-only mode — still work as before. - Tick-interval safety floor of 1 second. Prevents pathological
busy-loops if an operator types
"1ns"by accident.
- 7 new tests in
internal/configcovering: legacy-Subnets path, profile-overrides-win, explicit-False-beats-global-True, mutually- exclusive validation, duplicate-subnet rejection, empty-subnet rejection, zero-profile happy path. - Scanner + agent test suites updated for the new
SubnetOptions{}third argument and(a, err)constructor.
Existing configs keep working — set scanner.subnets and the global
fields (scan_interval, timeout, probe_ports, …) as before.
Operators who want per-subnet tuning switch to:
{
"scanner": {
"profiles": [
{ "subnet": "10.0.0.0/24", "scan_interval": "1h", "deep_probe": true },
{ "subnet": "192.168.1.0/24", "scan_interval": "24h" }
],
"scan_interval": "5m",
"timeout": "2s",
"workers": 50
}
}Any field absent from a profile inherits the corresponding global.
The flat scanner.subnets and per-subnet scanner.profiles fields
are mutually exclusive — boot fails fast if both are set.
Original asks from the operator pass: all five shipped.
| # | Item | Sprint |
|---|---|---|
| 1 | Service / application discovery | 26.12 |
| 2 | Change detection + webhook/syslog alerts | 26.13 |
| 3 | Device-type classifier | 26.11 |
| 4 | Query API beyond bulk export | 26.14 |
| 5 | Per-subnet scan profiles | 26.15 |
The agent now does end-to-end inventory: discovery → enrichment →
classification → change detection → alerting → queryable API, with
per-subnet scheduling. Next-feature backlog is empty; future work
should be driven by a fresh round of operator feedback or /ultrareview
findings.
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.
-
GET /api/v1/hosts— list hosts as JSON. Query parameters (all optional, all AND together):vendor— exact match onHost.Vendordevice_type— exact match onHost.DeviceTypehostname— case-insensitive substring matchsubnet— CIDR; host IP must be inside itport— integer; host must have that TCP port openlimit— page size (default 100, capped at 1000)offset— zero-based offset (default 0)
Response envelope:
{ "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 inhandlers.goso the two concerns can evolve independently. Newinternal/admin/api_test.gocovers every filter dimension, combined filters, pagination edges, invalid input → 400, unknown host → 404.
- 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 SQLWHEREclauses 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.jsonendpoint.
Change detection + alert sinks (P2-02). The agent now diffs the host
inventory pre- and post-cycle and emits host.discovered /
host.vanished events to a configurable HTTP webhook, syslog, or both.
Operators no longer have to grep logs to notice a new device appeared.
internal/alertspackage —Event(JSON-tagged for wire reuse),EventType(host.discovered,host.vanished),Emitterinterface,Multiplexerthat fans events out to N sinks in parallel,NoopEmitterfor the alerts-disabled deployment.- WebhookSink — HTTP POST JSON. One retry on transient failures
(network error or 5xx); 4xx is final. Optional
Authorizationheader passed verbatim (soBearer …andBasic …both work without per-scheme code). - SyslogSink — RFC 5424 over UDP/TCP. Hand-rolled because
stdlib
log/syslogis Unix-only and the agent runs on Windows. Message body is the same JSON as the webhook payload, so syslog parsers (rsyslog mmjsonparse, syslog-ng, Splunk) get structured fields for free. config.AlertsConfig— new optional top-level section:Either or both sub-sections may be set; absence of both silently disables alerting (no change for existing deployments)."alerts": { "webhook": { "url": "https://hooks.example/x", "auth_header": "Bearer …" }, "syslog": { "addr": "udp://syslog.example:514", "tag": "inventory" } }
agent.Newgained analerts.Emitterparameter (8th positional arg). Passnilfor the noop emitter; the constructor substitutes one automatically so tests stay terse.agent.runCyclesnapshots the host inventory before scanning and again after, then diffs the two by IP. Discovered hosts get the fresh enrichment (Hostname/Vendor/DeviceType); vanished hosts get the last-known pre-cycle row (since the post-cycle one is gone). Cycle-failed runs intentionally skip the diff to avoid alert spam on transient DB blips.mockHostStore.Upsertin the agent test suite now mirrors the sqlite UPSERT (overwrite-on-conflict) so test stores stay parity with production — same fix landed in the scanner mock during 26.11.
- 11 new tests covering: multiplexer fan-out, sibling sinks surviving
a peer-sink error, webhook auth-header round-trip, 5xx retry, 4xx
no-retry, nil-on-empty-URL/addr guards, syslog RFC 5424 format
against a real UDP listener (PRI calculation, JSON MSG, MSGID =
event type), bad-scheme rejection, agent-level diff producing
host.vanishedon prune andhost.discoveredon a mid-cycle insert.
- Sinks deliver in goroutines. Multiplexer.Emit returns immediately; failures show up in slog warnings, not back at the call site. This matches operator expectations for alert pipelines and keeps the scan-cycle hot path off the network.
- Port-level events (
port.appeared/port.vanished) and watchdog events are deliberately deferred — host-level coverage satisfies the headline operator ask first.
Service / application discovery (P2-01). Turns "port 22 is open" into
"SSH-2.0-OpenSSH_9.6p1 Ubuntu", and similar for nine more protocols.
Banners now flow into Port.Service for every persisted port, in addition
to the existing Host.OSFingerprint for the liveness winner.
internal/scanner/banner.go— three new banner-grab strategies:lineBannerfor protocols where the server greets first (SMTP 25/465/587, FTP 21, POP3 110, IMAP 143, Telnet 23). BoundedcapReaderdefends against peers that flood without an EOL.tlsHTTPSFingerprintfor HTTPS (443/8443). Completes a TLS handshake (InsecureSkipVerify — we're scraping for ID, not trusting), peeks at the peer cert CN/SAN, then reuses the same connection for a HEAD to capture the Server header. Two IDs for the cost of one dial.mysqlGreetingfor MySQL/MariaDB/Percona (3306). Reads the v10 handshake packet and extracts the server-version string. Passive — we never write to the socket.
- HTTP port list expanded to include 8000 and 8888 (common developer-server defaults).
Port.Servicepopulated by the scanner for every TCP port upserted by the liveness, deep-probe, and HTTPS-fingerprint paths. The column has existed in the schema since the initial migration but was never written until now.internal/scanner/banner_test.go— full coverage of every banner: SMTP greeting parse, FTP greeting parse, silent-server timeout (must return ""), valid v10 MySQL handshake parse, wrong protover MySQL guard, HTTPS handshake with cert CN extraction + Server header pickup via ahttptest.NewUnstartedServer.
scanner.upsertPortgained aservice stringparameter. The three existing call sites (liveness, deepScan, udpScan) pass the result offingerprint()for TCP and""for UDP. UDP banner probes are protocol-specific and out of scope for this sprint.fingerprint()dispatch table grew from 4 entries to 12 — see the function comment for the full list.
- The liveness path no longer redials for its banner:
host.OSFingerprintwas already populated byfingerprint()before the port upsert, so the same string is reused as the liveness-portService. - The deepScan path does redial inside
fingerprint()per open port. The first dial indeepScanwas a connect-and-close to confirm liveness; protocols where the server speaks first need a fresh socket so the read deadline starts cleanly. The cost is one extra dial per deep-open port per cycle — well within the global worker semaphore. - UDP services (DNS 53, SNMP 161, NTP 123, …) are not banner-grabbed yet. Each requires a protocol-specific request packet rather than a passive listen; deferred to a future sprint if asked.
Device-type classifier (P2-03 from the operator-feedback queue). Populates
Host.DeviceType automatically based on the OUI vendor, OS-fingerprint
banner, and the open TCP + UDP ports found during the scan. The admin
console templates already had the field plumbed; this sprint just gives
them data to show.
internal/scanner/classify.go— pure heuristic function with rules for printers (9100/631/515), routers (MikroTik + vendor-pinned Cisco), hypervisors (VMware + 902/5988/5989), databases (MySQL, Postgres, MSSQL, MongoDB, Redis, Memcached), Windows hosts/servers (SMB ± HTTP), Active Directory domain controllers (Kerberos+LDAP), mail servers (SMTP+IMAP combo), DNS servers, MQTT brokers, embedded systems (Raspberry Pi OUI), Linux hosts (SSH banner), and generic web appliances. Conservative: returns "" rather than guessing when no rule fires confidently.internal/scanner/classify_test.go— 27 table-driven cases covering every rule above, including overlapping cases (DC beats generic SMB, SMB+webserver beats SMB alone).scanner.Scanintegration test — confirms a host listening on 11211 (memcached) getsDeviceType = "database (memcached)"after a real scan, exercising the full classify → re-upsert path.
scanner.deepScanandscanner.udpScannow return the list of open ports they found (in addition to upserting them) so the per- host goroutine can pass the complete port set toclassify(). No behavioural change for callers that ignore the return value.- Per-host scan path does a second
hosts.Upsertwhen the classifier produces a non-empty device-type. The cost is one extra small SQL write per live host per cycle.
mockHostStore.Upsertnow mirrors the sqlite UPSERT — it previously returned the existing row unchanged on conflict, which meant the scanner's "first upsert without device_type, then re-upsert with device_type" path tested green against the mock but would have lost the device_type write against any real store. Mock parity caught the bug before it shipped.
Container distribution. Adds multi-arch Docker images on the GitHub Container Registry alongside the existing binary archives, with the same cosign keyless OIDC signing flow extended to the image manifests.
ghcr.io/cryptojones/networkinventoryagent:<version>— multi-arch manifest covering linux/amd64 + linux/arm64.:latestresolves to the same image as the most recentvYY.NNtag. Default entrypoint isagent;wintermuteandneuromancerare present in the same image and reachable via--entrypoint /usr/local/bin/wintermuteetc.Dockerfile.goreleaser— slim COPY-only Dockerfile consumed by goreleaser. Pre-built binaries are copied in rather than recompiled per arch (the host-level goreleaser build matrix already produced them). The existing top-levelDockerfileis unchanged sodocker build .from a checkout still works.cosignsigning on the published manifests. Verify with:cosign verify ghcr.io/cryptojones/networkinventoryagent:26.10 \ --certificate-identity-regexp 'https://github.com/CryptoJones/NetworkInventoryAgent/' \ --certificate-oidc-issuer https://token.actions.githubusercontent.com
.github/workflows/release.ymlnow sets up QEMU + Docker Buildx and logs in to ghcr.io before invoking goreleaser, so the cross-archlinux/arm64layer builds succeed on the amd64-only GitHub runner.- README gains a Docker section with the
docker pullquickstart and thecosign verifysnippet.
- The image's default entrypoint is
agent(standalone single-agent binary). For the Wintermute/Neuromancer pair, the existingdocker-compose.ymlstill applies — point itsimage:atghcr.io/cryptojones/networkinventoryagent:26.10and you skip thedocker buildstep. - Pulls are unauthenticated for public images;
docker login ghcr.iois only needed if a future release flips visibility to private.
Post-Planning.md tightening pass — clears the lint debt that 26.06's
golangci-lint integration (item #40) catches but never fixed, and
pre-empts the September 2026 Node.js 20 → 24 transition on GitHub
Actions before it bites.
golangci-lint run ./...now passes clean. The 21 baseline findings (15× errcheck onClose/Body.Close, 4× staticcheck QF1008/QF1012, 2× gocritic ifElseChain/exitAfterDefer) are all resolved — either by explicit_ =discards on Close calls in defer position (the Go idiom for "we don't care about this error") or by rewriting the offending pattern.make lintis now meaningfully enforceable.cmd/consoleos.Exitno longer skips deferred cleanup (gocritic exitAfterDefer). The real entry point moved into arun() inthelper andmaincallsos.Exit(run()), matching the established Go idiom.config.goMarshalJSONdrops the redundant.Durationselector (staticcheck QF1008).cmd/console/tuiWriteString(fmt.Sprintf(...))rewritten tofmt.Fprintf(&b, ...)(staticcheck QF1012) at three sites.render()loading/err/default chain rewritten asswitch(gocritic ifElseChain).
renovate.json— Renovate Bot configuration for ongoing supply-chain updates. Bundlesgomodminor/patch into one weekly PR (go-deps), auto-merges GitHub Actions and Docker base-image digest bumps, scheduleslockFileMaintenancemonthly, and labels vulnerability alerts immediately. No automerges ongomodmajors — those land as manually-reviewed PRs.internal/tracing/tracing_test.go— coversSetupwith an empty endpoint, confirmsHTTPMiddlewareproduces a valid span inside the handler context, and verifiesHTTPClientwraps an injected base RoundTripper instead of replacing it.
- GitHub Actions opt in to Node.js 24 now. Both
ci.ymlandrelease.ymlsetFORCE_JAVASCRIPT_ACTIONS_TO_NODE24=true, pre-empting the September 2026 Node.js 20 removal that would otherwise hard-fail CI without warning. internal/tracingsemconv bumped from v1.26.0 → v1.40.0 to match the otel SDK'sresource.Default()schema URL — the older version produced "conflicting Schema URL" errors atSetuptime.
- No new Planning.md items remain. This sprint addresses real lint and CI-deprecation debt rather than feature work; future cleanup follow-ups should be tracked via issues or a new Planning.md entry.
Final Planning.md sprint. Items #13 (peer TLS), #20 (OpenTelemetry tracing), and #41 (release engineering) — closing out the original 42-item backlog.
- OpenTelemetry tracing (Planning item #20). New
internal/tracingpackage wires a process-global TracerProvider with an OTLP/HTTP exporter. Admin server, health server, and the watchdog's outbound HTTP client all produce spans; trace context propagates between the two paired agents.tracing.endpointconfig field (orOTEL_EXPORTER_OTLP_ENDPOINTenv var) selects the collector. With no endpoint the SDK runs no-op, so spans are produced but discarded — turning real export on later is a one-line change. - Peer TLS + optional mTLS (Planning item #13). New
watchdog.tls(CA pinning + optional client keypair) andhealth.tls_cert_path/tls_key_path/client_ca_pathconfig fields. TLS 1.2+ enforced. The watchdog and the health server share the sameinternal/tlsutilhelpers, so the two sides of the handshake are configured by the same struct. Bearer tokens still apply on top of TLS. - goreleaser release pipeline + SBOM + cosign signing (Planning
item #41). New
.goreleaser.yamlcross-compiles linux/darwin/windows × amd64/arm64;.github/workflows/release.ymltriggers onv*tag push. Every archive bundles a CycloneDX SBOM (sbom.cyclonedx.json); every artefact is signed via cosign keyless OIDC (GitHub Actions). README documents thecosign verify-blobcommand.make release-snapshotvalidates the pipeline locally without cutting a tag.
health.NewServernow takes aServerOptionsstruct. The positional signature is preserved ashealth.NewServerLegacyso external callers don't break. New options fields:TLSConfig.health.NewClientWith(addr, ClientOptions)is the new full- control client constructor; existingNewClient/NewAuthedClientremain as thin wrappers.watchdog.Newnow takes a pre-built*health.Client. Client construction (tracing transport, TLS pinning, bearer token, timeouts) has moved intocmd/internal/runtimeso the watchdog package has no knowledge of TLS or otel.config.WatchdogConfig.PeerTokenfield is unchanged butPeerTokenis no longer aConfigfield — it's been part of WatchdogConfig since 26.06. The 26.06 ChangeLog mention is still accurate.internal/adminandinternal/healthwrap their muxes intracing.HTTPMiddlewareso every request becomes a server span.
- CycloneDX SBOM generation via
github.com/CycloneDX/cyclonedx-gomod(run only at release time; not a build-time dep). - cosign keyless signing in CI; transparency log entry uploaded to Rekor automatically.
make release-snapshotfor local pipeline validation.
health.NewServer(addr, tracker, staleAfter, authToken)→health.NewServer(addr, tracker, health.ServerOptions{StaleAfter: …, AuthToken: …, TLSConfig: …}). UseNewServerLegacyto keep the old signature.watchdog.New(cfg, localStatus, publish)→watchdog.New(cfg, client, localStatus, publish). TheConfig.PeerTokenfield moved out; build the client withhealth.NewClientWith(addr, ClientOptions{Token: token, HTTPClient: tracing.HTTPClient(transport)}).- New optional config fields:
tracing.endpoint,watchdog.tls.{ca_cert_path,client_cert_path,client_key_path,server_name},health.{tls_cert_path,tls_key_path,client_ca_path}. All default empty (no behaviour change for existing deployments).
All 42 recommendations are now addressed. Items #1–#42 are either implemented, satisfied by an earlier change (e.g. #26 by 26.02), or documented as a deliberate non-goal. Future work should be tracked via new Planning.md entries or GitHub issues.
Observability + remaining enrichment + dual-remote parity — items #16, #19, #28, #29, #31, and #42 from Planning.md.
- Prometheus
/metricsendpoint (Planning item #19). Newinternal/metricspackage exposes counters for scans, scan errors, hosts/ports upserted, TCP probe success/failure, UDP probe success, DB errors, watchdog checks, watchdog failures, peer-down transitions, hosts pruned, and on-demand triggers; plus gauges for host count and peer-up state. Mounted on the existing health server, gated by the same bearer token. Dependency-free implementation — the binary footprint is unchanged. - Configurable deep TCP port scan (Planning item #28). New
scanner.deep_probeflag andscanner.deep_probe_portslist. When enabled, every host confirmed alive by the liveness pass gets a second-pass scan across the configured port list. The default list is a 34-port "top services" set rather than nmap's top-1000 — the former completes in seconds per host, the latter would not fit a 5-minute scan interval. Each open port is persisted via the existingPortRepo.Upsert. Disabled by default so existing deployments are unaffected. - UDP probe stage (Planning item #29). New
scanner.udp_portslist. Best-effort UDP probing per live host: ports that respond are recorded asstate=open udp; ports the kernel surfaces as ICMP port unreachable are recorded asstate=closed udp; the ambiguous "no reply" case is not persisted to avoid filling the table with filtered-vs-open noise. Makes the existingmodels.UDPprotocol type honest. Disabled when the list is empty (the default). - MAC + vendor enrichment (Planning item #31). New
scanner.enrich_arpflag. On Linux the scanner parses/proc/net/arpafter each successful probe and populatesHost.MACAddressandHost.Vendorfrom an embedded OUI prefix table (~80 common vendors: Cisco, VMware, Apple, Raspberry Pi, MikroTik, Synology, …). Silent no-op on non-Linux platforms and for hosts outside the directly attached subnet (the kernel only holds neighbour cache entries for hosts it has actually contacted). -versionflag on every binary (Planning item #16 prereq).wintermute,neuromancer,agent, andconsolenow accept-versionand exit 0 with<name> <revision>. Revision is read fromruntime/debug.ReadBuildInfo()(vcs.revision when built from a checkout) or overridable at link time via-ldflags="-X .../runtime.Version=...".- Docker smoke test in CI (Planning item #16). Woodpecker now
builds the image and runs
docker run --rm <img> -version, instead of the previousdry_run: truewhich compiled and threw the image away. Catches GLIBC skew, missing CA-bundle, and entrypoint regressions that compile-time checks miss. - GitHub mirror parity (Planning item #42). New
.github/directory:CODEOWNERSroutes all reviews to@CryptoJones.workflows/ci.ymlmirrors the Woodpecker pipeline (build, vet, fmt, test, vuln, lint, docker) on every PR and push to main.ISSUE_TEMPLATE/{bug_report,feature_request}.mdandPULL_REQUEST_TEMPLATE.mdcarry Planning.md cross-reference prompts.CONTRIBUTING.mdgains an "Authoritative remote" section naming Codeberg as the canonical home.
scanner.Newnow takes anOptionsstruct. The positional parameter list had grown to nine and was about to grow further with this sprint's additions; the struct form keeps each call site readable and lets future fields be added without touching unrelated callers. See "Notes / breaking changes" below.internal/scannerinstrumentation. Probe, host upsert, port upsert, and DB-error paths increment the matchingmetricscounters. No change to log output or behaviour.internal/agentandinternal/watchdoginstrumentation. Scan cycles, triggers, prune counts, watchdog checks/failures/peer-down transitions, and host count gauge are all wired throughmetrics.
scanner.New(hosts, ports, scans, timeout, workers, maxHosts, probePorts)is replaced byscanner.New(scanner.Options{Hosts: …, Ports: …, …}). Out-of-tree callers need a one-line update; in-tree callers (agent.go) are already updated.config.ScannerConfiggains four optional fields:deep_probe,deep_probe_ports,udp_ports,enrich_arp. Existing config files remain valid./metricsis gated by the same bearer token as/healthand/status. Loopback-only deployments are unchanged; off-loopback deployments scrape withAuthorization: Bearer $INVENTORY_AUTH_TOKEN.- Item #26 ("probe is sequential per host") is considered satisfied by the parallel-probe change shipped in 26.02 and is not re-listed here. Items #13 (peer TLS), #20 (OpenTelemetry tracing), and #41 (goreleaser/SBOM/cosign) remain outstanding for a future sprint — each is substantial enough to warrant its own PR.
A second batched pass — items #4, #10, #12, #14, #17, #18, #23, #24, #25, #27, #30, #32, #33, #34, #35, #36, #37, #38, #39, and #40 from Planning.md.
- WAL writer/reader pool split (Planning item #4).
sqlite.DBnow opens two*sql.DBpools against the same on-disk file: a writer pinned at one connection and a reader sized for2×GOMAXPROCS. Dashboard / watchdog queries no longer queue behind the scanner's upserts.:memory:paths collapse to one pool because that storage is per-connection. Repo structs gainedwriter/readerfields and dispatch mutations vs. queries accordingly. - IPv6 subnet size guard (Planning item #33). The size check now
computes
1 << (bits-ones)up front and refuses before allocating the address slice — previously a /64 would have grown the slice to 2⁶⁴ entries long before the post-allocation check tripped. - Global worker semaphore across subnets (Planning item #34). The
per-Scan semaphore moved into the
Scannerstruct, soscanner.workersnow caps total concurrent dials across all subnets in a cycle. Operators with 20 subnets no longer get20×workersin-flight probes.
- Bearer-token auth for
/healthand/status(Planning item #12). Whenhealth.addris bound off-loopback the agent refuses to start withouthealth.auth_token(orINVENTORY_AUTH_TOKEN). The watchdog peer client takes a matchingpeer_token. Token comparison is constant-time; mismatches return 401 withWWW-Authenticate. - Config-file permission check (Planning item #18). Boot fails if a
config containing a bearer token has group/other read permissions.
Keep them in env vars or
chmod 600the file. - CSRF protection on the admin console (Planning item #17). A per-process random token gates every state-changing method; templates carry the value in hidden form inputs so the no-JS flow works out of the box.
- Watchdog peer-status surfacing (Planning item #10). The watchdog
publishes its view (
reachable, drift, staleness, last error) to thehealth.Tracker, which exposes it onGET /statusand at the newGET /watchdogadmin page. POST /scanon-demand trigger (Planning item #23). The admin console's dashboard now has a "Trigger Scan" button that pushes onto a buffered channel consumed byagent.Run; coalesces when a trigger is already pending.- Host pruning / staleness policy (Planning item #24). New
optional
scanner.host_ttlconfig. Hosts whoselast_seenis older than this TTL are deleted at the end of each cycle. Disabled by default to preserve existing deployments. /export.jsonand/export.csv(Planning item #25) — full host- ports snapshot without taking the agent down to copy the SQLite file.
- Configurable probe-port list (Planning item #27). New
scanner.probe_ports []intconfig; default unchanged. - Reverse-DNS lookup (Planning item #30). After a successful
probe, the scanner does a 500 ms PTR lookup and populates
Hostname. - Basic OS fingerprinting (Planning item #32). Best-effort SSH
banner read on port 22 and HTTP
Serverheader on 80/8080; absent on TLS/443 (deferred to a future deep-probe pass).
cmd/internal/runtime(Planning item #35). The 95%-identicalcmd/agent,cmd/wintermute, andcmd/neuromancermain.gofiles collapsed behindruntime.Run(opts)— each binary is now ~10 lines.internal/adminsplit into three files (Planning item #36):server.go(router + lifecycle),handlers.go(one per page),render.go(template plumbing + page-data types),middleware.go(existing middleware + CSRF).stringfuncMap helper removed (Planning item #37). Templates comparemodels.Protocol/models.PortStatedirectly sinceeqhandles the underlying string kind reflectively.- TUI uses a cancellable context (Planning item #38). The console
binary plumbs a signal-cancelled context into
tui.New; all store loads now respect cancellation instead of usingcontext.Background(). - Docker base images pinned by sha256 digest (Planning item #14).
golang:1.25-bookwormandalpine:3.20now point at concrete manifest digests; rebuilds are reproducible and supply-chain advisories tie to an exact image.
internal/agenttests (Planning item #39). Newagent_test.gocoversTriggercoalescing, the Healthy flip onCount()failure, and host pruning with/without TTL.- golangci-lint (Planning item #40).
.golangci.ymlconfigureserrcheck,staticcheck,govet,ineffassign,bodyclose,errorlint,gocritic, andrevive. CI runsgolangci-lint run ./...;make lintdoes the same locally.
scanner.Newgained a trailingprobePorts []intparameter. Passnilto keep the historical default.health.NewServergained a trailingauthToken stringparameter. Pass""to disable auth (tests do this).health.NewClientis preserved as the unauthenticated client; usehealth.NewAuthedClient(addr, token)for peers behind auth.watchdog.Newgained apublish func(health.PeerStatus)parameter alongside the existinglocalStatus. Passnilfor tests; passtracker.SetPeerin production.admin.NewServergained a trailingtrigger admin.Triggerparameter. Passnilto omitPOST /scan(501 in that case).logging.Setup(cfg, name)is unchanged from 26.05.- Off-loopback
health.addrwithout anauth_tokennow refuses to boot. Docker compose deployments need to setINVENTORY_AUTH_TOKENon both agents (with matching values).
A batched correctness, observability, and security pass — items #3, #5, #6, #7, #8, #9, #11, #15, #21, and #22 from Planning.md.
/healthnow actually reports unhealthy (Planning item #3). The agent flipsTracker.Healthytofalsewhen a cycle's DB write fails (subnet scan error or host count error), and/healthadditionally returns 503 when the most recent scan is older than3×ScanInterval. PreviouslySetHealthy(false)was never called, so Kubernetes liveness probes, the Docker compose healthcheck, and any external monitor reported "healthy" even when the agent was wedged.- Migrations now race-safe (Planning item #8). Each pending migration
upgrades its transaction to
BEGIN IMMEDIATEand re-checksschema_migrationsafter acquiring the write lock, so two agents sharing one SQLite file no longer crash on first boot. - Standalone agent ships its own config (Planning item #5).
configs/agent.jsonandconfigs/agent.docker.jsonare now in the repo, matching what the README andcmd/agent-configdefault both point at. start.shno longer mutates tracked configs (Planning item #6). Subnet overrides are written toconfigs/*.local.json(already gitignored), so re-running with a different subnet leaves a clean working tree.
- Admin dashboard surfaces backend failures (Planning item #9). Each of the three queries logs the underlying error and the page renders an inline error banner listing which sections were degraded, instead of silently showing zeros.
- Admin server emits one slog record per request (Planning item #21) with method, path, status, duration, and remote address.
- Admin server sets baseline security headers on every response
(Planning item #11):
X-Content-Type-Options: nosniff,X-Frame-Options: DENY,Referrer-Policy: no-referrer,Permissions-Policy, and aContent-Security-Policythat allows inline<style>(templates) but blocks scripts/frames/forms by default. - Scan cycles log their duration and warn when a cycle uses more
than half the configured
scan_interval(Planning item #7), so operators see cadence trouble before the ticker starts dropping firings. - Default slog logger carries an
agentfield (Planning item #22) fromlogging.Setup(cfg, name), so a combined paired-deployment stdout is attributable from the very first line. Callers incmd/agent,cmd/wintermute, andcmd/neuromancerupdated. - CI runs
govulncheckon every PR (Planning item #15) via a new Woodpecker step.
health.NewServersignature gained astaleAfter time.Durationparameter. Tests pass0to disable the freshness check; binaries pass3*cfg.Scanner.ScanInterval. Callers outside this repo will need a one-line update.logging.Setupsignature gained aname stringparameter. Pass""for the previous behaviour.
- Default scanner timeout lowered from 30 s to 2 s and per-host
probing is now concurrent across the 4 probe ports instead of
sequential (Planning item #2). Together these change worst-case
per-host probe time from 4 × timeout ≈ 120 s to ≈ timeout (2 s),
which keeps a /24 sweep comfortably inside the default 5 min
scan_interval. The previous defaults caused the watchdog's freshness check to fire continuously on any network with a meaningful fraction of dead hosts.
scanner.probefans out one goroutine per probe port and short-circuits the remaining dials via context cancellation as soon as the first port answers. Hosts with multiple open probe ports may now record any one of them, not deterministically the lowest-numbered.- README
scanner.timeoutrow updated to reflect the new default.
- Scanner now persists the open port it discovered during liveness
probing (Planning item #1). Previously the
PortRepowas never written to from anywhere in the codebase, so both the web admin console and the TUI's "Open Ports" view were always empty in production. Each successful probe now writes onemodels.Portrow for the answering port, keyed on(host_id, port, tcp)via the existing upsert.
scanner.Newandagent.Newsignatures gained astore.PortStoreparameter (immediately afterstore.HostStore). Callers incmd/agent,cmd/wintermute, andcmd/neuromancerupdated. Anilport store is permitted and yields the old liveness-only behaviour.scanner.probenow returns(port int, ok bool)instead ofboolso the calling goroutine knows which port to record.
Baseline. Planning.md adopted; ChangeLog.md introduced. No code changes.