Skip to content

shipped · · 23 min

Finding machines that do not announce themselves

Hikvision and Dahua answer a multicast probe; ZKTeco, most of the fleet, answers nothing until you knock on port 4370. How the connector searches its own networks, the ones the platform already knows, the ones a person types and the network one router away, proves a machine is the one meant by its own serial before reading it, and finds it again after the router moves it.

The person setting up an attendance machine rarely knows its address. They know where it hangs on the wall. So the connector's setup window has a Search button, and this post is what happens behind it.

Some brands make that easy: Hikvision and Dahua answer a multicast probe with their serial, MAC, model and address. ZKTeco, about seven in ten of the machines on the platform, announces nothing at all, and neither do Anviz and Suprema. And the machines are not always on the PC's network: they sit on a branch's subnet, a second VLAN, or the internet provider's LAN one router up. Reaching them is the companion post; this one is finding them, being sure which one you found, and finding it again when it moves. A search runs when someone asks, once on a network the PC has not searched, and when a machine it reads goes missing; never as a continuous scan of a customer's network.

Machines that answer a call

Hikvision speaks SADP: an XML inquiry to the multicast group 239.255.255.250 on UDP 37020, which every Hikvision device on the network answers with its serial, MAC, address, model and firmware. The connector sends one from every office network the PC is on and listens for four seconds.

A SADP reply carries no proof of anything, and the address in it can become the place this PC reads attendance from. So a reply is believed only when the address it claims is the address the packet came from, and is on one of the PC's own networks, or at least private (a machine still on its factory address, marked off-range). A reply with neither serial nor MAC is dropped. "Own networks" leaves out the second addresses the connector added itself, because judged against those, a factory-address machine stops looking off-range the moment the alias that reaches it goes up.

terminal
func parseMatch(raw []byte, from *net.UDPAddr, own []*net.IPNet) (Discovered, bool) {
	var m probeMatch // DeviceSN, MAC, IPv4Address, DeviceType, SoftwareVersion
	if xml.Unmarshal(raw, &m) != nil {
		return Discovered{}, false
	}
	claimed := net.ParseIP(strings.TrimSpace(m.IPv4Address)).To4()
	if claimed == nil || from == nil || !claimed.Equal(from.IP.To4()) {
		return Discovered{}, false // claims to be somewhere it did not send from
	}
	offRange := !onLocal(claimed, own)
	if offRange && !private(claimed) {
		return Discovered{}, false
	}
	// ... serial or MAC required
}

Multicast does not always get through: office switches, Wi-Fi access points and IGMP snooping drop it. Every Hikvision also answers the same inquiry sent straight to its address, so when the group probe finds nothing, the search asks every address of the PC's own /24s directly (at most four /24s, at most 50 inquiries a second across the whole PC) and judges the replies the same way. On Windows a host's ICMP "port unreachable" arrives as a read error; the reader keeps going past those, but not on a socket that only fails.

Dahua has DHDiscover.search, multicast to 239.255.255.251 on UDP 37810: a 32-byte header (0x20000000, DHIP, then the payload length twice, little-endian) in front of a JSON body. I did not find the format in Dahua's public documentation; the clearest public description was an open-source home-automation binding, so I treat it as medium confidence, and the trust rules are SADP's exactly. Both probes go out together and share one wait, so "call every machine" costs four seconds, not eight, and one brand's probe failing to send does not stop the other's replies counting.

Machines that answer a knock

ZKTeco answers no probe. What it has is a TCP port, 4370. So the search knocks: a plain TCP connect to every address of the PC's private networks, closed at once, nothing sent. Up to 1,024 addresses per network (on a wider one, the /22 around the PC), 64 connects at a time, 250 ms each. An answered connect leaves the machine's MAC in the OS's ARP table, read once afterwards (on Windows through the IP Helper API, without starting arp.exe). A MAC with ZKTeco's IEEE prefix makes the row zk_port; anything else on 4370 is zk_port_guess, likely but not proven. Then each ZKTeco is asked for its serial with comm key 0 (connect, prove the key, read, close), four at a time, ten seconds each, so the row shows the real serial and "add it" can prefill it.

