Daemon reference
Lifecycle, scanners, signals, hardening
Edit this pageOn 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
- Boot. Reads the environment. Exits immediately if
LOOMSCOPE_DAEMON_API_KEYis unset — a scanner that starts without credentials and never scans is worse than one that refuses to start. - 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. - Register with the control plane, reporting name, version, platform,
capabilities and any
LOOMSCOPE_NETWORK_CIDRS. - Heartbeat every 30 seconds, reporting status and signature counts.
- Long-poll for work, up to 60 seconds per request.
- Scan, posting observation batches as it goes and a final batch at the end.
- On
SIGHUP, reload custom signatures without dropping work. - 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
| Package | What it does |
|---|---|
icmp | Echo sweep, with three privilege modes and a TCP fallback |
arp | Reads the OS neighbour cache — finds silent devices on the segment |
tcp | Connect scan, top 1 000 ports by default |
udp | Probe scan, top 50 ports by default |
dns | Reverse lookups and hostname resolution |
names | mDNS and NetBIOS, for the hosts reverse DNS cannot name |
traceroute | The routers between here and a scanned range, without a credential |
snmp | v1/v2c/v3: system, interfaces, LLDP/CDP, VLANs, routes, LAG |
tls | Handshake, chain capture and trust verification |
docker | Container inventory from the local socket, opt-in |
netflow | UDP listeners for NetFlow v5/v9, IPFIX and sFlow |
oui | Resolves a MAC address to its manufacturer, from a compiled-in table |
nmap | Invokes 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 PATHThe 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 -Adoes, 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
postgresqland a closed 9090zeus-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:
| Mode | Requires | Coverage |
|---|---|---|
| Unprivileged ping | net.ipv4.ping_group_range includes its group | Full |
| Raw socket | CAP_NET_RAW effective | Full |
| TCP fallback | Nothing | Partial — 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:
docker logs loomscope-daemon | grep "falling back to TCP"See Deploying daemons.
Signals
| Signal | Effect |
|---|---|
SIGHUP | Reload custom signatures from LOOMSCOPE_SIGNATURES_DIR |
SIGTERM | Graceful shutdown: finish the current batch, then exit |
SIGINT | Same as SIGTERM |
docker kill --signal=HUP loomscope-daemon
sudo systemctl reload loomscope-daemonContainer security defaults
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:roNever 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:
| Field | Use |
|---|---|
daemon_id | Correlate with the control plane |
session_id | Follow one scan end to end |
cidr | Which 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
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:
GOOS=windows GOARCH=amd64 go build ./cmd/loomscoped
GOOS=darwin GOARCH=arm64 go build ./cmd/loomscopedCI 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.