For home labs, shop floors and customer sites
Reach applications in a private network, without opening any ports.
A database in a stockroom, a Pi in a spare room, a customer's ERP — whatever your cloud applications need to reach, there is no inbound path to it and no VPN. So the machine dials out instead: an agent holds one outbound HTTPS connection and pulls work down it. Whoever administers that machine writes a deny-by-default policy — GET on these two paths, this one command, nothing else — and can print it. The credentials stay on the machine and never reach the cloud side.
One machine or five hundred · Deny-by-default policy the machine's owner holds · No inbound ports, no VPN · Commercial licence with a 30-day evaluation
Two ways people run it
The mechanism is identical either way. What changes is who the person writing the policy is — you, or somebody you are selling to.
Something of your own
A home lab, a side project, a workshop machine, the one server at a small business. You own the network and you administer the machine, so you write the policy for yourself — and it is worth writing narrowly anyway, because it is what stands between a mistake in your cloud code and everything else on that LAN. The agent is a single static binary on Windows, Linux or a Raspberry Pi; it needs no port forward, no dynamic DNS and no address of its own, which is the whole point behind CGNAT or a router you don't control. Self-host the server on a box you already have, or register for an instance here.
Shipping it to your customers
Your product runs on your customers' premises and your cloud side needs to reach it, so onboarding currently waits on a firewall change request. The agent goes out under your own name and your own certificate, their administrator writes the deny-by-default policy and prints it for their security review, and one API key reaches one customer's machines and nothing else. Their ERP password stays on their machine and never reaches your servers. What that takes, in detail →
Shipping it to your own customers
Skip this if you are running it for yourself. Your customers are signed up in your product, not here, and everything below exists so that stays true at two hundred sites.
It carries your name, not ours
The Windows service name, display name and description, the Apps & features entry with its publisher
and support URL, the systemd unit and its Description=, the service account and the log file
are all set at build time. The site administrator who opens Services sees your product. A component they
can't account for is one their security review asks about, and "some third party our supplier uses" is
the wrong answer to give on their behalf.
One key per customer, reaching only their machines
An API key can carry a reach — a set of tags or named machines outside which it can
neither send work, read a result, nor discover anything exists. Reading a task, an agent or an enrollment
token out of reach answers 404 rather than 403, so the estate can't be mapped
by trying names; aiming work at a machine the key cannot reach is refused with 403, and with
the same message whether or not that machine exists. A leaked key reaches one customer.
Onboard a site from your own product
The whole sequence is on the API: mint an enrollment token, hand back the command to run on the machine,
approve the machine that used it. The agents:provision scope carries exactly that authority
— not task submission, and not agent removal, because signing a customer up and wiping their machines are
opposite ends of the relationship. Nobody signs in to a dashboard here.
Signed, and packaged the way their IT expects
Authenticode on Windows via osslsigncode or signtool, with arguments passed
through untouched for an HSM, a token or Azure Trusted Signing — and a detached GPG signature over
SHA256SUMS for Linux. Shipped as .deb, .rpm and .msi,
so it deploys through the tooling their IT already runs. An unsigned binary means a SmartScreen warning
naming an unknown publisher, in front of every site administrator, before anyone reads a word.
What a reach is not. There is no tenant object. Underneath it is one database, one process, one queue, one retention setting and one backup, and the rate limit is per key while the queue is not. Anyone signed in to the console — and every forward and schedule configured there — addresses the whole fleet. A reach makes one instance defensible for a vendor serving many customers. Where your customer is the one who must be assured, or their data must not sit beside anyone else's, run an instance each.
Why it's built this way
The design assumes the cloud side may be compromised, and the local machine should still be safe.
The local machine holds the authority
The cloud can only ask. Each agent has a local policy file listing the exact task types, hosts, paths and commands it will accept — anything else is refused before it runs, no matter what the server sends.
Latency that feels synchronous
Agents hold a long poll open, so a task usually starts within milliseconds. Submitting with
wait_ms turns the whole round trip into a single blocking HTTP call.
Nothing to install on machines you least want to touch
The agent is one static binary — about 7 MB, versus roughly 99 MB for a bundled Node runtime — that registers itself as a native service. No runtime, no dependencies, no runtime CVEs to patch across the fleet.
Small enough to audit
The server is plain TypeScript run directly by Node 24 — no build step, and no runtime dependencies at all unless you choose Postgres.
Get your OpenHook instance
Tell us who you are and confirm your email, and we'll provision your instance at your own subdomain — then send your dashboard URL and sign-in details. A company is not required; a personal project is a fine answer.
Confirm your email
We've sent a confirmation link to .
Open it within 24 hours and we'll provision .
We hold that subdomain for you until the link expires. Nothing arrives? Check your spam folder, or register again for a fresh link.
Install an agent
Once your instance is live, install the agent on any Windows or Linux machine inside your network. The dashboard generates these commands with your token filled in.
Windows (elevated PowerShell)
$env:OPENHOOK_SERVER='https://acme.openhook.cloud'
$env:OPENHOOK_ENROLL_TOKEN='ohe_…'
irm https://acme.openhook.cloud/download/install.ps1 | iex
Linux (root)
curl -fsSL https://acme.openhook.cloud/download/install.sh | \
sudo OPENHOOK_SERVER='https://acme.openhook.cloud' \
OPENHOOK_ENROLL_TOKEN='ohe_…' bash
Both write a deny-by-default policy — the agent reports system info
and calls localhost, nothing more, until you widen it in agent.config.json.
Calling a local service from the cloud
curl -X POST https://acme.openhook.cloud/api/v1/call \
-H "Authorization: Bearer $OPENHOOK_KEY" \
-H "Content-Type: application/json" \
-d '{
"tag": "warehouse",
"url": "http://192.168.1.50:8080/api/stock/SKU-441",
"method": "GET"
}'
{
"task_id": "8f3c…", "agent_id": "a55d…",
"duration_ms": 37,
"status": 200,
"json": { "sku": "SKU-441", "on_hand": 128 }
}
Give an internal app a public URL
Tasks answer "the cloud wants to run something." Forwarding answers the opposite — an outside caller wants to reach an application inside your network that has no public address at all.
curl -H "x-api-key: ohf_…" \
https://acme.openhook.cloud/f/8Kd2mQ7xR4vN9pL1wZ3aBc/stock/SKU-441
Forwarding → New forward gives you that unguessable URL. A request
to it is checked against your rules, carried inside by the agent, run against the internal URL you configured,
and the response comes back exactly as it was — status, headers and body, binary included. One response is
capped under a megabyte by default and an overrun is refused rather than truncated, so a large file has to be
fetched with Range, which is relayed inward and narrowed to fit. The application itself stays
unreachable, and no port is opened anywhere.
| Layer | What it does |
|---|---|
| Unguessable URL | 128 random bits on the /f/ address — the floor, never the whole fence. A forward given
its own hostname answers at that address instead, so the gates below become the only ones. |
| Source IP allowlist | Addresses or CIDR ranges; off the list is refused whatever else the caller holds |
| API key | x-api-key or Authorization: Bearer, shown once, stored as a hash |
| Basic auth | Username and password, with the challenge older clients need |
IP filtering is an AND; API key and Basic auth are alternatives, so different callers
can hold different credentials. Every relayed request is still an ordinary http.request task
underneath — the agent's policy has the final say, and widening it for one forward widens it for everything. A
disabled forward answers exactly like one that never existed, so the difference can't be probed.
Secrets that never leave your private network
A task often needs a credential. Reference it instead of carrying it, and the server never sees the value.
// variables.json — sits beside agent.config.json, owner-only permissions
{
"DbPassword": "Tr0ub4dor-and-3-horses",
"ApiKey": "sk-live-9f3a7c2e8b1d"
}
- Reference it from a task payload with
$Name— the agent substitutes the value just before running; the server stores and logs the reference, never the secret. - Redacted both ways: results, streamed output and error messages are scanned and rewritten back to
$ApiKeybefore they leave the machine. - The file is per machine, owner-only permissions, deleted on uninstall — nothing to rotate fleet-wide, and compromising the server yields none of them.
- An unknown
$Nameis left as text by default, so a shell command containing$HOMEstill works; setvariables.strictto refuse instead.
This is defence in depth, not a guarantee — redaction matches literal text, so a task that encodes a secret before printing it can still defeat it. The agent's policy is the real boundary.
Task types
Every type is off unless the agent's policy enables it.
| Type | What it does | Payload |
|---|---|---|
http.request |
Calls an HTTP service only the agent can reach | { url, method?, headers?, body?, json?, timeout_ms?, max_bytes? } |
shell.exec |
Runs an allow-listed executable | { command, args?, cwd?, env?, stdin?, timeout_ms? } |
script.run |
Runs an inline script | { interpreter, code, args?, cwd?, env? } |
fs.read / fs.write / fs.list |
File access under allowed roots | { path, … } |
net.check |
TCP reachability probe | { host, port, timeout_ms? } |
sys.info |
Host facts: OS, CPU, memory, uptime, interfaces | {} |
agent.logs |
Tail the agent's own log file | { lines?, contains? } |
agent.restart |
Restart the agent via its service manager | { delay_ms? } |
agent.uninstall |
Deregister, remove the service, delete the files | { keep_registration? } |
agent.logs and agent.restart are enabled by default —
without them a headless Windows service can't be diagnosed from the dashboard. agent.uninstall is
not, because it can't be undone from the cloud; enabling it is a deliberate local decision.
Target with "agent": "warehouse-pc-01" for one machine,
"tag": "warehouse" for whichever tagged agent is free, or neither for any capable agent. Tasks
wait in the queue, so a machine can be offline when the work is submitted. Any task can also run on a cron
schedule from Tasks → Scheduled, timezone-aware and previewed as you type.
Security model
Two independent gates: an administrator approves the machine, and then a config file on that machine decides what it will run.
First, the machine has to be approved
Installing an agent does not put it to work. A machine that enrols appears on the Agents page as pending and is refused every task until an administrator approves it there — so an enrollment token that leaks, or is used on a machine you did not mean, produces a request for a decision rather than a foothold.
- The pending card shows the hostname, platform, version and the address it connected from — which is what you check it against.
- Approving starts the agent within a couple of seconds, with nothing to restart. Until then it says so plainly in its own log and keeps asking.
- Re-enrolling an existing agent has to be approved again. Re-enrolment issues a fresh token for a name that already exists, which is exactly what taking over a trusted machine would look like, so the approval does not carry over.
- The gate can be turned off under Settings → Agents for fleets provisioned by automation that has no one to click — which leaves the enrollment token as the only thing standing between a machine and your network.
Then the policy decides what it will run
"policy": {
"enabled_types": ["http.request", "fs.read"],
"http": {
"allow_hosts": ["10.0.0.0/8", "*.corp.local"],
"deny_hosts": ["169.254.169.254", "169.254.0.0/16"],
"allow_methods": ["GET", "POST"],
"url_rules": [
{ "host": "api.corp.local",
"path": "/v1/secrets/*", "methods": [] },
{ "host": "api.corp.local",
"path": "/v1/orders", "methods": ["POST"] }
]
},
"fs": { "allow_read_roots": ["C:/openhook/inbox"] },
"shell": { "allow_commands": [], "allow_any": false }
}
shell.execandscript.runare disabled by default; each executable or interpreter must be named.- Filesystem access is confined to explicit roots, checked after resolving
... - HTTP targets match allow/deny lists supporting exact names,
*.wildcardsand CIDR ranges. - Hostnames are resolved and the resulting IP re-checked, so a DNS name pointing at a blocked range is still refused.
- Cloud metadata endpoints are blocked out of the box. Redirects are not followed unless you opt in, and are re-checked on every hop when you do.
url_rulespin an individual path to its own methods, so one service can be readable and another writable. First match wins, as in a firewall list.- Rules can only ever subtract — the host list and
allow_methodsare both checked first. A rule that names a host governs that host entirely, so anything its rules do not cover is refused. - Paths are normalised the way a permissive server would read them before matching — backslashes,
;matrixparameters,..— so a rule cannot be walked around.
And across the platform
- Credentials are stored as SHA-256 hashes (passwords use scrypt): enrollment tokens, agent tokens, and scoped API keys.
- On-prem credentials never leave your premises — a task references a local secret by name, and only the agent that owns it ever sees the value.
- Every privileged action lands in an audit log that can be mirrored to your SIEM.
- Dashboard sessions use HttpOnly, SameSite=Lax cookies plus a custom-header CSRF check; repeated failed sign-ins lock an IP out for 15 minutes.
What OpenHook will not do: stop an operator who deliberately sets
allow_any: true on cmd.exe from handing the cloud full control of that machine. Keep
policies narrow.
Identity and access
Single sign-on against any OIDC provider — Entra ID, Okta, Google Workspace, Keycloak, Auth0 — with authorization-code flow and PKCE.
| viewer | operator | admin | owner | |
|---|---|---|---|---|
| Read tasks and agents | ✓ | ✓ | ✓ | ✓ |
| Submit and cancel tasks | ✓ | ✓ | ✓ | |
| Manage agents, keys, users, audit | ✓ | ✓ | ||
| System settings | ✓ |
API keys are separate credentials for machines, scoped to tasks:read,
tasks:write, agents:read, agents:manage and
agents:provision — the last minting an enrollment token and approving the machine that used it,
and nothing else — with optional expiry and a per-key rate limit. A key can also carry a reach — a set of tags or named machines outside
which it cannot send work, read a result, or discover that anything exists.
Reliability & observability
Leases
A claimed task carries a lease; if the agent dies, the task is requeued — or failed, once
max_attempts is spent — rather than lost.
Cancellation
Cancellation reaches a running task within seconds and kills its process tree.
Idempotency
Pass idempotency_key and a repeated submission returns the original task, even across
replicas.
Graceful drain
On SIGTERM, readiness flips false, parked agents are released to reconnect elsewhere, and
in-flight work finishes before exit.
Metrics
/metrics serves Prometheus text: queue depth, task durations, queue wait, lease expiries,
auth failures, agent counts, live replicas.
Health checks
/healthz is liveness, /readyz is readiness including a database check. Logs are
structured JSON with automatic credential redaction.
Self-updating agents
Opt in with connection.auto_update.enabled: the agent fetches its own platform's build,
refuses it unless the SHA-256 matches, and restarts once running tasks finish. Off by default.
Scheduled tasks
Any task can run on a cron schedule with a timezone-aware preview. A missed occurrence is never replayed, and a run is skipped while the previous one is still going.
Completion webhooks
Set callback_url for an HMAC-signed POST when a task settles, retried with
backoff up to five times. Deliveries are at-least-once — key handlers off delivery_id.
Put one machine through it
Whether that machine is in your spare room or in a customer's server cupboard, one is enough to find out whether this fits — the policy you write for it is the same either way. Register for an instance, or write to us and we will go through it with you.
Documentation
OpenHook is commercially licensed rather than open source, so the documentation ships with an evaluation rather than sitting on a public URL. The licence grants thirty days of internal, non-production use to evaluate it, so you can try it before there is anything to negotiate. Ask and we will send the documentation the same day.
API reference
Public API, agent protocol, the provisioning endpoints, scopes and key reach, errors and rate limits.
Operations guide
Day-2 operations, backup and restore, monitoring, and the incident runbook.
Security review pack
What the agent does and cannot do, what the policy file controls, sample --print-policy
output, and what is logged — written to be handed to whoever has to sign off on the machine, whether
that is a customer's IT department or you.
Prefer to run it yourself?
OpenHook's server is plain TypeScript with no build step, and the agent is a static binary — both can be self-hosted instead of registering for a subdomain above.
npm start # Node 24+, SQLite
docker compose up -d # single node, SQLite
helm install openhook deploy/helm/openhook -f my-values.yaml
Full platform recipes for AWS, Azure, GCP and Kubernetes, plus SQLite vs. Postgres
tradeoffs, live in deploy/README.md.