Configuring Écluse

This page covers the two configuration layers, the configuration document, the secrets, and the rule policy.

Two layers, one spelling rule#

Configuration has two layers. Environment variables carry process and secret values. An optional config document (YAML) carries the two things flat variables express badly: the rule policy and the mount map. A value resolves as defaults < config document < environment variable, so the environment wins. The boot log carries one config: line per resolved key, naming the layer that supplied it and redacting secrets. ecluse check-config prints the same dump.

One spelling rule. Environment variables are the mechanical transliteration of the document schema: __ descends into an object and _ joins a camelCase word. So ECLUSE_CACHE__MAX_BYTES spells cache.maxBytes, and ECLUSE_MOUNTS__NPM__MIRROR_TARGET spells mounts.npm.mirrorTarget.

A mount serves only when you declare it. Any ECLUSE_MOUNTS__<ECOSYSTEM>__* variable, or any key under mounts.<ecosystem> in the document, activates that mount. A mount you never mention stays off. Declaring mirrorTarget makes an active mount mirror, and a mirrored mount then requires its private upstream, so the mirror reads back. Omit mirrorTarget and the mount is serve-only. Each boot logs one posture line per mount and warns on any pair of a mount's endpoints that resolve to the same registry. The design rationale is in Configuration and authentication.

Three ambient AWS-SDK variables are read from the process environment and are not document keys. AWS_REGION scopes SQS only under an AWS_ENDPOINT_URL_SQS override (a real SQS URL carries its own region) and the S3 advisory client, never CodeArtifact. AWS_ENDPOINT_URL_SQS overrides the SQS endpoint and forces the SQS interpretation of queue.url. AWS_ENDPOINT_URL overrides the S3 advisory client only, never SQS. Neither endpoint value may carry userinfo, a query, or a fragment, and neither may write a malformed port. Surrounding whitespace is trimmed before parsing. Écluse refuses such a value, and either variable then fails the boot. The refusal names the variable and never the value, which can carry a credential.

The configuration reference#

The defaults the binary embeds, with every key, its default, and its meaning:

# The defaults Écluse boots from, embedded in the binary. Your config document and the
# ECLUSE_* environment variables override it key by key, in the order defaults <
# document < environment. ECLUSE_CACHE__TTL spells cache.ttl. A commented key is
# INACTIVE: it documents the schema, and names the computed or derived default that
# applies while it stays unset. Put secrets in environment variables, never here. An
# integer key takes a positive integer unless its comment says otherwise.

server:
  # TCP port, 0 to 65535. 0 binds an OS-assigned ephemeral port.
  port: 8080
  # The proxy's own externally-reachable base URL. The proxy rewrites served tarball
  # URLs against it. http(s) with a dialable authority, and http stays legal for
  # loopback. Required as soon as any mount is active, else the boot refuses
  # (PublicUrlRequired).
  # publicUrl: https://registry.example.com
  # Set via ECLUSE_SERVER__AUTH_TOKEN (a secret): clients must then present it.
  # authToken:
  # A note Écluse appends to client-facing error bodies, e.g. where to ask for access.
  # helpMessage: Contact platform engineering for access
  # Seconds the graceful shutdown waits for in-flight requests and artifact streams.
  shutdownDrainTimeout: 30

# The mirror queue, read only when a mount mirrors. The shape of url picks the
# backend. An SQS queue URL (https://sqs.{region}.amazonaws.com/...) selects the
# durable SQS backend and takes its region from the host. A Pub/Sub topic resource
# (projects/{p}/topics/{t}) names the GCP backend, which Écluse refuses at boot. Unset,
# mirroring runs on the bounded in-memory queue: non-durable, best-effort, and warned
# loudly at boot. That is fine for a single node or a trial.
queue:
  # How many deliveries one mirror job gets before the worker retires it outright. The
  # worker alarms and counts each retirement. A queue with a dead-letter queue attached
  # runs one delivery above that dead-letter queue's own maxReceiveCount. The
  # dead-letter queue therefore always captures the job first. This count is the floor,
  # and the only end a job gets on a queue with no dead-letter queue.
  maxReceiveCount: 5
  # An SQS URL is validated in full: https, a single-label region, a 12-digit account,
  # one queue segment, no port, query, or fragment.
  # url: https://sqs.us-east-1.amazonaws.com/123456789012/ecluse-mirror
  # In-memory queue only. Past the cap an enqueue is dropped (drop-newest) and the
  # package re-mirrors on next demand. Unset, the memory plan computes it.
  # memoryMaxDepth: 50000

