Disco¶
Automatic service discovery and name resolution for offline networks.
What it does¶
Disco solves a basic problem in an offline network: nodes need to find each other by name without DNS servers, without manual configuration, and without internet. When a Raspberry Pi boots in the field, other nodes should discover it and its services automatically.
The daemon broadcasts its identity (hostname, IP addresses, running services) over UDP. Every other node running disco receives these broadcasts and builds a local cache. Name resolution is wired into the operating system through a custom NSS module, so standard tools like ssh, curl, and ping resolve discovered hosts without any extra configuration.
Why it exists¶
Offline Lab nodes run on a local WiFi network with no internet. There is no external DNS to resolve hostnames. Hardcoding IPs doesn't scale and breaks when nodes change. Disco fills this gap with zero-configuration service discovery designed for resource-constrained devices.
Design priorities:
- Minimal footprint - under 10 MB RAM, designed for Raspberry Pi Zero 2W
- Zero configuration - nodes discover each other on first broadcast
- Native integration - NSS module means standard tools work out of the box
- Offline-first - no dependencies on external services
How it works¶
Each node runs disco-daemon, which does three things:
- Broadcast - periodically sends a UDP packet (port 5354) with its hostname, IP addresses, and detected services
- Listen - receives broadcasts from other nodes and maintains an in-memory cache
- Resolve - answers name queries from the local NSS module over a Unix domain socket
Service detection is automatic. The daemon scans local TCP ports and maps them to service names (port 80 becomes www, port 25 becomes smtp, and so on). Discovered services are included in broadcasts so other nodes know what's running where.
Node A Node B
| |
|-- UDP broadcast (announce) -->|
| hostname: web1 |
| addresses: [10.0.0.1] |
| services: {www: 80} |
| |
|<-- UDP broadcast (announce) --|
| from Node B |
| |
Both nodes update their caches
Components¶
| Component | Description |
|---|---|
| disco-daemon | Go daemon. Handles broadcast, listening, service detection, name resolution. ~6 MB binary. |
| disco | CLI tool for querying and managing the daemon. Separate binary to avoid memory overhead in scripts. |
| libnss_disco.so.2 | C NSS module. Integrates with glibc so gethostbyname() and friends resolve discovered hosts. |
| disco-gps-broadcaster | Optional. Broadcasts GPS time for clock synchronization on airgapped networks. |
Optional features¶
DNS server. Disco can serve DNS for the .disco domain, allowing standard DNS clients to resolve discovered hosts without the NSS module.
Time synchronization. On airgapped networks, NTP is unavailable. Disco can receive time from GPS broadcaster devices and synchronize the local clock. Requires at least two agreeing GPS sources before adjusting.
Location sharing. GPS broadcasters also share location data (coordinates, estimated country and timezone) via LOCATION_ANNOUNCE messages. All nodes maintain a location store with a 2-minute stale timeout. Location is always enabled when the daemon is running.
Message signing. Broadcasts can be signed with HMAC-SHA256 to prevent spoofing and replay attacks. Nodes verify signatures against a shared key before accepting announcements.
Protocol¶
This section describes the wire protocol for anyone building a client implementation. Disco uses two transport channels: UDP broadcast for discovery and time synchronization, and Unix domain sockets for local name queries.
Transport¶
| Channel | Address | Purpose |
|---|---|---|
| UDP broadcast | 255.255.255.255:5354 (configurable) | Node announcements, time broadcasts, location broadcasts |
| Unix domain socket | /run/disco.sock (configurable) | Local name queries from NSS module |
All messages are JSON-encoded UTF-8. Each UDP datagram contains exactly one message. Unix socket messages are length-prefixed (4-byte big-endian length, then JSON body).
Message types¶
ANNOUNCE¶
Sent by every node at a configurable interval (default 30 seconds). Contains the node's identity and services.
{
"type": "ANNOUNCE",
"message_id": "web1-1708123456789000000",
"timestamp": 1708123456,
"hostname": "web1",
"ips": ["192.168.1.10", "10.0.0.1"],
"services": [
{"name": "www", "port": 80, "addr": "192.168.1.10"},
{"name": "ssh", "port": 22, "addr": "192.168.1.10"}
],
"ttl": 3600,
"signature": {
"signature": "a3f2b8...",
"signer": "node-abc123",
"nonce": "c9d4e1...",
"timestamp": 1708123456
}
}
| Field | Type | Description |
|---|---|---|
| type | string | Always "ANNOUNCE" |
| message_id | string | Unique ID: {hostname}-{unix_nano}. Used for deduplication. |
| timestamp | int64 | Unix seconds when the message was created |
| hostname | string | Node's hostname |
| ips | string[] | All non-loopback IPv4 addresses on the node |
| services | ServiceInfo[] | Detected services on this node |
| ttl | int64 | How long other nodes should cache this record (seconds). Default: 3600. |
| signature | object or null | HMAC-SHA256 signature (omitted when security is disabled) |
ServiceInfo:
| Field | Type | Description |
|---|---|---|
| name | string | Service name (e.g. "www", "smtp", "ssh") |
| port | int | TCP port number |
| addr | string | Local IP address the service is bound to |
TIME_ANNOUNCE¶
Sent by GPS broadcaster devices to provide time references for clock synchronization.
{
"type": "TIME_ANNOUNCE",
"message_id": "gps-node-1-1708123456789000000",
"timestamp": 1708123456789000000,
"source_id": "gps-node-1",
"clock_info": {
"stratum": 1,
"precision": -20,
"root_delay": 0.0,
"root_dispersion": 0.0001,
"reference_id": "GPS",
"reference_time": 1708123456789000000
},
"leap_indicator": 0,
"signature": {
"signature": "b7c3d9...",
"signer": "gps-node-1",
"nonce": "e1f2a3...",
"timestamp": 1708123456
}
}
| Field | Type | Description |
|---|---|---|
| type | string | Always "TIME_ANNOUNCE" |
| message_id | string | Unique ID for deduplication |
| timestamp | int64 | Nanosecond-precision Unix timestamp from GPS |
| source_id | string | Identifier of the GPS source |
| clock_info | ClockInfo | NTP-like clock quality metrics |
| leap_indicator | int | Leap second indicator (0 = none, 1 = last minute has 61 seconds, 2 = 59 seconds) |
| signature | object or null | HMAC-SHA256 signature |
ClockInfo:
| Field | Type | Description |
|---|---|---|
| stratum | int | NTP stratum (1 = GPS, 2 = synced to stratum-1, etc.) |
| precision | int | Log2 of clock precision in seconds (-20 = ~1 microsecond) |
| root_delay | float64 | Round-trip delay to reference clock (seconds) |
| root_dispersion | float64 | Maximum error relative to reference clock (seconds) |
| reference_id | string | Reference clock identifier (e.g. "GPS") |
| reference_time | int64 | Reference timestamp (nanoseconds) |
LOCATION_ANNOUNCE¶
Sent by GPS broadcaster devices to share location data with the network. Nodes can use this for timezone detection, country estimation, and geofencing.
{
"type": "LOCATION_ANNOUNCE",
"message_id": "gps-node-1-1708123456789000000",
"source_id": "gps-node-1",
"location": {
"latitude": 52.3676,
"longitude": 4.9041,
"altitude": 12.5,
"fix": true,
"satellites": 8
},
"estimated_country": "NL",
"estimated_timezone": "Europe/Amsterdam",
"signature": {
"signature": "d4e5f6...",
"signer": "gps-node-1",
"nonce": "a7b8c9...",
"timestamp": 1708123456
}
}
| Field | Type | Description |
|---|---|---|
| type | string | Always "LOCATION_ANNOUNCE" |
| message_id | string | Unique ID for deduplication |
| source_id | string | Identifier of the GPS source |
| location | LocationInfo | GPS position data |
| estimated_country | string | ISO 3166-1 alpha-2 country code (e.g. "NL") |
| estimated_timezone | string | IANA timezone name (e.g. "Europe/Amsterdam") |
| signature | object or null | HMAC-SHA256 signature |
LocationInfo:
| Field | Type | Description |
|---|---|---|
| latitude | float64 | Decimal degrees (WGS 84) |
| longitude | float64 | Decimal degrees (WGS 84) |
| altitude | float64 | Meters above sea level |
| fix | bool | Whether the GPS has a valid position fix |
| satellites | int | Number of satellites in use |
NSS query protocol¶
The Unix domain socket uses a request-response pattern. Each query is a JSON object with a type field. The daemon responds with a JSON object.
Query by name¶
Response (success):
Response (not found):
Query by address¶
Response format is the same as query by name.
List all hosts¶
Response:
{
"type": "OK",
"request_id": "list-1",
"hosts": [
{
"hostname": "web1",
"addresses": ["192.168.1.10"],
"status": "healthy",
"services": {"www": "80"},
"last_seen": 1708123456,
"last_seen_ago": "2m",
"is_static": false
}
]
}
List all services¶
Response:
{
"type": "OK",
"request_id": "list-2",
"services": [
{"name": "www", "protocol": "tcp", "port": 80, "hosts": ["web1"], "status": "healthy"}
]
}
Location status¶
Response:
{
"type": "OK",
"request_id": "loc-1",
"sources": [
{
"source_id": "gps-node-1",
"latitude": 52.3676,
"longitude": 4.9041,
"altitude": 12.5,
"fix": true,
"satellites": 8,
"estimated_country": "NL",
"estimated_timezone": "Europe/Amsterdam",
"last_seen_ago": "30s"
}
],
"count": 1
}
Message signing¶
When security is enabled, messages include a signature object:
| Field | Type | Description |
|---|---|---|
| signature | string | Hex-encoded HMAC-SHA256 of nonce + payload |
| signer | string | Node ID of the signer |
| nonce | string | 32-character hex-encoded random nonce |
| timestamp | int64 | Unix seconds when the signature was created |
Signing algorithm:
- Serialize the message fields (excluding
signature) as deterministic JSON - Generate a random 16-byte nonce, hex-encode it
- Compute HMAC-SHA256 over
nonce_bytes + message_jsonusing the shared secret - Hex-encode the HMAC digest as the signature
Verification:
- Look up the shared secret for the signer's node ID
- Reject if timestamp is older than 300 seconds or in the future
- Recompute HMAC over
nonce_bytes + message_jsonusing the signer's shared secret - Compare using constant-time equality
Rate limiting¶
Outgoing broadcasts are rate-limited with a token bucket algorithm: 10 messages per second, burst of 10. Incoming messages pass through a duplicate filter with a 5-minute TTL keyed on message_id. Duplicates are silently dropped.
Host record lifecycle¶
- A node receives an ANNOUNCE and creates a record with status
healthy - The record's TTL counts down (default 3600 seconds)
- If refreshed by a new broadcast, TTL resets
- After TTL expires, status changes to
stale - After a grace period (default 60 seconds), status changes to
lost - Lost records are eventually removed from the cache
Building a client¶
A minimal disco client needs to:
- Listen on UDP port 5354 for broadcast messages
- Parse JSON datagrams and check the
typefield - Deduplicate by
message_idto avoid processing the same announcement twice - Extract host information from
hostname,ips, andservicesfields - Cache records with a TTL from the
ttlfield - Periodically announce its own identity using the ANNOUNCE format
Pseudo-code for a listener:
socket = bind_udp("0.0.0.0:5354")
seen_ids = cache_with_ttl(5 minutes)
loop:
data = socket.recv()
msg = json_decode(data)
if msg.message_id in seen_ids:
continue
seen_ids.add(msg.message_id)
if msg.type == "ANNOUNCE":
store_record(msg.hostname, msg.ips, msg.services, msg.ttl)
elif msg.type == "TIME_ANNOUNCE":
handle_time(msg.timestamp, msg.source_id, msg.clock_info)
elif msg.type == "LOCATION_ANNOUNCE":
store_location(msg.source_id, msg.location, msg.estimated_country, msg.estimated_timezone)
Pseudo-code for announcing:
socket = create_udp_socket()
interval = 30 seconds
loop every interval:
msg = {
type: "ANNOUNCE",
message_id: hostname + "-" + current_time_nanoseconds,
timestamp: current_time_seconds,
hostname: my_hostname,
ips: my_ip_addresses,
services: my_detected_services,
ttl: 3600
}
data = json_encode(msg)
socket.send_to(data, "255.255.255.255:5354")
For querying the daemon locally:
socket = connect_unix("/run/disco.sock")
query = {
type: "QUERY_BY_NAME",
name: "web1",
request_id: random_id()
}
send_length_prefixed(socket, json_encode(query))
response = recv_length_prefixed(socket)
result = json_decode(response)
if result.type == "OK":
return result.addrs
else:
return not_found
Configuration¶
Disco is configured via YAML. Minimal config:
daemon:
socket_path: /run/disco.sock
broadcast_interval: 30s
record_ttl: 3600s
network:
broadcast_addr: 255.255.255.255:5354
max_broadcast_rate: 10
discovery:
enabled: true
detect_services: true
security:
enabled: false
Service port mapping is configurable:
The daemon detects services by attempting a TCP connection to each mapped port on 127.0.0.1. Open ports are matched to service names and included in announcements.
CLI reference¶
disco hosts # List all discovered hosts
disco hosts <name> # Show host details
disco hosts forget <name> # Remove host from cache
disco hosts mark-lost <name> # Mark host as lost
disco services # List all services
disco services <name> # Show service details
disco lookup <name> # Resolve hostname to IP
disco status # Daemon status
disco time # Time sync status
disco timeset # Force time update
disco ping <hostname> # Ping a discovered host
disco check # Check which services are reachable
disco announce # Send manual announcement
disco key generate # Generate security keys
Reference implementation¶
The reference implementation is written in Go and available at github.com/offline-lab/disco. It includes:
disco-daemon- the core daemon (~6 MB binary)disco- CLI management tool (~4 MB binary)libnss_disco.so.2- C NSS module for glibc integrationdisco-gps-broadcaster- GPS time source for Raspberry Pi, Arduino, and ESP32
The NSS module requires Linux with glibc. The daemon and CLI work on any platform Go supports, but broadcast discovery requires UDP broadcast support.