auth proxy
TypeScript 78%
Nix 20%
JavaScript 2%

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
  • 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 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
  • maxBodyBytes - optional buffered request limit, default 64 MiB
  • auth - none, header, or cookie-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>.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;
            };

            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