limits:
  # Structural hostile-input bounds. They bound document shape, not bytes, so they are
  # a pinned policy stance rather than a machine property.
  maxVersionCount: 100000
  maxNestingDepth: 64
  # The memory plan computes the byte-valued caps at boot, as named tenants of the
  # effective heap ceiling, and the boot log names each decision. Set either one only
  # to override it. maxResponseBytes bounds an upstream metadata body, floored at
  # 12 MiB so real packuments fit, and the fetch aborts fail-closed past it.
  # maxRequestBytes bounds a client request body (a publish), refused with 413.
  # maxResponseBytes: 12582912
  # maxRequestBytes: 26214400
  # The mirror worker's per-artifact fetch cap. The memory plan computes this one too,
  # as the mirror-artifact tenant, sized so the transient publish envelope stays within
  # the heap ceiling. Raising it needs headroom the plan will refuse to boot without.
  # maxArtifactBytes: 536870912

cache:
  # Seconds the packument cache keeps metadata. Non-negative, and a fractional value
  # is refused rather than truncated.
  ttl: 60
  # Écluse computes both bounds at boot from the resolved heap ceiling, as a clamped
  # fraction of it, and the boot log names each decision. Set either one only to
  # override it.
  # maxEntries: 1024
  # One aggregate budget, split across the three stores (full packument 60%, single
  # version 15%, assembled the rest). A pod too small for the plan sheds it first.
  # maxBytes: 268435456

integrity:
  minPublic: sha256
  minTrusted: sha256 # refinable per mount: mounts.<ecosystem>.minTrustedIntegrity
  # What to do when the public and private copies of a version contradict on a shared
  # integrity algorithm (threat #11). Both policies log a WARNING and increment the
  # ecluse.registry.merge.divergence counter. The trusted copy always wins the bytes.
  #   warn        - serve the trusted copy and rely on the alarm (default)
  #   fail-closed - additionally withhold the contested version from the served listing
  divergencePolicy: warn # refinable per mount: mounts.<ecosystem>.divergencePolicy

# Extra CIDR ranges denied registry egress, on top of the built-in internal-range
# blocklist (comma-separated). Extends the block only. A malformed entry fails closed
# at boot.
# egress:
#   additionalBlockedRanges: "100.64.0.0/10,198.18.0.0/15"

advisories:
  # How often the proxy polls object storage for a fresh advisory database (a cheap
  # ETag HEAD). It is deliberately independent of Pilot's compileInterval, and much
  # more frequent: matching them would nearly double the worst-case advisory age.
  pollInterval: 60
  # Pilot only: how often it recompiles and re-exports the advisory artifacts.
  compileInterval: 3600
  # Where Pilot compiles the advisory databases and where the proxy lands synced ones.
  dataDir: "data/osv"
  # Base URL of the per-ecosystem OSV advisory exports Pilot compiles from
  # (<base>/<ecosystem>/all.zip). Override it if the upstream moves or you mirror it.
  # A new URL must never require a new Écluse binary. Allowlist this host for Pilot's
  # egress, not osv.dev.
  osvExportBaseUrl: "https://osv-vulnerabilities.storage.googleapis.com"
  # Refuse to download an advisory database larger than this many bytes (512 MiB).
  # The declared length fails fast and the streaming download enforces the cap.
  maxDatabaseBytes: 536870912
  # The object-storage bucket the compiled advisory database syncs from. Unset, the
  # advisory stack is off and the advisory-dependent rules abstain.
  # bucket: my-advisory-bucket

# Process sizing. Écluse computes every key at boot from the container's cgroup
# limits, the RTS posture, and the file-descriptor budget. The boot log records each
# decision with its provenance. Set a key only to override the computed value.
# runtime:
#   # Cores the process claims: the cgroup CPU quota floored, at least 1, clamped to
#   # the visible processors. The memory plan may shed some on a small pod. Give the
#   # container whole cores.
#   cores: 4
#   # Heap ceiling in bytes: the cgroup memory limit less the nursery budget and 10%
#   # slack, floored at half the limit. Enforcing it re-executes the binary once. A
#   # breach is a clean heap-overflow error, not a kernel OOM kill.
#   maxHeapBytes: 1073741824
#   # Concurrent metadata materialisations, computed from cores and the heap ceiling
#   # (the boot log names the value). Over it a request waits up to 1 s for a slot,
#   # then gets 503 with Retry-After: 1. That 503 is backpressure: exclude it from
#   # alerts. A real upstream failure returns 503 without the header.
#   serveMaxInFlight: 128
#   # Pooled connections per public upstream host: clamp(32, 1024, nofile / 8).
#   # Connections beyond the pool still open, but re-handshake TLS each time.
#   publicConnectionsPerHost: 64
#   # Pooled connections to the private upstream host: nofile / 4, clamped to 64..4096.
#   privateConnectionsPerHost: 256

