auth proxy

Files
README.md

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 Host header, 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-Cookie and Set-Cookie2 headers 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
  • internal JSON or form login plus an in-memory bearer token for services that return an access token, such as an ATProto PDS
  • one automatic relogin and retry on configured authentication failure statuses
  • secret files must not be accessible by group or other; systemd credential references only must not be accessible by other, because systemd installs LoadCredential= files as 0440 under DynamicUser=
  • 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, cookie-login, and token-login examples

configuration #

Top-level fields:

  • listen.host - TCP address to bind
  • listen.port - TCP port, or 0 for an ephemeral test port
  • upstreams - 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 fragment
  • domain - unique lowercase DNS name expected in the incoming Host header (optional port must match the listener port)
  • allow - required non-empty list of exposed paths, as { "path": "/api/torrents", "subtree": true }; unmatched paths return 404
  • deny - optional paths to exclude from allowed paths, using the same rule shape; deny wins
  • auth - none, header, cookie-login, or token-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" }

Static header authentication (auth.type = "header") injects the secret as the value of the configured header:

  • header - header name to inject, for example Authorization
  • secret - secret reference resolved at startup
  • valuePrefix - optional text prepended to the secret value, default ""; must not contain newlines
  • valueSuffix - optional text appended to the secret value, default ""; must not contain newlines

The header value is built once at startup. Keep the secret containing only the raw credential and use valuePrefix/valueSuffix for any framing the upstream expects

Jellyfin accepts API keys with the modern Authorization value MediaBrowser Token="<api-key>". Configure header = "Authorization", valuePrefix = "MediaBrowser Token=\"", and valueSuffix = "\""; the secret file still holds the raw key, so existing secrets need no change. The legacy X-Emby-Token header requires legacy authorization, which Jellyfin 12 disables by default (Jellyfin 12.0 release)

Cookie login (auth.type = "cookie-login") posts the configured body to loginPath with bodyType (json or form) and keeps the cookieNames values from the Set-Cookie response in memory:

  • loginPath - absolute login endpoint on the upstream origin
  • cookieNames - cookies the login response must set

Token login (auth.type = "token-login") posts the configured body to loginPath with bodyType (json or form) and keeps a top-level string field of the JSON response in memory:

  • loginPath - absolute login endpoint on the upstream origin, for example /xrpc/com.atproto.server.createSession
  • tokenField - top-level JSON field holding the token, for example accessJwt
  • header - optional header to inject, default Authorization
  • valuePrefix - optional value prefix, default "Bearer "; must not contain newlines
  • refreshStatuses - optional statuses that trigger one relogin and retry, default [401, 403]

The login response must be a 2xx JSON object whose tokenField is a non-empty string without newlines; otherwise the request fails with a generic upstream error. The token is cached in memory until a refreshStatuses response clears it, and login failures are not cached

Cookie and token 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 fully buffered in memory to support one authenticated retry, so memory use grows with upload size and concurrency. Responses stream. WebSocket upgrades are not implemented in the initial version

Keyhole imposes no upstream inactivity or response deadline. An outbound request waits for the upstream to respond, close the connection, or fail at the network layer, so configure connect and read timeouts on a reverse proxy or other fronting layer for production deployments

Keyhole imposes no inbound deadline for request headers or request receipt and no request body-size limit. Configure incoming size and time limits on the fronting nginx or other reverse proxy. A fronting proxy timing out a client-facing request does not cancel the corresponding outbound request; Keyhole does not propagate that cancellation

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, token login and refresh, unknown services, insecure secret files, and cross-origin login configuration

Nix flake #

The flake exports:

  • packages.<system>.keyhole and packages.<system>.default
  • apps.<system>.keyhole and apps.<system>.default
  • nixosModules.keyhole and nixosModules.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;
              pds-account = config.age.secrets.pds-account.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 = "Authorization";
                  valuePrefix = "MediaBrowser Token=\"";
                  valueSuffix = "\"";
                  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";
                  };
                };
              };

              upstreams.pds = {
                baseUrl = "https://pds.intranet.net";
                domain = "pds.intranet.net";
                allow = [
                  { path = "/xrpc/com.atproto.repo"; subtree = true; }
                  { path = "/xrpc/com.atproto.server.getSession"; }
                ];
                auth = {
                  type = "token-login";
                  loginPath = "/xrpc/com.atproto.server.createSession";
                  bodyType = "json";
                  tokenField = "accessJwt";
                  refreshStatuses = [ 400 401 ];
                  body = {
                    identifier.secret = {
                      credential = "pds-account";
                      format = "env";
                      key = "PDS_IDENTIFIER";
                    };
                    password.secret = {
                      credential = "pds-account";
                      format = "env";
                      key = "PDS_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

Because settings uses the freeform pkgs.formats.json type, every auth shape, including header with valuePrefix/valueSuffix, cookie-login, and token-login with tokenField, header, valuePrefix, and refreshStatuses, is serialized verbatim to the generated JSON. Secret references such as password.secret = { credential = "pds-account"; format = "env"; key = "PDS_PASSWORD"; } resolve through CREDENTIALS_DIRECTORY exactly like other secret references

A consuming flake uses the Keyhole revision in its lock file, not a separate local checkout. When testing local changes, pass --override-input keyhole path:/path/to/keyhole --no-write-lock-file to the Nix command. New auth options require both the updated settings and a Keyhole package that implements them

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