Skip to content

Deploying daemons

Capabilities, systemd, one daemon per site

Edit this page
On 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:

bash
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:

yaml
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:

bash
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.

bash
sudo install -m 0640 -o root -g loomscope infra/systemd/daemon.env.example /etc/loomscope/daemon.env
sudo systemctl enable --now loomscope-daemon

The 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:

bash
sudo systemctl enable --now loomscope-daemon@paris

registers 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:

powershell
$env:LOOMSCOPE_SERVER_URL   = "https://loomscope.example.com"
$env:LOOMSCOPE_DAEMON_API_KEY = "lsd_..."
.\loomscope-daemon_0.13.1_windows_amd64.exe

Ask 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 PATH

What 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:

dockerfile
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:

yaml
daemon:
  image: my.registry/loomscoped-nmap:0.13.4

Settings → 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:

bash
ip -o link show

On 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

bash
LOOMSCOPE_FLOW_INTERFACE=eth0 \
  docker compose -f infra/docker-compose.yml --profile flows up -d

Without --profile flows the service is not created, so the default stack is unaffected.

Kubernetes

yaml
flowExporter:
  enabled: true
  interface: cni0

The 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:

bash
LOOMSCOPE_NETWORK_CIDRS=10.0.0.0/24,fd00::/64

This 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:

bash
docker kill --signal=HUP loomscope-daemon
# or
sudo systemctl reload loomscope-daemon

Validation 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

CheckWhere
Registered and aliveSettings → Daemons — online, with a recent heartbeat
Signatures loadedSame page — core and custom counts, reported by the heartbeat
ICMP privilegedocker logs loomscope-daemon | grep "falling back to TCP"
Reaching the serverDaemon 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.