observability:
  # json: one JSON object per line, for log collectors. console: human-readable.
  logFormat: json
  # The lowest severity Écluse logs: debug, info, warn, or error. Debug carries the
  # per-decision diagnostics, such as mirror probes and artifact fetches, and is
  # verbose under load.
  logLevel: info
  # The OpenTelemetry master switch, on or off. Off, Écluse emits no telemetry. Logs
  # go to stdout either way.
  telemetry: "off"

mounts:
  # Dormant per-ecosystem templates. A mount activates only when your document or
  # environment declares any key under it. `enabled: true` alone is enough for the
  # serve-only pure public gate, and declaring `mirrorTarget` is what makes a mount
  # mirror.
  npm:
    publicUpstream: https://registry.npmjs.org
    # The only key the serve-only pure public gate needs. false switches off a mount
    # whose other keys remain.
    # enabled: true
    # The read authority. Required on a mirrored mount, so the mirror reads back.
    # Optional on a serve-only mount, where it still merges with the gated public set.
    # privateUpstream: https://acme-123456789012.d.codeartifact.us-east-1.amazonaws.com/npm/internal/
    # Declaring it makes the mount mirrored. The write credential derives from this
    # URL: a CodeArtifact host mints a short-lived token scoped to its domain, and any
    # other host needs mirrorTargetToken.
    # mirrorTarget: https://acme-123456789012.d.codeartifact.us-east-1.amazonaws.com/npm/mirror/
    # Set via ECLUSE_MOUNTS__NPM__MIRROR_TARGET_TOKEN (a secret). Required for a
    # non-CodeArtifact mirror target, forbidden for a CodeArtifact one or a serve-only mount.
    # mirrorTargetToken:
    # Lifetime in seconds of the minted CodeArtifact token, 900 to 43200. Unset,
    # CodeArtifact defaults it to the caller's role-credential expiry. The refresh
    # policy adapts to the minted token's actual expiry, so this is a preference.
    # mirrorCodeArtifactTokenDuration: 3600
    # Where a client `npm publish` is relayed. Opt-in: unset, PUT /{pkg} is 405.
    # publicationTarget: https://acme-123456789012.d.codeartifact.us-east-1.amazonaws.com/npm/internal/
    # Set via ECLUSE_MOUNTS__NPM__PUBLICATION_TARGET_TOKEN (a secret). Forwarded only
    # when the publishing client sends none. Requires server.authToken, or the boot
    # refuses (PublishStaticCredentialNeedsEdge).
    # publicationTargetToken:
    # Names a client may publish, as npm scopes. Required with a publication target.
    # It limits names, not callers, and an empty or malformed list is a boot error.
    # publishAllow: "@acme,@beta"
    # Per-mount refinements of integrity.minTrusted and integrity.divergencePolicy.
    # minTrustedIntegrity: sha256
    # divergencePolicy: warn
    # Per-mount rule refinement, merged over the shared policy with the same schema
    # as the top-level rules.
    # rules: {}
  pypi:
    publicUpstream: https://pypi.org
  rubygems:
    publicUpstream: https://rubygems.org

rules:
  min-age:
    type: AllowIfOlderThan
    precedence: 100
    ageSeconds: 604800
  # The remediation fast lane. Écluse admits a version a synced advisory names as its
  # exact fix ahead of the min-age quarantine. With no advisory database configured
  # (no advisories.bucket) the rule abstains, and only the quarantine governs.
  remediation-fast-track:
    type: AllowIfRemediatesCve
    precedence: 150
  # Opt-in CVE deny gate, absent from this shipped policy. It denies a version a
  # synced advisory records as affected at or above `minSeverity` (a CVSS base score,
  # 0-10). Unscored npm malware advisories count as above every threshold, so
  # enabling this also blocks known malware. It sits just below `AllowByIdentity`, so
  # an identity pin still overrides it. On a cold mirror it can deny historical
  # versions your builds depend on. Warm the mirror first:
  # https://ecluse-proxy.com/docs/configuration/#onboarding-denyifcve
  # deny-known-cves:
  #   type: DenyIfCve
  #   minSeverity: 8          # CVSS >= 8.0 (high/critical). Unscored malware always blocks
  #   onUnavailable: deny     # fail-closed if the advisory database cannot answer.
  #                           # Set to "skip" to fail-open (availability over the gate)

The configuration document#

A YAML file at /etc/ecluse/config.yaml. ECLUSE_CONFIG relocates it, and is the one process-level setting with no document key. With it set, a missing file is a boot error. At the default path an absent document is fine. The document carries only what you change: the rule policy (see Rule policy) and, for multi-mount deployments, the mount map. A single-mount npm deployment on the default policy needs none. The schema is the embedded default above, and an unknown key anywhere in the document is a boot error.

