Skip to content

Daemon reference

Lifecycle, scanners, signals, hardening

Edit this page
On this page

A single static Go binary, loomscoped. It is configured entirely by environment variable — no config file, no command-line flags — and it makes only outbound connections.

Lifecycle

  1. Boot. Reads the environment. Exits immediately if LOOMSCOPE_DAEMON_API_KEY is unset — a scanner that starts without credentials and never scans is worse than one that refuses to start.
  2. Load signatures. Core signatures are compiled in; custom YAML is read from LOOMSCOPE_SIGNATURES_DIR. Strict validation: an unknown field or a bad enum fails the file with a structured error.
  3. Register with the control plane, reporting name, version, platform, capabilities and any LOOMSCOPE_NETWORK_CIDRS.
  4. Heartbeat every 30 seconds, reporting status and signature counts.
  5. Long-poll for work, up to 60 seconds per request.
  6. Scan, posting observation batches as it goes and a final batch at the end.
  7. On SIGHUP, reload custom signatures without dropping work.
  8. On SIGTERM, finish the current batch and exit.

Environment

See Configuration for the full table. The two that must be set are LOOMSCOPE_SERVER_URL and LOOMSCOPE_DAEMON_API_KEY.

Scanners

PackageWhat it does
icmpEcho sweep, with three privilege modes and a TCP fallback
arpReads the OS neighbour cache — finds silent devices on the segment
tcpConnect scan, top 1 000 ports by default
udpProbe scan, top 50 ports by default
dnsReverse lookups and hostname resolution
namesmDNS and NetBIOS, for the hosts reverse DNS cannot name
tracerouteThe routers between here and a scanned range, without a credential
snmpv1/v2c/v3: system, interfaces, LLDP/CDP, VLANs, routes, LAG
tlsHandshake, chain capture and trust verification
dockerContainer inventory from the local socket, opt-in
netflowUDP listeners for NetFlow v5/v9, IPFIX and sFlow
ouiResolves a MAC address to its manufacturer, from a compiled-in table
nmapInvokes the operator's own nmap, when the host has one

Address handling is netip.Addr throughout, and both address families are covered by fixtures — IPv6 is not a mode, it is the same path.

Platforms, and what each can see

The daemon runs natively on Linux, Windows and macOS. Every release attaches binaries for linux/amd64, linux/arm64, windows/amd64, darwin/amd64 and darwin/arm64, with SHA256SUMS.

loomscoped --version prints the version, the platform, the signature counts, and what that build can actually do where it is running:

loomscoped 0.13.1 linux/amd64
signatures: 10 core, 0 custom
neighbour cache (MAC addresses): yes
nmap: not found on PATH

The same set is reported to the control plane at registration and shown on Settings → Daemons, with absent capabilities drawn rather than omitted. That is deliberate: a daemon that can do less must not look identical to one that can do more, or a map with no MAC addresses and a map whose hosts genuinely have none read the same.

MAC addresses without privilege

MAC attribution reads the operating system's own neighbour cache rather than sending ARP frames — netlink RTM_GETNEIGH on Linux, GetIpNetTable2 on Windows, the routing socket on macOS. It needs no raw socket, no Npcap and no Administrator, because the machine has already resolved those addresses in the course of talking to them.

The trade is worth stating: a cache holds only neighbours the host has spoken to recently, so it finds fewer MACs than an active sweep would. It supplements the ICMP and TCP sweeps; it does not replace them.

The Linux path also covers IPv6 neighbours, which /proc/net/arp — what this used to read — never could.

There is no installer or service wrapper. Run the binary under sc.exe or launchd yourself. See ADR-0025.

A layer-3 map without a credential

The layer-3 topology is built from SNMP routing tables, which is the better source when it is available — and unavailable more often than not. An estate that will not hand out a community string produces no map at all.

A traceroute asks nothing of the devices it finds. Routers are obliged to answer a low hop limit with an ICMP Time Exceeded, and that is the whole mechanism. The router that answered one hop before the destination is recorded as reaching that range, with source = 'traceroute' so a reader can tell an inference from a routing table that was actually read.

Once per scanned range, not once per host. The path to a range is a property of the range; tracing to each of two hundred hosts would send two hundred times the packets to learn the same three routers.

It needs CAP_NET_RAW. Not because raw sockets are traditional here, but because the unprivileged datagram ICMP socket does not deliver Time Exceeded through the ordinary read path — Linux puts those on the socket's error queue, which a plain read never sees. Measured rather than assumed: the same probe against the same router returns the reply on ip4:icmp and times out on udp4.

A daemon without the capability skips the trace and says so. Returning an empty path instead would draw a flat network where the truth is "we were unable to look", and those are different maps.

Naming a host DNS will not name

Reverse DNS is asked first and fails constantly on the networks this product is bought to inventory: printers, cameras, badge readers, IP phones and much of a Windows estate have no PTR record, because nobody ever made one. Those hosts land in the inventory as an address and nothing else, which is the row an operator cannot act on.

