on-premise
A door on someone else's network

A cloud platform that manages clubs eventually has to open a door. Not metaphorically — a physical door, on a site, wired to a controller that was installed years before anybody wrote the platform, sitting on a private network with no route to the internet and no cloud API of its own.
You cannot solve that from the cloud. Something has to run there. So the shape is a small piece of software installed on the customer's network that speaks one stable contract upwards and whatever the access-control system speaks downwards:
1Club Server
→ @1club/access-bridge-sdk typed TypeScript client
→ Access Bridge API a versioned OpenAPI contract
→ vendor integration one binary per access-control system
→ vendor API / controller / hardware
A customer installs exactly one integration: the one matching the system they already own. Everything above that line is ours and deploys on our schedule. Everything below it is theirs and does not.
That asymmetry is the whole design problem, and it is why the language choice for the bottom half was not a matter of taste.
The deployment target picks the language
Here is the constraint list I actually had, before any opinion about syntax:
- It installs on a Windows Server in a plant room as often as on Linux.
- Nobody is going to install a runtime for us. Not a JVM, not .NET, not Node. "Please have your IT department provision a runtime on the machine that runs your security system" is a sentence that loses deals.
- I will never have a shell on it. When it misbehaves, a support engineer who is not me has to diagnose it from logs and a health endpoint.
- It holds a credential that can open every door on the site, so the amount of third-party code in the process is a security number, not a convenience one.
- It has to keep an eye on a long-lived vendor session while serving concurrent
HTTP requests, and be able to shut that session down cleanly on
SIGTERM.
Go answers all five, and it is the only mainstream option that answers the first two without an asterisk.
One static binary, cross-compiled from CI. The build line in the
integration's manifest is a single go build:
build:
setup: go mod download
lint: gofmt -l . && go vet ./...
test: go test -race ./...
build: go build -trimpath -o ../../bin/impro ./cmd/impro
platforms: [linux/amd64, linux/arm64, windows/amd64, darwin/arm64]
Four platforms, no cross toolchain, no runtime to match, no .dll to bundle —
one GOOS/GOARCH pair per matrix cell over the same source, built with
CGO_ENABLED=0 inside a digest-pinned container so the toolchain is identical
every time. That is what makes shipping a native package per platform affordable
at all, and it is why the default artefact type is native-binary and not
container: access-control systems live on machines where mandating Docker would
simply exclude the site.
What cross-compiling does not hand you is a service. For most of this project's
life its own documentation said installation was install -m 0755 on Linux and a
copy plus an sc.exe create on Windows. Both of those were wrong, and I will
come back to why.
The dependency surface is close to zero. The whole module requires one third-party package:
require (
github.com/1club-ai/.../bridgeapi v0.0.0 // ours
github.com/1club-ai/.../bridgehttp v0.0.0 // ours
gopkg.in/yaml.v3 v3.0.1
)
A YAML parser. Everything else — the HTTP server, the HTTP client, TLS, XML
decoding, structured logging, signal handling, the test harness — is the standard
library. Both shared libraries have empty require blocks. For software that
holds the keys to a building, "what is the supply chain" having a one-line answer
is worth more than any amount of framework ergonomics.
Concurrency you can actually reason about. The vendor session is shared
mutable state behind a mutex; the HTTP host serves requests on goroutines;
context.Context threads cancellation from the request all the way down to the
vendor call. This is the boring, well-trodden part of Go, and boring is the point.
go test -race runs in CI and has caught things reading never would.
And a small one that turned out to matter. The systemd unit sets
MemoryDenyWriteExecute=true, which a JIT runtime cannot tolerate. A statically
linked Go binary does not care. The hardening options you would like to set on a
process that can open doors are exactly the options that fight a managed runtime.
Where Go made me work for it
I would rather write this down than pretend the choice was free.
The vendor's write API is lenient by design: you send only the attributes you
want to change, and omitting one means "leave it alone". Attribute presence
is therefore semantically load-bearing. encoding/xml cannot express that for
scalar fields without making every one of them a pointer, and ,omitempty is
actively dangerous here — it would drop current="0", which is the documented way
to deactivate a cardholder. A departed member would keep working access, and the
call would return success.
So the encoder is hand-rolled around an ordered, presence-tracking attribute bag.
Decoding stays on encoding/xml, which is fine at it. That is perhaps three
hundred lines I would not have written in a language with a serialisation library
that models optionality properly. It is also three hundred lines I can read in one
sitting, which is not nothing when the failure mode is a door.
The communication hiccups
I expected the hard part to be the domain — doors, credentials, entitlements. It was not. The hard part was that a twenty-year-old on-premise product has opinions, and none of them are in the shape you assume.
The vendor meters concurrent API sessions, and the floor is one. On the lowest licence tier the customer's own system permits a single concurrent API login. The Bridge therefore holds exactly one session for its entire lifetime, and three things follow from that which are not obvious:
- Logout is mandatory on shutdown. A session that is not released stays held
until the vendor times it out, and until then nothing else can authenticate —
including the site's own staff, at their own console. Killing the process with
SIGKILLlocks the customer out of their security system. - That logout needs a context detached from the shutdown signal. By the time
shutdown runs, the ambient context is already cancelled, so the logout request
is cancelled with it and the session leaks anyway.
context.WithoutCancelis the fix, and there is a test asserting the fake portal's session count returns to zero. - Authentication is rate-limited. A crash loop that retries login aggressively
would burn the customer's licence. The limiter is enforced while holding the
session lock, so a burst of concurrent callers cannot each independently decide
a login is due, and a Bridge that cannot log in degrades to
503rather than taking the site down with it.
"Not authenticated" arrives in three costumes. A 401 is the easy one. But a
servlet container that has forgotten the session answers with an HTML login page
and HTTP 200, which without a guard surfaces as an unintelligible XML parse
error:
switch {
case resp.StatusCode == http.StatusUnauthorized, resp.StatusCode == http.StatusForbidden:
return nil, errSessionExpired
case looksLikeLoginPage(resp, body):
// 200 OK with a login page is still a dead session.
return nil, errSessionExpired
case isWrongEndpoint(resp.StatusCode):
return nil, ErrEndpointNotFound
And the third costume is the one that cost me an afternoon. The product remounted
its XML servlet between generations — one serves it at /portal/api/xml, the
newer one at /api/xml. Point at the wrong path on the newer one and you reach
the web app's static handler, which accepts GET, HEAD, OPTIONS and refuses the
login POST with 405. The credentials are never examined. Diagnosed naively
that is "check your password", and the password is fine. So 405 is classified
as wrong path, reported as such, and — importantly — it does not arm the
login rate limiter, because a request that never reached the authentication code
consumed no licence session. Otherwise the one diagnostic the operator needs gets
replaced on every subsequent attempt by "waiting before reconnecting", which is
both unhelpful and untrue.
Success is a sentence, not a word. A query that matched nothing replies with
a result attribute reading Success : No records found. Compare that string
against Success and every empty table becomes a vendor error — which on the
event stream means a quiet night fails every single poll. The predicate now lives
in exactly one place and every reply type that carries the attribute uses it.
The session is bound to the URL, verbatim. Reaching the same server as
https://localhost and then https://127.0.0.1 does not work; it is treated as a
different route. So the configured base URL is used exactly as given, redirects
are refused, and a URL carrying a query string or fragment is rejected at startup
rather than at 3am.
Two units bugs, both silent, both found only against real hardware. The
schedule primitive stores start time and duration as HHMM, not as minutes since
midnight. Read as minutes, a group configured for 10:00–16:00 was published as
16:40–24:00 — two windows that never overlap, returned as a success. A duration of
2400 is what settles it: forty hours is not a number. The parser now refuses a
malformed value and publishes no schedule rather than a plausible one that is
not the vendor's.
The other is the classic. One entity is written in local ISO time and stored as UTC; the vendor documents this as an explicit exception to its own rule. Get the zone wrong and every access window shifts by the offset with no error at all — the grant syncs cleanly and the member is denied at the door. Three defences: the time zone is required configuration with no default (a container almost always runs UTC, and defaulting to it is silently wrong at every site outside it), it is measured against the vendor's own data on every event poll, since each transaction carries both a UTC and a local timestamp for the same instant, and the result is published in the capability document. A Bridge that cannot prove its time zone says so out loud.
Errors leak. Vendor error payloads carry a full Java stack trace in one attribute and, in another, human-readable text that can quote a card number. The stack trace is attached as the error's cause so it reaches the local debug log and never a response body, a metric label or an upstream log line. Digit runs of four or more are redacted before any of it is surfaced. Both directions of the wire log go through the same redaction, because a write carrying a tag has a card number in it too.
Event ordering is by insertion, not by time. Controllers buffer transactions
while they are offline and upload them later, so a higher row id can carry an
earlier timestamp. The cursor is a keyset scan on the primary key — no OFFSET,
so it cannot skip or duplicate rows while new ones are being inserted underneath
it — and the capability document states the ordering is ingestion so the
consumer knows it must sort itself. Claiming chronological order would have caused
silent event loss after every controller outage, on an audit trail, which is about
the worst thing this software could do quietly.
Installation is a feature
The part I underestimated, and the part where the distance between what the
documentation promised and what the software could do was widest. installation.md
told operators to download a versioned artefact and check its checksum against
the release page. There was no release page. There were no tags, and make build
stamped every binary 0.1.0-dev into a gitignored directory that CI threw away.
Everything below existed on paper first and had to be made true.
When the software runs on hardware you will never touch, installation is not a README section — it is a designed surface with nine steps: install, configure, start, stop, check health, view logs, upgrade, roll back, uninstall. A few decisions in there are worth defending.
The release pipeline does not know what Go is. On merge, every integration
whose code changed is cross-compiled once, packaged, checksummed, attested and
published — to a rolling prerelease always, and to a versioned release when the
manifest's version changes. The workflow never says go, impro or nfpm,
because both matrices are read out of the integration's own manifest:
packaging:
- format: deb
targets: [linux/amd64, linux/arm64]
setup: packaging/setup-nfpm.sh
command: packaging/build-nfpm.sh
- format: msi
targets: [windows/amd64]
runsOn: windows-latest
setup: packaging/setup-wix.ps1
command: packaging/build-msi.ps1
sign: windows-authenticode
Validation fails if a platform is declared and never packaged, because a platform nobody can install is not a platform. A .NET integration shipping an MSI joins by adding a manifest, which is what the contributing guide had been promising all along. Each release also carries an operator handbook as a PDF, whose settings reference is generated by running the binary — so it cannot document a variable that does not work.
The copy-the-binary story was wrong on both platforms, and silently. A console
process never answers the Windows Service Control Manager, so sc query
misreported state, failure-restart never fired, and Stop-Service killed rather
than stopped — leaving a vendor session held, which on the single-session tier is
the lockout described above. The documented systemd unit had the mirror-image
defect: Type=notify-reload with nothing ever sending READY=1, so systemd would
have waited out TimeoutStartSec and killed it. Neither had been noticed for the
same reason: nothing had ever been installed as a service. So the artefacts are
now a .deb, an .rpm, an .msi with a configuration wizard, and a .pkg
inside a .dmg — each installing a service its own platform recognises, with
archives and a container image for the hosts none of those suit.
Configuration is one command. --configure asks for every setting, mints the
API key, validates the answers against the vendor — including checking the
configured time zone against the system's own data — and only then writes
anything and enables the service. One implementation, used on all three
platforms.
The Bridge mints its own API key. There is no default credential and nothing to generate in the cloud first. It writes a key readable only by the service user, prints it exactly once, and the operator pastes that value into the app:
──────────────────────────────────────────────────────────────
This Bridge generated its own API key. Nothing else has it yet.
<48 hex characters>
Paste it into the 1Club app to finish connecting this site.
Print it again with: impro --show-api-key
──────────────────────────────────────────────────────────────
The direction matters. A secret that travels from the site to the cloud never needs a channel into the site to be established first, which means enrolment does not depend on the connectivity question being settled. A Bridge that can neither mint a key nor be handed one refuses to start. Minting only ever happens because a person asked, never on a service start: that would print the credential into the journal and leave a Bridge listening on a key nobody had read.
Commission before you start. --selftest is read-only, takes about a minute,
and answers the questions that otherwise become support tickets: do the
credentials work, which licence tier is in force, does the configured time zone
match what the system actually reports, and which doors have more than one reader
and therefore need their unlock target pinned by hand. That last one is a real
ambiguity — the unlock command addresses a reader, not a door, and a door
commonly has two. The integration picks deterministically, then flags the door
as ambiguous and publishes every candidate, rather than guessing silently.
Upgrades are always operator-initiated. A Bridge that updated itself would be a remote code execution path into a physical security system. So an upgrade is the platform's own package command, which stops the service, swaps the binary and starts it again; the environment file is marked as configuration so no package manager overwrites it, and the key file is not in the package at all. Rolling back is installing the version you came from — which is why the instruction is to keep that package on the host, since a rollback during an incident should not begin with a download. It is safe by construction, because the Bridge holds only regenerable state — an idempotency record, an event cursor, a session token. The access-control system holds the real state and the cloud holds the desired state. The one genuine cost is those idempotency records: losing them means a command retried across the rollback can run twice, so mid-unlock-burst is the wrong moment.
And the fleet is permanently version-skewed. Customer binaries update on the
customer's schedule. The cloud therefore branches on a capability document fetched
at runtime and never on the API version — because a version number tells you
nothing useful when one system can enumerate a person's permissions and another
cannot. 501 unsupported_operation is a first-class answer, deliberately distinct
from 404, so a capability gap never looks like a data problem.
The decision I deliberately did not make
Who dials whom is still open. Today the cloud calls the Bridge: an ordinary HTTPS
API, debuggable with curl, at the cost of an inbound firewall rule and a
certificate on customer hardware. The alternative is the Bridge dialling out and
holding a persistent connection, which removes the single largest deployment
obstacle and replaces it with a bespoke tunnel protocol to design, test and
support — multiplexing, correlation, backpressure, reconnection — where a defect
hits every customer at once instead of one site.
I did not want to build that on a guess. So the code is arranged to keep the
choice cheap: the integration interface contains no HTTP at all, just methods over
context.Context, and the HTTP host is a separate package that happens to serve
it. Outbound mode is a second host package over the same unchanged integration.
The idempotency key is already required on unlock, because a reconnecting tunnel
needs replay protection just as much as a retried request does.
What settles it is not architecture, it is arithmetic: record, for the first ten installations, whether an inbound rule was granted, how long it took, and who had to approve it. Ten data points will answer it better than any amount of discussion, and Go's cheap seam between transport and behaviour is what buys the time to collect them.
Written by
Deyan Peev
Founding Engineer · Sofia, Bulgaria


