Deploying daemons
Capabilities, systemd, one daemon per site
Edit this pageOn this page
A daemon has to sit where the networks are. Everything else about deploying one is straightforward; the part that is not, and that silently costs you an entire scan when you get it wrong, is the single privileged operation it needs.
Enrolling one
Create the daemon in the UI first — Settings → Daemons → Add daemon — because the key is generated there and shown once. Only its hash is stored, so a key you did not copy is replaced rather than recovered.
Then set LOOMSCOPE_DAEMON_API_KEY on the host, along with a distinct
LOOMSCOPE_DAEMON_NAME. The daemon registers itself on first boot and
appears as online with a heartbeat within about 30 seconds.
The one privileged operation
The scan needs exactly one thing an unprivileged process cannot do: send an
ICMP echo. Everything else — ARP from /proc/net/arp, TCP and UDP connect
scans, the flow listeners above port 1024 — needs nothing.
Never privileged: true. There are two supported ways to grant it, and the
daemon prefers the first whenever it is available.
Unprivileged ping (preferred)
Set on the host:
sysctl -w net.ipv4.ping_group_range="0 2147483647"The daemon then uses an ICMP datagram socket as a non-root user with no capabilities at all.
It cannot be set inside the compose file. The daemon uses the host network namespace, and runc refuses a namespaced sysctl there — so it has to be set on the host itself.
Root in the container with CAP_NET_RAW (the shipped default)
What infra/docker-compose.yml sets, because it works on any host:
user: "0:0"
cap_drop: [ALL]
cap_add: [NET_RAW]
read_only: true
security_opt: [no-new-privileges:true]user: "0:0" is not decoration. The image runs as uid 65532, and a
capability added by Docker lands in the permitted set but never the effective
one for a non-root process — so before 0.3.3 the sweep could not open its
socket and a scan found zero hosts, with nothing in the logs saying why.
The systemd units were never affected: they use AmbientCapabilities, which
does reach a non-root process.
What happens with neither
The sweep degrades to TCP connect probes on 80, 443 and 22. That finds web servers and misses printers, cameras, switches and anything else that is up but serving nothing. The daemon logs a warning saying exactly that:
docker logs loomscope-daemon | grep "falling back to TCP"If you see it, discovery is running at a fraction of its coverage and no number anywhere in the UI will look wrong.
What is not needed
CAP_BPF and CAP_PERFMON are not granted and are not required. Flow data
comes from NetFlow, IPFIX and sFlow records that network devices send to the
daemon's UDP listeners; the eBPF collector that would have wanted those
capabilities attached no probes and was removed in 0.3.1.
Deployment shapes
Docker with host networking
The compose default. Host networking is needed for ARP — which requires layer-2 adjacency — and for the NetFlow listeners to receive traffic addressed to the node.
systemd on the host
Units are in infra/systemd/. They are hardened with ProtectSystem=strict,
a syscall filter, and only CAP_NET_RAW plus CAP_NET_ADMIN, granted as
ambient capabilities so they reach the unprivileged loomscope user.
sudo install -m 0640 -o root -g loomscope infra/systemd/daemon.env.example /etc/loomscope/daemon.env
sudo systemctl enable --now loomscope-daemonThe env file contains an API key, so it is deliberately not world-readable.
The templated unit sets LOOMSCOPE_DAEMON_NAME from the instance name, so:
sudo systemctl enable --now loomscope-daemon@parisregisters a daemon called paris. Override it in the per-instance env file
if you want a different label.
Kubernetes
A DaemonSet, because which segments a scanner can see depends on which
nodes it runs on. Restrict with nodeSelector rather than assuming every
node is a useful vantage point. See Kubernetes.
A native binary on Windows or macOS
For the branch office running Windows Server and the studio running a Mac, where introducing Docker for one small binary is the reason the scanner never gets deployed.
Every release attaches binaries with SHA256SUMS — windows/amd64,
darwin/amd64, darwin/arm64, and both Linux architectures. Verify the
checksum, then set the same two variables the container uses:
$env:LOOMSCOPE_SERVER_URL = "https://loomscope.example.com"
$env:LOOMSCOPE_DAEMON_API_KEY = "lsd_..."
.\loomscope-daemon_0.13.1_windows_amd64.exeAsk it what it can do before you rely on it:
loomscoped 0.13.1 windows/amd64
signatures: 10 core, 0 custom
neighbour cache (MAC addresses): yes
nmap: not found on PATHWhat differs from Linux. MAC attribution reads the operating system's own neighbour cache rather than sending ARP frames, so it needs no raw socket, no Npcap and no Administrator — but a cache only holds neighbours the host has spoken to recently, so it finds fewer devices than an active sweep. ICMP on Windows has no unprivileged mode and needs Administrator; without it the daemon still discovers hosts by TCP and DNS, and says so.
Whatever the platform can and cannot do is reported to the control plane and shown on Settings → Daemons, with the absent capabilities drawn rather than left out. A daemon that can see less must not look identical to one that can see more.
There is no installer and no service wrapper yet. Run it under
sc.exe on Windows or a launchd plist on macOS. See
ADR-0025.
Giving a daemon nmap
Optional, and Loomscope never ships it: no image contains nmap and the
air-gap bundle does not carry it. The daemon runs whichever nmap it finds
on its own PATH, and reports the version the next time it registers. A
scan can then ask for it — see
the REST API reference or the checkbox on
Discovery, which is disabled with an explanation on daemons that have
none.
Where "its own PATH" is depends on how the daemon runs, and the difference matters more than it sounds:
A native binary or a systemd unit — install nmap from your distribution. The daemon shares the host's filesystem and finds it.
A container — installing nmap on the host does nothing. The daemon's
runtime is distroless/static, which has no shell and no package manager,
and it cannot see the host's filesystem. Build your own image from the
published binary:
FROM alpine:3.20
RUN apk add --no-cache nmap nmap-scripts nmap-nselibs
COPY --from=ghcr.io/bendaamerahmed/loomscope-daemon:0.13.4 /usr/local/bin/loomscoped /usr/local/bin/loomscoped
USER 65532:65532
ENTRYPOINT ["/usr/local/bin/loomscoped"]Building it yourself is also the licensing answer: nmap is NPSL-licensed and we do not redistribute it, but nothing stops you assembling an image for your own estate. See ADR-0024.
Then point the deployment at it. Under Compose, set the daemon service's
image. Under Helm, set daemon.image, which replaces that one
reference and leaves the server and the worker on the chart's own images:
daemon:
image: my.registry/loomscoped-nmap:0.13.4Settings → Daemons shows which nmap each scanner found, or — nmap
when it has none, so you can tell a daemon that was given one from a
daemon that was meant to be.
Scans ask for NSE categories, never script names or paths, and nmap's
external category is excluded from every selection: 46 scripts nmap
classes as safe also query third parties like robtex and public DNS
blacklists, and "safe" there means "will not crash the target", not "stays
on your network".
Giving a daemon something to collect
The daemon has carried a NetFlow v5/v9/IPFIX and sFlow collector since
Phase 3. It listens, and it works — but a collector with nothing exporting
to it leaves the same empty flows table as no collector at all, and that
is where most evaluations start: NetFlow comes from routers and switches,
and a single node running containers has neither.
loomscope-flow-exporter fills that gap. It is softflowd, packaged: it
watches one interface and exports what it sees to the daemon's collector,
so "who talks to whom" has an answer on a host that is the whole network.
It is a separate image from the daemon on purpose. The daemon sends probes; this captures packets for the life of the process, which is a much larger claim on a machine. Keeping them apart means enabling one does not enable the other.
It cannot guess which interface to watch, so it is opt-in on both install paths and refuses to start without one. Find yours:
ip -o link showOn a Kubernetes node the pod network is usually cni0 or flannel.1; on a
router or a bridge it is the segment you actually want to see.
Docker Compose
LOOMSCOPE_FLOW_INTERFACE=eth0 \
docker compose -f infra/docker-compose.yml --profile flows up -dWithout --profile flows the service is not created, so the default stack
is unaffected.
Kubernetes
flowExporter:
enabled: true
interface: cni0The chart refuses to render if interface is unset, or if daemon.enabled
is false — an exporter with no collector listening sends flows nowhere and
never errors, which is the failure this is trying not to let you configure.
What arrives, and when
Flows are delivered on their own schedule, every 60 seconds by default,
independent of when a scan runs. Change it with
LOOMSCOPE_FLOW_DELIVERY_INTERVAL on the daemon — a Go duration, so 30s
or 2m.
That is the delivery interval, not the age of what you see. Traffic is
aggregated into five-minute buckets and a bucket is only sent once it has
closed, so expect the newest edge on a map to be five to seven minutes
old. Shortening the interval does not move that floor: the flows table
keys on five-minute boundaries, so a narrower bucket would stop
aggregating.
They used to leave only attached to a scan's observation batch, and scans are hourly by default. A daemon therefore held an hour of traffic in a fixed buffer and discarded whatever did not fit: measured on a live single-node cluster, 18 728 buckets dropped per hour to deliver 10 000.
Two things changed. Delivery is its own loop, so the buffer holds a minute rather than an hour. And each conversation is now recorded once, pointing client → server, instead of twice: an exporter reports both the request and the reply, and the reply — addressed to the client's ephemeral port — matches no service and was discarded on arrival after occupying the buffer all the way there. On that same cluster it was 97.6% of what the daemon delivered.
If a daemon still cannot keep up, Settings → Daemons says so under its name, with a count and when it last happened. A dependency map built from what survived is partial, and this is what tells you that it is.
The exporter's own LOOMSCOPE_FLOW_MAXLIFE, 60 seconds by default,
controls how long softflowd holds a flow before exporting it to the
daemon — a separate hop from the daemon's delivery to the control plane.
One daemon per site
Bind each daemon to a site, either from the site page in the UI or with
LOOMSCOPE_DAEMON_SITE_CODE before it first registers. If both are set, the
UI assignment wins — an operator's explicit choice should not be overridden
by an environment variable somebody edited months ago.
Scanning without registering a network
LOOMSCOPE_NETWORK_CIDRS lets a daemon scan ranges that were never declared
in the UI:
LOOMSCOPE_NETWORK_CIDRS=10.0.0.0/24,fd00::/64This exists for immutable deployments where the ranges are part of the host's configuration. It is the one exception to "nothing is scanned until a network exists", and it is deliberately opt-in per host.
Custom service signatures
Drop YAML into /etc/loomscope/signatures.d/ (or wherever
LOOMSCOPE_SIGNATURES_DIR points) and send SIGHUP:
docker kill --signal=HUP loomscope-daemon
# or
sudo systemctl reload loomscope-daemonValidation is strict: an unknown field or a bad enum value fails at load with an explicit error rather than being silently ignored. The heartbeat reports how many core and custom signatures loaded, so the UI can tell you the file took effect. See Service signatures.
Checking a daemon is healthy
| Check | Where |
|---|---|
| Registered and alive | Settings → Daemons — online, with a recent heartbeat |
| Signatures loaded | Same page — core and custom counts, reported by the heartbeat |
| ICMP privilege | docker logs loomscope-daemon | grep "falling back to TCP" |
| Reaching the server | Daemon logs; a 401 means the key is wrong or was revoked |
A daemon quiet for more than five minutes is a problem. There is no alert for it yet — see Monitoring.
Troubleshooting
The daemon exits immediately. LOOMSCOPE_DAEMON_API_KEY is unset. That
is a deliberate hard failure: a scanner that starts without credentials and
never scans is worse than one that refuses to start.
It registers, then nothing is scanned. A network must exist in the UI before anything is probed. Check Networks, then check the daemon has layer-2 reachability to the range.
It logs 401. The key is wrong, or the daemon was deleted in the UI. Re-enrol it and replace the key.
Hosts appear and then disappear. Two daemons registered with the same
name are the usual cause — each LOOMSCOPE_DAEMON_NAME must be distinct.