A biometric attendance machine is a small box on an office wall. Someone presses a finger or shows a face, and it stores a punch: who, when, and sometimes in or out. A cloud attendance platform needs those punches every few minutes, from every office, for payroll to be right at the end of the month.
The cloud cannot reach the machine. It sits on an office network behind a router, often behind two. Some machines can be told to call the cloud by themselves. In practice that is the least reliable path on small-office networks, and many models cannot do it at all. So something on the office network has to go and get the punches. That something is a program on a PC that is already there, usually the receptionist's or the accountant's.
This post is the whole design of that program, the connector. The series after it goes deep on one part at a time:
- The complete architecture (this post)
- Reading four brands of attendance machine
- Time is the hardest field in a punch
- Handing over from the old agent without losing a punch
- Reaching a machine you cannot route to
- Finding machines that do not announce themselves
- An unsigned Windows program no antivirus holds
- Testing against hardware we do not own
- What the connector still needs
The same work, written as a short research paper with the field audit in it: Reading the Unreachable (PDF), DOI 10.5281/zenodo.24026722.
The constraints, before any design
Every decision below comes from one of these. They are not preferences. They are what the offices are like.
- The PC is not ours. It runs whatever its owner installed: antivirus suites, VPN clients, a printer's helper, a browser with thirty tabs. It may be a laptop that goes home at night. Nobody on site is IT.
- The network is not ours either. Proxies, HTTPS inspection boxes, guest Wi-Fi that keeps devices apart, a machine still on its factory address in a different range from everything else, a machine plugged into the ISP's router while the PC sits behind the office's own router.
- The internet goes away. For an hour, or for a week. Attendance must not.
- The machines speak four protocols. Some are pulled over a binary TCP protocol, some over HTTP APIs, and some push their punches by themselves to whoever they are told to.
- A punch is money. A lost punch is a wrong salary. A duplicated punch is a wrong salary. A punch on the wrong day is a wrong salary.
Generation one, and what it taught
The first version was a Python agent, installed by a PowerShell script and kept alive by NSSM as a Windows service. It worked, and it ran at every site for months. The field taught it a list of rules that the connector kept word for word, because each one cost real attendance to learn:
- The first read of a machine brings nothing up. A terminal can hold a year of punches, and the platform would discard the old ones anyway. Start from the machine's own clock and read forward. Older attendance is an explicit "bring in older punches", which uploads day by day.
- A machine whose clock is set forward cannot stop attendance. Punches dated more than a day ahead are ignored, and the reading position is never allowed past now.
- A backlog older than 20 hours is sent as older punches. Otherwise the platform discards it as clock drift. A weekend of real attendance was lost that way once.
- Failures are reported, not just logged. A machine that stopped answering must not look like a machine where nobody punched.
What the field also taught was where Python hurt. The installer was most of
the bug reports: PowerShell 5.1 and 7.4 parse and exit differently, a
non-ASCII character broke the script on some PCs, the service manager
reported START_PENDING and the script called it a failure, and the install
downloaded things at install time on networks that blocked downloads.
Shipping a runtime to a PC you do not control is a liability. The
Windows post has the
details.
So generation two is Go: one static file per platform, no runtime, no C compiler for Windows or Linux, cross-compiled from one machine. About 20,000 lines of Go and 14,000 lines of tests.
The shape of it
Three things to notice in the picture. The connector only ever connects out, to the platform on port 443 and to the machines on the office network, so no port is opened towards it from the internet. The spool sits between the machines and the network, so the two sides never wait on each other. And the relays are the one place it listens: a machine set to send through the PC sends to it on the office network (port 8085 for ADMS, the vendor's own convention, and UDP 5055 for Sintech), behind a firewall rule scoped to private networks.
Identity: a key that never leaves the PC
The Python agent was set up by pasting a long secret into a config file. That secret could be copied off the PC, read over a shoulder, or committed to a backup.
The connector is set up with a 6-digit code that the HR person gets from
the platform. The PC generates its own Ed25519 key pair, sends the public
half with the code, and gets back an identity. The private half never leaves
the PC. On Windows it is sealed with DPAPI in machine scope and the file is
restricted to SYSTEM and Administrators. Elsewhere it is a 0600 file in a
root-owned folder.
The connector checks that folder on every start, and refuses to run if anyone else can write to it. The service runs as SYSTEM or root, so a folder an ordinary user can change could hand it a forged key or a forged settings file.
A paired PC still gets nothing until a person confirms it in the platform. Until then it waits quietly and asks again every 30 seconds. A stolen code used from somewhere else shows up as a PC nobody recognises, not as a stream of punches.
Every request is signed. The canonical string is five lines:
func Canonical(method, path, timestamp, nonce string, body []byte) string {
sum := sha256.Sum256(body)
return strings.Join([]string{
strings.ToUpper(method), path, timestamp, nonce, hex.EncodeToString(sum[:]),
}, "\n")
}
The server recomputes the string, checks the signature, allows a window of five minutes either side, and keeps a cache of nonces that fails closed: if the cache is unavailable, the request is refused, not waved through. Two details made this hold up in the field:
- The PC's clock does not have to be right. Office PCs drift by minutes, and some are set to the wrong year. The connector learns the platform's time from every answer, carries it forward on the monotonic clock, and signs with that. The setup window says so when the PC's own clock is more than two minutes off, but nothing breaks.
- The signature is tested against the server, not against itself. The Go tests check the canonical string against the server's own golden file, and the Ed25519 signature against values produced by the library the server verifies with. A test that signs and verifies in the same language proves nothing about the other side.
Five loops and a backstop
Everything the connector does is one of a few loops. Each has its own interval, and none waits on another.
- Read the machines, every minute by default, or whatever the platform's config says. One reader per machine at a time, so a "sync now" from the platform queues behind a read instead of failing as "timed out".
- Send the spool, every 15 seconds.
- Do what the platform asked, every 30 seconds: search the network, set a machine's clock, bring in older punches, find a machine that moved.
- Report in, every minute. The heartbeat is the most useful thing the connector sends after punches. It carries, per machine, the last read, the last error and its kind, and how many reads failed in a row and since when. It also carries the PC's networks, whether a VPN captures the office network, whether the Wi-Fi looks isolated, the proxy mode and the last upstream error. It never carries addresses or secrets.
- Refresh the machine list, every 5 minutes, and at once when the heartbeat answer says the list changed.
- A backstop read every 30 minutes for a machine that pushes its punches by itself: one read on top of the push, to catch whatever the push path missed. Never the normal read.
Backing off is shared. When the platform is unreachable, every loop backs off together, the waits are spread out so a fleet of PCs coming back from the same outage does not arrive in the same second, and a large backlog drains in steady batches instead of one burst.
Readers: one interface, four brands
Each brand is a reader behind one small interface:
type Reader interface {
Info(ctx context.Context) (Info, error) // it answers, and we are allowed in
Punches(ctx context.Context, since, until time.Time) ([]Punch, error)
Users(ctx context.Context) ([]User, error)
Close() error
}
Optional interfaces add what only some brands can do: say their own serial in one small request, read and set their clock. A registry maps the brand to an opener, so the runner never knows which protocol it is talking.
The rule that matters most sits above the interface: a machine is read only after it proves it is that machine, by its own serial. Offices reuse addresses. A machine replaced under warranty comes back on the same IP with a different serial. Without the check, the connector would happily read the new machine's punches into the old machine's record, or read a neighbouring company's machine on a shared network.
The four brands are different enough that each got its own section in the readers post: a binary TCP protocol with an optional numeric key, an HTTP API whose search parameters differ by firmware, a CGI API, and a UDP push whose bytes were decoded on a real terminal.
The spool: nothing lost, nothing sent twice
Every punch goes to disk, with fsync, before any upload is attempted. One
append-only file per machine plus a small acknowledgement file. A punch is
removed only after the platform has acknowledged it.
The interesting failure is not the network going away. A spool handles that
by being a spool. The interesting failure is one bad punch. The platform
can refuse a single punch for a reason that will never change: a person
deleted, a machine detached. If the uploader retries the whole batch, that one
punch blocks every punch behind it, forever. So a punch refused on its own is
parked: kept on the PC, listed by a parked command, sent once more on
request, and out of the way of the rest.
The relays have their own bounded disk store with the same rule. A machine that pushes through the PC keeps working through an internet outage, and the store has a ceiling so a week offline cannot fill the disk.
An adversarial review pass found the two ways a punch could still be lost.
The first: "read all now" while the regular upload drained a backlog sent
the same batch twice and marked never-sent punches as sent. Reproduced, it
lost 200 punches out of 1,000. Now there is one upload at a time. The second:
two connector processes sharing one spool. Now the spool takes an exclusive
lock that the operating system releases even on a crash, and status reads
without taking it. Each fix left a test that fails without it.
The network layer
The connector's network code is about a quarter of the program, and almost all of it exists to turn "it doesn't work" into a sentence someone at the office can act on.
- Every failed request is classified:
dns,connect_timeout,connect_refused,connect_reset,proxy_auth,tls_inspected,tls_other,http_blocked,server_error. The last one rides on the heartbeat. Retrying does not change. Punches wait on the disk either way. - Proxies are a saved setting, protected like the key. The service runs as SYSTEM, which does not see a person's Internet Options or proxy auto-config, so it needs a fixed address.
- HTTPS inspection is detected and reported, never skipped. The certificate is always checked against the system's own store. An office that inspects traffic must trust its box in Windows, or exempt one host. There is no setting to turn the check off.
- VPN clients that capture the office network are detected by asking which adapter the route to a machine leaves through.
- Wi-Fi isolation is detected when a search hears only the router.
- Machines in another range, or one router away, have their own post: a second address on the PC added only on evidence, and a bounded sweep beyond the office's own network.
doctor runs all of it and prints one plain fix per problem, written to be
read out over the phone.
The window, and the installer
The setup window is an embedded HTML page shown in the system's own web view: Edge WebView2 on Windows, WebKit on the Mac. It loads nothing from outside the program. Where the web view is missing, the same flow runs in the system's own dialogs. The person double-clicks a download, or pastes one PowerShell line, says yes to the administrator prompt, types the 6-digit code, and the connector installs itself as a service that restarts itself if it stops.
Getting that one sentence to be true on every Windows PC took more fixes than any other part of the program, and it is a post of its own: an unsigned program, antivirus suites that hold any child process that looks like network configuration, and a window that Windows itself kept hidden.
Updates: designed, signed, and switched off
The update path is built: a manifest signed offline with a key the server never holds, a public key compiled into the program, a SHA-256 of the file, an HTTPS address inside one domain, a staged share of offices, and a rollback if the new version cannot report in within five minutes. A compromised server cannot ship a binary to every office PC.
It is switched off. A security review before release asked for three more things before an update may run as SYSTEM: install the same bytes it verified, refuse an older version, and trust-check the rollback files. Until those land, a new version is installed by running the new installer over the old one. It is a slower path and the right one. Self-update is the one feature where a bug ships to every PC at once.
Handing over from the agent that is already there
Every office already ran the Python agent. The connector does not ask anyone to uninstall it. It reads the old agent's settings once, takes over its machines, carries over where each machine had been read to, and turns the old agent off only when nothing can be lost. While anything is unclear, the answer is "not yet", and the old agent stays on. The handover post has the rules, and the production audit that found the cases the rules missed.
What I would tell someone building this
- Design for the PC you don't control. Every system command you start is one an antivirus can hold. Every runtime you ship is one an update can break. One static binary, with operating-system APIs instead of command-line tools, removed whole categories of support calls.
- Put a disk between the two networks. Once the spool is the only thing the readers and the uploader share, an outage on either side is a delay, not a loss.
- Prove identity before you read. Addresses are not identities in a small office. Serial numbers are.
- Report the failure kind, not the failure. "timeout on port 4370 from 10.0.0.12" gets fixed. "Sync failed" gets a support ticket.
- Never sign with the PC's clock. Learn the server's time and carry it.
- Ship the risky feature switched off. A self-update path that is built, reviewed and off is worth more than one that is on and almost right.
The last post in the series, what the connector still needs, is the honest list: what it cannot fix by itself today, and the research on how it should.