The same pass knocks on TCP 5010 (Anviz's client-mode port) and TCP 51211 (the port Suprema devices listen on). An open port there is specific enough on its own. The connector does not read either brand; it lists them, so someone can ask for support rather than conclude there is no machine.

Last, any ARP neighbour on the swept networks that no probe or port named is checked against a table of MAC prefixes (IEEE MA-L, via Wireshark's manufacturer list): ZKTeco, the OEM behind Sintech, Anviz, Suprema, and the many Hikvision and Dahua blocks. An OUI match is a hint, never identity, so it is used only after every active check has had its chance.

One search, step by step1 call2 each3 sweep4 away5 recheck6 nameSADP and DHIP multicast together, one 4 s waitmeanwhile: 8 router addresses on 53, 80, 443 at TTL 2only if nobody answered: the same inquiry to each address50 a second across the PC, four /24s at mostTCP connect to 4370, 5010, 51211 on every address, nothing sentthen MACs from the ARP table, then the OUI tableone router away, after the PC's own networks, 30 s budgetSADP/DHIP per address, beside a knock with 8000 and 37777knock again on this PC's own ZKTecos the sweep missedone connection at a time: wait up to 15 s for the readask each ZKTeco for its serial with key 0, four at oncea company key only at the address of the machine it belongs togreen: added after a real search missed a real machine

The cheap steps go first and the slow ones get their own budget, so what was found next door is never lost to a sweep further out.

Where it searches

The window shows three brand-neutral steps (call, sweep, router), with the per-address inquiry and the port knock folded into one sweep. Behind them the search covers more than the PC's own network, and each addition was a field case where a machine existed and the search said it did not.

The /24 of machines the platform already knows. A branch or a second VLAN the PC is not on was never searched. Now the addresses the platform has for the company's machines become their /24s (private only, at most eight, skipping any the PC already covers) and the search asks them by routing: SADP and DHIP address by address, plus the port knock. A hit there is never marked off-range, because off-range means "add a second address", and a routed network must not get one.

Networks a person types. Some networks have no known machine yet. The Search screen has a field for them, and the platform can hold a company-wide list every PC searches. It takes 192.168.10.x, 192.168.10.0/24 or any private CIDR from /22 to /32, comma-separated, at most eight together with the automatic ones, and an error names the entry that failed in the person's own words. Each is swept at its own width, not forced into a /24. The setting lives in the connector's protected folder under the same trust check as the proxy setting, and the background service re-reads it every minute.

One router away. The office in the companion post had its PC behind its own router and two ZKTecos on the provider's LAN, which none of the above contained. So the search also asks eight addresses home and office routers ship with (192.168.0.1, 192.168.1.1, 10.0.0.1 and the like) on ports 53, 80 and 443, all at once with a one-second timeout, in the background while the multicast wait runs. Three guards keep that honest:

  • TTL 2. Each connect leaves with an IP time-to-live of two: it reaches a router one hop past the PC's own and no further, so an address the internet provider answers for is never taken for one in this office.
  • Five or more answering is interception. A captive portal or security box that answers for every address would make every candidate look real, so then none is used.
  • No overlap. A candidate whose /24 overlaps the PC's own networks, its machine link or a range already being searched is skipped.

A candidate that answers gives its /24 to the routed sweep, with two more ports in the knock: Hikvision's 8000 and Dahua's 37777. Rows there are off-range and never earn a second address; no MAC crosses a router, so the platform tells them apart by serial alone. Order and budget matter. The PC's own networks go first, and the sweep one router away runs after them in its own 30-second budget, so a slow sweep further out never costs what was found next door. Inside that budget the knock runs beside SADP and DHIP, not after: on two networks one router away, address-by-address SADP and DHIP used the whole budget and the knock never ran. In that office SADP sent to single addresses never came back across the router at all. The knock is what found the Hikvisions there.

A network whose router is silent. The first build of this found nothing in the office it was written for. The provider's network had no router answering at any usual address (as happens when an office router is joined to it by a second port), so the gate skipped the range holding both machines. Now a candidate with a silent router counts if any address in its /24 answers on 4370, at the same TTL 2. Addresses go in the order machines and DHCP pools usually take them (.2–.30, .100–.120, .200–.254, then the rest), 128 at a time, capped at eight seconds: about a thousand addresses, the likely ranges of every candidate rather than every address of all of them. A machine elsewhere on a silent network is found once someone types its network.

Where one search looksoffice PC10.40.40.15gatewayown network10.40.40.0/24multicast + knock1,024 at mostknown machines10.40.60.0/24a /24 per addressrouted, no aliastyped networks10.40.80.0/22/22 to /32, as is8 at most, sharedone router away192.168.100.0/24TTL 2 · 30 s5+ routers: nonerouted rows: no second address, no MAC, matched by serial aloneno serial and no MAC: one line per brand and network, never reporteda silent router: knock 4370 on the likely hosts first, 8 s at most

The PC's own network is swept in full. Everything else is reached by routing, so it is bounded, never given a second address, and named by serial.

Devices that will not say what they are. Across a router there is no MAC, and many devices give no serial without a password. In that office the search found about thirty Hikvision devices on a third range, almost certainly the CCTV cameras, which answered on their ports and said nothing else. Listing each as an "add this machine" row was noise, and adding one made a card for nothing. A device with neither serial nor MAC is now never reported to the platform; the window shows one line per brand and network, in the shape n Hikvision devices on 192.168.50.x · usually cameras, with an "Add by address" link, and the search counts only machines it named.

This PC's own ZKTeco. The most embarrassing miss: a ZKTeco takes one connection at a time, so one this PC was reading during the sweep did not answer the knock, and the person looking at the list saw their own working machine missing. After the search, each of this PC's ZKTecos on a searched network that did not answer is knocked once more, after taking that machine's read lock (waiting at most 15 seconds for the read to let go).

Proving identity before reading

Finding a machine at an address is not knowing which machine it is. Two machines that swap addresses (DHCP after a power cut, the same admin password on both) used to have one machine's punches, and its staff numbers, recorded as the other's. Every read would succeed. Nothing would be wrong except everything.

Now, before anything is read, the connector asks the machine at the address for its own serial and reads only if it matches. The first version checked once an hour per address. An adversarial review the same day found the flaw: a swap keeps both addresses the platform has, so a cached "this address is that machine" is exactly what a swap breaks. It is now asked on every read, one small request a minute per machine:

terminal
got, err := reader.Identify(ctx) // the machine's own serial, nothing else
if err != nil {
	reader.Close()
	return nil, err
}
if got != "" && !sameSerial(got, t.Serial) {
	reader.Close()
	return nil, &WrongMachineError{Host: t.Host, Want: t.Serial, Got: got}
}
return reader, nil // only now is anything read

A different machine is never read: the window says which serial answered instead of the one expected, and the right machine is looked for by its serial at once. Serials match in either form, because a Hikvision reports a long serial while its label shows a short one: equal, or one ending with the other and at least eight characters long. A correctly set-up machine is never blocked by its own label.

Company keys stay with their own machines

A ZKTeco with a comm key will not give its serial for key 0. The platform holds the keys of the company's own ZKTecos, and the obvious move is to try them all on every locked machine a search finds. That would leak them. The protocol does not send a hash of the key: it sends a reversible scramble of the key's bits and the session number, so whoever receives the proof can work the key out. A host that merely answers on 4370 (a neighbour's machine in a shared building, or anything pretending to be one) would collect every key offered.

So a search offers a key in one place: the address the platform has for the machine it belongs to, its own address or one reported for that same serial. Anywhere else the row stays serial-less. A search never tries a Hikvision or Dahua login either; their serial comes from SADP and DHIP. A ZKTeco that refuses key 0 at no address the platform knows is listed as locked. Someone types its key once in the web app, and an identify instruction tries that key at that one private address, reads the serial and nothing else, and forgets the key.

A comm key is offered only where its machine isoffice PCthe platform saysABC1234567890is at 10.40.40.21with comm key K10.40.40.21 · its addressserial matches: named10.40.40.77 · 4370 openrefuses key 0: locked10.40.40.77 · key typedserial read, key forgottenkey 0, then Kkey 0 onlythat key, oncenever: every key to every host that answers on 4370the proof is a reversible scramble, so offering it gives the key away

In a search, a key goes where the platform already places its machine, or where a person typed it for one address.

Finding a machine again after it moves

An office router handing out a new address looks exactly like a dead machine. The connector does not wait for someone to drive there. After three failed reads in a row it looks for the machine, and again every 30 failures after that, because a machine switched off during the first look may come back somewhere new. Three cases look sooner or not at all:

  • Added with only a serial, no address at all: looked for on the first failed read, not the third.
  • A different machine at the address: looked for on the first failure.
  • Answered and refused the comm key: no look. It is where it was, and a search would not help.

A Hikvision is looked for by SADP, by serial and MAC. ZKTeco was the gap: refinding was Hikvision-only, and the setup advice that covered for it, "turn DHCP off on the machine", was wrong for offices whose provider's router renumbers the network. Now a ZKTeco that stopped answering sets off a search of every network this PC searches (its own, one router away, the company's) and is matched by the serial that search reads. A locked one is matched by trying its own comm key at each unnamed ZKTeco not at another machine's address. That is the one place a key leaves its machine's known address, and it is bounded: only on networks this PC already searches, only for a machine that is already failing, at most sixteen hosts. One look per machine at a time, and machines that stop together share one search, since a router that renumbers moves them all at once. When the machine turns up, the next read goes to the new address and the platform is told, so the saved address follows the machine.

When a machine that stopped answering is looked forfailed reads in a row, one read a minutea look123…33…63added with an addressadded with a serial onlya different machine thererefused the comm keyno looka ZKTeco look is one search of every network, shared by machines that stop togetherfound: the next read uses the new address, and the platform is told

A moved machine is the common case of a dead one, so the looks are on a schedule rather than on someone's visit.

The window

Two changes made the search feel like it worked without changing what it found.

List a machine the moment it answers. The address check takes about 30 seconds on a /24, and the window used to list nothing until the whole search ended. Half a minute of an empty box reads as "nothing found". Now each machine appears as soon as its port answers, under "Found so far", partial (no MAC, model or serial yet), and the search's full answer replaces the list when it ends. A new search starts with an empty list, so the previous one's rows never pass for this one's.

One steady progress bar. The whole search, own networks plus one router away, takes about a minute. The window used to promise 30 seconds, restart its address count on each pass, give up after 80 seconds, and, on a click within two minutes of the last search, quietly show the old results instead of searching. Now:

terminal
// One bar for the whole search, moving with the clock against how long the
// last one took (a minute before any), so it never jumps back between passes.
var expect = Math.max(10, run.last_secs || 60), over = secs > expect;
fill.style.width = Math.min(95, Math.round(100 * secs / expect)) + '%';
label = over ? 'Taking longer than last time, still checking'
      : run.last_secs ? 'About ' + about + ', like the last search' : 'Usually about a minute';

The bar stops at 95% until the search really ends, so it never claims to be done. The address count only rises; each pass adds to it instead of resetting it. The window waits while the search runs, up to five minutes. And "Search again" searches: the background service takes a new request 20 seconds after the last, which absorbs a double-click and is short enough that nobody notices.

What I would tell someone building this

  • Most machines will not announce themselves. Multicast discovery covers the brands that implement it. Budget for a port knock, an ARP read and an OUI table for the rest, and record which kind of evidence named each row.
  • Believe a discovery reply only where it came from. The claimed address must be the source address, on a network you can justify.
  • Search where the machines are, not where the PC is. Known machines' networks, typed networks and one router away each found machines the PC's own sweep never could.
  • Bound everything you send. TTL 2, an interception threshold, a budget per step, and the cheap steps first.
  • Identity before data, on every read. An address is a guess and a serial is the machine; a cached mapping is exactly what a swap breaks.
  • A reversible proof is the secret. If trying a key gives it away, offer it only where you have a reason to.
  • Treat a moved machine as the usual dead one. Look again on a schedule, share one search across machines that failed together, and tell the platform the new address.
  • Show progress honestly. Hits as they land, one bar that only moves forward, and a button that does what it says.

What the search still cannot do, such as calling a camera a camera before anyone types its password, belongs with the rest of what the connector still needs.

Working through something like this? I help teams ship AI and cloud systems that hold up, and cost what they should.