A worked document for a mirrored npm deployment. Reads resolve against a private CodeArtifact endpoint, approved public packages mirror into a separate CodeArtifact store, and the quarantine widens to fourteen days. That distinct read endpoint and mirror store is the recommended topology.

server:
  publicUrl: https://ecluse.example.internal
  helpMessage: Contact the ACME platform team for access

queue:
  url: https://sqs.us-east-1.amazonaws.com/123456789012/ecluse-mirror

advisories:
  bucket: acme-ecluse-advisories

mounts:
  npm:
    privateUpstream: https://acme-123456789012.d.codeartifact.us-east-1.amazonaws.com/npm/internal/
    publicUpstream: https://registry.npmjs.org
    mirrorTarget: https://acme-123456789012.d.codeartifact.us-east-1.amazonaws.com/npm/mirror/

rules:
  min-age:
    ageSeconds: 1209600

Delete the mirrorTarget line and the same mount is serve-only. It still merges the private upstream with the gated public registry, it never writes, and queue then goes unread. Delete privateUpstream as well and the mount is the pure public gate of the quick start in document form. enabled: true is then the only key it needs, because publicUpstream already has a default.

No token appears above. Écluse mints the mirror-target write credential from the CodeArtifact host. Every other secret is an environment variable.

Secrets#

Secrets never live in the config document. Client and registry tokens are always env vars. A CodeArtifact mirror target needs none: Écluse mints its short-lived write token from the container's ambient AWS credentials. Any other mirror-target host needs ECLUSE_MOUNTS__NPM__MIRROR_TARGET_TOKEN. A mirrored mount therefore holds one write credential, and a serve-only mount never writes and holds none.

The secret-typed variables also accept the container-secret file pattern. Set the _FILE form (ECLUSE_SERVER__AUTH_TOKEN_FILE, ECLUSE_MOUNTS__NPM__MIRROR_TARGET_TOKEN_FILE, ECLUSE_MOUNTS__NPM__PUBLICATION_TARGET_TOKEN_FILE) to a file path. The file's contents, with trailing newlines stripped, become the value, so the token never enters the environment. Setting both a variable and its _FILE form, or naming an unreadable file, is a fail-loud boot error.

A registry URL never carries a token either. Écluse refuses an endpoint written with userinfo (https://user:token@host/), a query string, or a fragment at boot, and the error names the key. The same refusal covers server.publicUrl, advisories.osvExportBaseUrl, and queue.url. That is why the config: boot echo and ecluse check-config print each endpoint in full. What Écluse does with a client's own token is under Edge authentication. The credential model is in Credential flow and authority and Outbound registry credentials.

Validation#

Écluse validates the configuration in full at startup and refuses to start on any problem. An unknown rule type, a bad URL, or an unresolved policy reference all stop the boot. A misconfiguration is then a loud, immediate failure rather than a quietly mis-enforced policy. ecluse check-config runs the same validation without starting anything. The validation model is in Validation: fail fast, reject the unknown.

Rule policy#

The policy is a named map of rules over the deny-by-default gate described in The policy. It lives in the config document's rules object. ECLUSE_RULES carries the same object as JSON, which suits a one-rule tweak, and the document stays the reviewable home for a real policy. The shipped default:

Every other built-in rule is off by default and opts in by name:

A shipped name patches that rule, enabled: false suppresses it, and a new name with a type adds a rule. Precedence defaults per type, and an integer precedence overrides it:

rules:
  min-age:
    ageSeconds: 1209600
  remediation-fast-track:
    enabled: false
  deny-scripts:
    type: DenyInstallTimeExecution
  revoke-bad:
    type: DenyByIdentity
    identity: bad-package
  pin-fix:
    type: AllowByIdentity
    identity: left-pad@1.3.0
  deny-known-cves:
    type: DenyIfCve
    minSeverity: 8

The precedence values, the patch/add/suppress merge model, and the strict validation are in Rule policy and Rules engine.

Onboarding DenyIfCve#

DenyIfCve can break a cold deployment. On a freshly stood-up mirror it can deny historical versions your existing builds still depend on that an advisory has since covered. Enable it after you warm your private mirror:

  1. Leave DenyIfCve out of your policy and run Écluse normally, so your CI and developers pull the versions you depend on. Each lands in the trusted store, which the rules never re-gate once the version is there.
  2. Once your must-have builds have mirrored, add DenyIfCve with a minSeverity you are comfortable with. A threshold of 8 blocks high and critical CVEs, and malware blocks regardless of the threshold.
  3. If Écluse then denies a specific version you must keep, pin it with an AllowByIdentity rule, which outranks DenyIfCve. That covers a false positive or a risk you accept.

Set onUnavailable: skip if you would rather the gate fail open (skip itself, logging loudly) than refuse traffic when the advisory database is briefly unavailable. The default deny fails closed.