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. SoECLUSE_CACHE__MAX_BYTESspellscache.maxBytes, andECLUSE_MOUNTS__NPM__MIRROR_TARGETspellsmounts.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:
min-age(AllowIfOlderThan): admit public versions older than a quarantine window (7 days by default), the core defence against race-to-publish typosquatting and dependency confusion.remediation-fast-track(AllowIfRemediatesCve): admit a release a synced advisory names as its exact fixed version ahead of the quarantine, provided no other advisory still affects it. It abstains until a first advisory database syncs (setECLUSE_ADVISORIES__BUCKETand run Pilot), so without one only the quarantine governs.
Every other built-in rule is off by default and opts in by name:
AllowByIdentity: admit a specific package orpackage@versionpast the quarantine, at the top of the allow band but still below every deny.DenyByIdentity(therevokeshape): a hard deny for a specific package orpackage@version.DenyInstallTimeExecution: deny install-time code execution (off because many legitimate packages ship install scripts).DenyIfCve: block a version a synced advisory records as affected at or above a CVSSminSeverity(0-10). The npm malware feed carries no score and counts as above every threshold, so enabling it also blocks known-malicious packages. It sits just belowAllowByIdentity, so an identity pin overrides it. ItsonUnavailableknob (denyby default, orskip) decides what happens when the advisory database cannot answer. Read Onboarding DenyIfCve before enabling.
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:
- Leave
DenyIfCveout 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. - Once your must-have builds have mirrored, add
DenyIfCvewith aminSeverityyou are comfortable with. A threshold of 8 blocks high and critical CVEs, and malware blocks regardless of the threshold. - If Écluse then denies a specific version you must keep, pin it with an
AllowByIdentityrule, which outranksDenyIfCve. 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.