Two protocols are asked afterwards, and only for the addresses reverse DNS could not name, so an estate with working DNS pays nothing:

  • mDNS (RFC 6762) on UDP 5353, first, because its answer is the name someone chose rather than fifteen characters that may be a truncation.
  • NetBIOS name service (RFC 1002) on UDP 137 — what nbtstat -A does, and how Windows machines have announced themselves since before DNS was everywhere. IPv4 only; no implementation carries it to IPv6.

Both are asked as a single unicast packet to the host itself, never to a multicast group or a broadcast address. That matters twice over: a multicast query puts a packet on every segment and then has to attribute the answers back to addresses by matching records, which guesses; and a unicast query adds one packet per unnamed host to a scan that has already sent hundreds. Neither needs a privilege.

The NetBIOS answer is the entry with suffix 0x00 that is not a group. The group entry is the workgroup, and reporting it would put WORKGROUP on every Windows row in the estate.

nmap

Loomscope never ships nmap. No image contains it and the air-gap bundle does not carry it. If the daemon's host has one on PATH, the daemon finds it, reports the version it found, and a scan can ask for it.

That is a licensing decision as much as a technical one: since 7.90 nmap ships under the Nmap Public Source License, which is not OSI-approved and restricts redistribution inside a product that is sold. Using the copy you installed, under whatever licence applies to you, carries no such obligation — and leaves nmap's fingerprint database on your update cadence rather than frozen into one of our releases.

Scripts are categories, never names or paths. A scan may ask for default, safe, version or vuln. --script accepts a filesystem path, and a scan definition arrives over the API, so nothing else reaches the command line.

external is excluded from every selection, whatever you ask for. nmap's safe category is about not crashing the target and says nothing about where data goes: 46 of its scripts are also tagged external. dns-blacklist queries public DNS blacklists; hostmap-robtex queries robtex.com. Running those would send your addresses off your network, which is the one thing this product promises it does not do.

Two things a scan will not report from nmap:

  • A service nmap named from its port-number table rather than by speaking to it. A closed 5432 is labelled postgresql and a closed 9090 zeus-admin; recording those would fill an inventory with fiction. Only probed identifications become services.
  • A partial run presented as a whole one. A run cut short yields the hosts nmap reached and is recorded as incomplete.

Results are stored with signature_source = 'nmap', separately from our own detections, so a reader can always tell which engine made a claim. See ADR-0024.

The ICMP modes

The one privileged operation the daemon needs is sending an ICMP echo. It detects at boot which of three modes is available and uses the first that works:

ModeRequiresCoverage
Unprivileged pingnet.ipv4.ping_group_range includes its groupFull
Raw socketCAP_NET_RAW effectiveFull
TCP fallbackNothingPartial — 80, 443, 22 only

A capability granted by Docker to a non-root process lands in the permitted set but never the effective one, which is why the shipped compose file runs the daemon as user: "0:0" with everything dropped except NET_RAW. systemd units use AmbientCapabilities, which does reach a non-root user.

In fallback mode the daemon logs a warning. Check for it:

bash
docker logs loomscope-daemon | grep "falling back to TCP"

See Deploying daemons.

Signals

SignalEffect
SIGHUPReload custom signatures from LOOMSCOPE_SIGNATURES_DIR
SIGTERMGraceful shutdown: finish the current batch, then exit
SIGINTSame as SIGTERM
bash
docker kill --signal=HUP loomscope-daemon
sudo systemctl reload loomscope-daemon

Container security defaults

yaml
daemon:
  image: ghcr.io/bendaamerahmed/loomscope-daemon:0.13.1 # pinned, never :latest
  user: "0:0"
  cap_drop: [ALL]
  cap_add: [NET_RAW]
  network_mode: host
  read_only: true
  tmpfs: [/tmp]
  security_opt: [no-new-privileges:true]
  volumes:
    - daemon-config:/etc/loomscope:ro

Never privileged: true. CAP_NET_ADMIN is added only where netlink or BPF is in use; CAP_BPF and CAP_PERFMON are neither granted nor needed, since flow data arrives as records from network devices rather than from a kernel probe.

Host networking is required for ARP, which needs layer-2 adjacency, and for the flow listeners to receive traffic addressed to the node.

Logging

Structured JSON on stdout via zerolog. Set LOG_LEVEL to trace, debug, info, warn or error.

Credentials, API keys and SNMP community strings are redacted before they reach a log line.

Fields worth grepping:

FieldUse
daemon_idCorrelate with the control plane
session_idFollow one scan end to end
cidrWhich range a message is about

Resource profile

1 vCPU and 512 MB is enough for a daemon scanning a few /24s. Scanning is network-bound rather than CPU-bound; the memory ceiling is the observation buffer between flushes.

Set container limits anyway — the compose file sets none, and a scan against an unexpectedly large range is the case where that matters.

Building from source

bash
cd services/daemon
go build ./cmd/loomscoped
go test -race ./...

Cross-compiling needs nothing but a GOOS/GOARCH pair — there is no cgo in the daemon:

bash
GOOS=windows GOARCH=amd64 go build ./cmd/loomscoped
GOOS=darwin  GOARCH=arm64 go build ./cmd/loomscoped

CI builds and runs the daemon's tests on ubuntu-latest, windows-latest and macos-latest, and cross-compiles the two targets no runner covers.

Race tests are required for anything touching goroutines, which is most of the scanning path.