on-premise

A door on someone else's network

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 SIGKILL locks 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.WithoutCancel is 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 503 rather 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.

Deyan Peev

Written by

Deyan Peev

Founding Engineer · Sofia, Bulgaria

Deyan Peev

Founding Engineer in Sofia, Bulgaria. Currently at 1club.

Elsewhere

© 2026 Deyan Peev