Snippy (Snippy v0.10.0)

Copy Markdown View Source

Discover SSL certificates and keys from environment variables and produce configuration suitable for :ssl, Cowboy, Ranch, Bandit, Thousand Island, or Phoenix.

How it works

Snippy runs in three lazy phases backed by a single shared GenServer:

  1. Scan (cheap). The Store walks the environment once and records every variable whose name ends in a recognized suffix (_CRT, _KEY, _PWD, ...). No PEM is decoded, no files are read. The scan is shared across every helper call.

  2. Filter by prefix (per call). Each helper takes the broad scan results and peels off entries whose names start with the requested prefix.

  3. Materialize (lazy, per group). Only when a helper actually asks about a (prefix, key) group does Snippy decode PEM, decrypt keys, validate the cert/key match, check expiry, and build the final :ssl payload. Successes and errors are memoized in ETS, so repeated lookups are constant-time and broken groups don't spam the log.

This shape gives Snippy a small DoS surface: env vars that no helper ever asks about never get decoded, even if an attacker can set arbitrary environment variables.

Helper option groups

All helpers accept the same option categories:

  • Required

    • :prefix - string, atom, or list of either.
  • Discovery passthrough (forwarded to the shared scan)

    • :case_sensitive - default true.
    • :env - env map override (mainly for testing).
    • :reload_interval_ms - if set, the Store schedules background re-scans at this cadence.
  • Per-lookup options

    • :default_hostname - SNI fallback host.
    • :expiry_grace_seconds - tolerate certs that expired up to this many seconds ago (default 0).
    • :public_ca_validation - :auto | :always | :never (default :auto).

    • :only - list of hostname patterns; only matching groups are exposed.
    • :keys - list of group key strings (or atoms); only matching groups are exposed.
  • Escape hatch

    • :discovered_certs - a %Snippy.Discovery{} from a prior call to discover_certificates/1. When provided, the helper uses that discovery's groups directly and skips the shared Store entirely. Useful when you want to control exactly when (and against what env) materialization happens, e.g. pre-warming at boot.

Quick example

Snippy.cowboy_opts(prefix: "MYAPP")

# equivalent to:
{:ok, disc} = Snippy.discover_certificates(prefix: "MYAPP")
Snippy.cowboy_opts(prefix: "MYAPP", discovered_certs: disc)

Summary

Functions

Build options suitable for Bandit.start_link/1.

Build options suitable for Plug.Cowboy.https/3 / :cowboy.start_tls/3.

Run discovery against the env and return a %Snippy.Discovery{} handle.

Build the keyword list to assign to the :https key of a Phoenix endpoint config.

Build options suitable for Ranch's :ranch.start_listener/5.

Re-scan the env (and re-read all _FILE sources). Clears the materialization cache so subsequent helper calls re-decode.

Build an SNI fun (suitable for the :sni_fun :ssl option).

Build a keyword list of :ssl.listen/2 options.

Build options suitable for ThousandIsland.start_link/1.

Types

discovery()

@type discovery() :: %Snippy.Discovery{
  default_hostname: term(),
  errors: term(),
  groups: term(),
  id: term(),
  reload_interval_ms: term(),
  table: term()
}

Functions

bandit_opts(opts \\ [])

@spec bandit_opts(keyword()) :: keyword()

Build options suitable for Bandit.start_link/1.

cowboy_opts(opts \\ [])

@spec cowboy_opts(keyword()) :: keyword()

Build options suitable for Plug.Cowboy.https/3 / :cowboy.start_tls/3.

discover_certificates(opts \\ [])

@spec discover_certificates(keyword()) :: {:ok, discovery()} | no_return()

Run discovery against the env and return a %Snippy.Discovery{} handle.

Eagerly materializes every group that matches :prefix, so this is a good pre-warm step at boot. The returned handle's :groups field contains the successful groups (without their internal :ssl payloads — those live in the Store's ETS); :errors contains any per-group materialization failures as {prefix, key, reason} tuples.

Options: see the moduledoc.

phx_endpoint_config(opts \\ [])

@spec phx_endpoint_config(keyword()) :: keyword()

Build the keyword list to assign to the :https key of a Phoenix endpoint config.

Accepts both Phoenix transport opts (e.g. :port, :cipher_suite, :otp_app) and Snippy scoping opts (:only, :keys). Snippy's SSL options (:sni_fun, :certs_keys) are merged in last so they win on collision; everything else is passed through unchanged.

Discovery passthrough opts (:prefix, :case_sensitive, ...) are consumed for discovery and stripped from the result.

Adapter

Pass :adapter to control how SSL options are nested in the result:

  • :cowboy (default) — SSL options are merged flat, suitable for Phoenix.Endpoint.Cowboy2Adapter.
  • :bandit — SSL options are nested under thousand_island_options: [transport_options: [...]], as required by Bandit.PhoenixAdapter.

Examples

# Cowboy (default)
config :my_app, MyAppWeb.Endpoint,
  https:
    Snippy.phx_endpoint_config(
      prefix: "MYAPP",
      port: 4443,
      cipher_suite: :strong
    )

# Bandit
config :my_app, MyAppWeb.Endpoint,
  https:
    Snippy.phx_endpoint_config(
      prefix: "MYAPP",
      adapter: :bandit,
      port: 4443
    )

ranch_opts(opts \\ [])

@spec ranch_opts(keyword()) :: keyword()

Build options suitable for Ranch's :ranch.start_listener/5.

reload(disc)

@spec reload(discovery()) :: {:ok, discovery()} | {:error, term()}

Re-scan the env (and re-read all _FILE sources). Clears the materialization cache so subsequent helper calls re-decode.

Returns a refreshed %Snippy.Discovery{} for the same prefix(es) the handle was created with.

sni(opts \\ [])

@spec sni(keyword()) :: (binary() | charlist() -> keyword())

Build an SNI fun (suitable for the :sni_fun :ssl option).

ssl_opts(opts \\ [])

@spec ssl_opts(keyword()) :: keyword()

Build a keyword list of :ssl.listen/2 options.

thousand_island_opts(opts \\ [])

@spec thousand_island_opts(keyword()) :: keyword()

Build options suitable for ThousandIsland.start_link/1.