keyhole #
A small auth-injecting reverse proxy for trusted private networks
Keyhole exposes configured upstream APIs at their configured domains with unchanged paths, accepts unauthenticated client requests, strips caller-supplied authentication, injects service credentials, and proxies the response. Only explicitly allowed paths are exposed; configure deny for authentication routes within an allowed subtree
trust model #
Reachability is the trust boundary. Keyhole does not authenticate clients and is not safe to expose to an untrusted network. Put network policy, a private namespace, firewall rules, or another reachability control in front of it
Configuration is administrator-controlled. Clients are hostile. Upstreams are trusted. Keyhole does not attempt to defend against a malicious upstream that deliberately reflects its credential in a response body
This repository contains the standalone application only. It does not install or deploy a service
behavior #
- fixed configured upstream domains selected by the incoming
Hostheader, never caller-selected target URLs - every method on explicitly allowed upstream paths is proxied without a prefix
- allow and deny rules match case-insensitively after repeated percent-decoding and path normalization; deny takes precedence
- caller
Authorization,Cookie, proxy-auth, forwarding, and configured auth headers cannot override injected credentials - upstream
Set-CookieandSet-Cookie2headers are not returned to clients - static header injection for token-authenticated APIs
- internal JSON or form login plus in-memory cookie sessions for services such as Flood and qBittorrent
- one automatic relogin and retry on configured authentication failure statuses
- secret files must not be accessible by group or other
- logs contain startup state only, never request headers or bodies
run #
Requires Node.js 22 or newer; the packaged application has no runtime dependencies. From a checkout, install the locked development dependencies and compile the TypeScript sources before starting Keyhole:
npm ci
npm run build
node dist/cli.js /path/to/config.json
The listener is ordinary HTTP. Bind it only to an address whose reachability already expresses the intended trust boundary
Requests use the configured domain and the upstream path directly (DNS must resolve the domain to the Keyhole listener):
curl http://jellyfin.intranet.net:3210/System/Info
curl http://flood.intranet.net:3210/api/torrents
See examples/config.example.json for static-header and cookie-login examples
configuration #
Top-level fields:
listen.host- TCP address to bindlisten.port- TCP port, or0for an ephemeral test portupstreams- map of lowercase service names to upstream definitions; names label configuration only, not request paths
Each upstream has:
baseUrl- an HTTP(S) origin with no path, query, credentials, or fragmentdomain- unique lowercase DNS name expected in the incomingHostheader (optional port must match the listener port)allow- required non-empty list of exposed paths, as{ "path": "/api/torrents", "subtree": true }; unmatched paths return 404deny- optional paths to exclude from allowed paths, using the same rule shape; deny winsmaxBodyBytes- optional buffered request limit, default 64 MiBauth-none,header, orcookie-login
Secret references support:
{ "file": "/run/credentials/token" }
{ "file": "/run/credentials/service.env", "format": "env", "key": "API_TOKEN" }
{ "file": "/run/credentials/service.json", "format": "json", "key": "password" }
Cookie login endpoints are always denied to clients automatically, even if covered by allow. Prefer narrow allow rules; add deny for other auth routes when an allowed subtree includes them
limits #
Request bodies are buffered to support one authenticated retry. Responses stream. WebSocket upgrades are not implemented in the initial version
Keyhole does not rewrite HTML, JavaScript, or redirect bodies, so it is intended for service APIs rather than browser UIs
test #
npm ci
npm test
npm run check
The suite covers arbitrary method/path proxying, caller-auth stripping, auth-route removal including encoded and case variants, cookie login and relogin, unknown services, insecure secret files, and cross-origin login configuration
Nix flake #
The flake exports:
packages.<system>.keyholeandpackages.<system>.defaultapps.<system>.keyholeandapps.<system>.defaultnixosModules.keyholeandnixosModules.default
The NixOS module uses pkgs.formats.json to generate the entire Keyhole JSON configuration from a Nix attrset. systemd credentials keep secret values out of that generated store path
{
inputs.keyhole.url = "git+https://example.invalid/keyhole";
outputs = { self, nixpkgs, keyhole, ... }: {
nixosConfigurations.proxy-host = nixpkgs.lib.nixosSystem {
system = "x86_64-linux";
modules = [
keyhole.nixosModules.default
({ config, ... }: {
services.keyhole = {
enable = true;
credentials = {
jellyfin-api-key = config.age.secrets.jellyfin-api-key.path;
flood-password = config.age.secrets.flood-password.path;
};
settings = {
listen = {
host = "10.0.0.5";
port = 3210;
};
upstreams.jellyfin = {
baseUrl = "http://127.0.0.1:8096";
domain = "jellyfin.intranet.net";
allow = [
{ path = "/System"; subtree = true; }
{ path = "/Items"; subtree = true; }
];
auth = {
type = "header";
header = "X-Emby-Token";
secret.credential = "jellyfin-api-key";
};
};
upstreams.flood = {
baseUrl = "http://127.0.0.1:3001";
domain = "flood.intranet.net";
allow = [ { path = "/api/torrents"; subtree = true; } ];
auth = {
type = "cookie-login";
loginPath = "/api/auth/authenticate";
bodyType = "json";
cookieNames = [ "jwt" ];
body = {
username.literal = "service-user";
password.secret.credential = "flood-password";
};
};
};
};
};
})
];
};
};
}
services.keyhole.credentials maps systemd credential names to source paths. Every { credential = "name"; } reference under settings must have a matching declaration or NixOS evaluation fails
The service uses a dynamic user and systemd hardening, but intentionally does not define firewall policy. Reachability is the trust boundary, so the deploying host must expose the configured listener only to the trusted network
Verify the package and module with:
nix flake check
nix build .#keyhole