| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Snippy do | |
| 1 | @moduledoc """ | |
| 2 | Discover SSL certificates and keys from environment variables and produce | |
| 3 | configuration suitable for `:ssl`, Cowboy, Ranch, Bandit, Thousand Island, | |
| 4 | or Phoenix. | |
| 5 | ||
| 6 | ## How it works | |
| 7 | ||
| 8 | Snippy runs in three lazy phases backed by a single shared `GenServer`: | |
| 9 | ||
| 10 | 1. **Scan** (cheap). The Store walks the environment once and records | |
| 11 | every variable whose name ends in a recognized suffix (`_CRT`, | |
| 12 | `_KEY`, `_PWD`, ...). No PEM is decoded, no files are read. The scan | |
| 13 | is shared across every helper call. | |
| 14 | ||
| 15 | 2. **Filter by prefix** (per call). Each helper takes the broad scan | |
| 16 | results and peels off entries whose names start with the requested | |
| 17 | prefix. | |
| 18 | ||
| 19 | 3. **Materialize** (lazy, per group). Only when a helper actually asks | |
| 20 | about a `(prefix, key)` group does Snippy decode PEM, decrypt keys, | |
| 21 | validate the cert/key match, check expiry, and build the final | |
| 22 | `:ssl` payload. Successes *and* errors are memoized in ETS, so | |
| 23 | repeated lookups are constant-time and broken groups don't spam the | |
| 24 | log. | |
| 25 | ||
| 26 | This shape gives Snippy a small DoS surface: env vars that no helper ever | |
| 27 | asks about never get decoded, even if an attacker can set arbitrary | |
| 28 | environment variables. | |
| 29 | ||
| 30 | ## Helper option groups | |
| 31 | ||
| 32 | All helpers accept the same option categories: | |
| 33 | ||
| 34 | * **Required** | |
| 35 | - `:prefix` - string, atom, or list of either. | |
| 36 | ||
| 37 | * **Discovery passthrough** (forwarded to the shared scan) | |
| 38 | - `:case_sensitive` - default `true`. | |
| 39 | - `:env` - env map override (mainly for testing). | |
| 40 | - `:reload_interval_ms` - if set, the Store schedules background | |
| 41 | re-scans at this cadence. | |
| 42 | ||
| 43 | * **Per-lookup options** | |
| 44 | - `:default_hostname` - SNI fallback host. | |
| 45 | - `:expiry_grace_seconds` - tolerate certs that expired up to this | |
| 46 | many seconds ago (default 0). | |
| 47 | - `:public_ca_validation` - `:auto | :always | :never` (default | |
| 48 | `:auto`). | |
| 49 | - `:only` - list of hostname patterns; only matching groups are | |
| 50 | exposed. | |
| 51 | - `:keys` - list of group key strings (or atoms); only matching | |
| 52 | groups are exposed. | |
| 53 | ||
| 54 | * **Escape hatch** | |
| 55 | - `:discovered_certs` - a `%Snippy.Discovery{}` from a prior call to | |
| 56 | `discover_certificates/1`. When provided, the helper uses *that* | |
| 57 | discovery's groups directly and skips the shared Store entirely. | |
| 58 | Useful when you want to control exactly when (and against what | |
| 59 | env) materialization happens, e.g. pre-warming at boot. | |
| 60 | ||
| 61 | ## Quick example | |
| 62 | ||
| 63 | Snippy.cowboy_opts(prefix: "MYAPP") | |
| 64 | ||
| 65 | # equivalent to: | |
| 66 | {:ok, disc} = Snippy.discover_certificates(prefix: "MYAPP") | |
| 67 | Snippy.cowboy_opts(prefix: "MYAPP", discovered_certs: disc) | |
| 68 | """ | |
| 69 | ||
| 70 | alias Snippy.Discovery | |
| 71 | alias Snippy.Lookup | |
| 72 | alias Snippy.Store | |
| 73 | ||
| 74 | @type discovery :: %Discovery{} | |
| 75 | ||
| 76 | # ----------------------------------------------------- Discovery handles | |
| 77 | ||
| 78 | @doc """ | |
| 79 | Run discovery against the env and return a `%Snippy.Discovery{}` handle. | |
| 80 | ||
| 81 | Eagerly materializes every group that matches `:prefix`, so this is a | |
| 82 | good pre-warm step at boot. The returned handle's `:groups` field | |
| 83 | contains the successful groups (without their internal `:ssl` payloads — | |
| 84 | those live in the Store's ETS); `:errors` contains any per-group | |
| 85 | materialization failures as `{prefix, key, reason}` tuples. | |
| 86 | ||
| 87 | Options: see the moduledoc. | |
| 88 | """ | |
| 89 | @spec discover_certificates(keyword()) :: {:ok, discovery()} | no_return | |
| 90 | def discover_certificates(opts \\ []) do | |
| 91 | 48 | Snippy.OTPCheck.check!() |
| 92 | 48 | Store.discover(opts) |
| 93 | end | |
| 94 | ||
| 95 | @doc """ | |
| 96 | Re-scan the env (and re-read all `_FILE` sources). Clears the | |
| 97 | materialization cache so subsequent helper calls re-decode. | |
| 98 | ||
| 99 | Returns a refreshed `%Snippy.Discovery{}` for the same prefix(es) the | |
| 100 | handle was created with. | |
| 101 | """ | |
| 102 | @spec reload(discovery()) :: {:ok, discovery()} | {:error, term()} | |
| 103 | def reload(%Discovery{} = disc) do | |
| 104 | 2 | case Store.reload([]) do |
| 105 | :ok -> | |
| 106 | # Re-run discovery for the original handle's prefix scope. | |
| 107 | 1 | opts = |
| 108 | 1 | [default_hostname: disc.default_hostname, reload_interval_ms: disc.reload_interval_ms] |
| 109 | |> Keyword.put(:prefix, prefixes_from_handle(disc)) | |
| 110 | ||
| 111 | 1 | Store.discover(opts) |
| 112 | ||
| 113 | {:error, _} = err -> | |
| 114 | 1 | err |
| 115 | end | |
| 116 | end | |
| 117 | ||
| 118 | defp prefixes_from_handle(%Discovery{groups: groups}) do | |
| 119 | 1 | groups |> Enum.map(& &1.prefix) |> Enum.uniq() |
| 120 | end | |
| 121 | ||
| 122 | # ----------------------------------------------------------- Helpers --- | |
| 123 | ||
| 124 | @doc """ | |
| 125 | Build an SNI fun (suitable for the `:sni_fun` :ssl option). | |
| 126 | """ | |
| 127 | @spec sni(keyword()) :: (binary() | charlist() -> keyword()) | |
| 128 | def sni(opts \\ []) do | |
| 129 | 3 | groups = resolve_groups(opts) |
| 130 | 3 | Lookup.sni_fun(groups, lookup_opts(opts)) |
| 131 | end | |
| 132 | ||
| 133 | @doc """ | |
| 134 | Build a keyword list of `:ssl.listen/2` options. | |
| 135 | """ | |
| 136 | @spec ssl_opts(keyword()) :: keyword() | |
| 137 | def ssl_opts(opts \\ []) do | |
| 138 | 24 | groups = resolve_groups(opts) |
| 139 | 24 | Lookup.ssl_opts(groups, lookup_opts(opts)) |
| 140 | end | |
| 141 | ||
| 142 | @doc """ | |
| 143 | Build options suitable for `Plug.Cowboy.https/3` / `:cowboy.start_tls/3`. | |
| 144 | """ | |
| 145 | @spec cowboy_opts(keyword()) :: keyword() | |
| 146 | def cowboy_opts(opts \\ []) do | |
| 147 | 3 | ssl_opts(opts) |
| 148 | end | |
| 149 | ||
| 150 | @doc """ | |
| 151 | Build options suitable for Ranch's `:ranch.start_listener/5`. | |
| 152 | """ | |
| 153 | @spec ranch_opts(keyword()) :: keyword() | |
| 154 | def ranch_opts(opts \\ []) do | |
| 155 | [socket_opts: ssl_opts(opts)] | |
| 156 | end | |
| 157 | ||
| 158 | @doc """ | |
| 159 | Build options suitable for `Bandit.start_link/1`. | |
| 160 | """ | |
| 161 | @spec bandit_opts(keyword()) :: keyword() | |
| 162 | def bandit_opts(opts \\ []) do | |
| 163 | [thousand_island_options: thousand_island_opts(opts)] | |
| 164 | end | |
| 165 | ||
| 166 | @doc """ | |
| 167 | Build options suitable for `ThousandIsland.start_link/1`. | |
| 168 | """ | |
| 169 | @spec thousand_island_opts(keyword()) :: keyword() | |
| 170 | def thousand_island_opts(opts \\ []) do | |
| 171 | [transport_options: ssl_opts(opts)] | |
| 172 | end | |
| 173 | ||
| 174 | @doc """ | |
| 175 | Build the keyword list to assign to the `:https` key of a Phoenix | |
| 176 | endpoint config. | |
| 177 | ||
| 178 | Accepts both Phoenix transport opts (e.g. `:port`, `:cipher_suite`, | |
| 179 | `:otp_app`) and Snippy scoping opts (`:only`, `:keys`). Snippy's SSL | |
| 180 | options (`:sni_fun`, `:certs_keys`) are merged in last so they win on | |
| 181 | collision; everything else is passed through unchanged. | |
| 182 | ||
| 183 | Discovery passthrough opts (`:prefix`, `:case_sensitive`, ...) are | |
| 184 | consumed for discovery and stripped from the result. | |
| 185 | ||
| 186 | ## Adapter | |
| 187 | ||
| 188 | Pass `:adapter` to control how SSL options are nested in the result: | |
| 189 | ||
| 190 | * `:cowboy` (default) — SSL options are merged flat, suitable for | |
| 191 | `Phoenix.Endpoint.Cowboy2Adapter`. | |
| 192 | * `:bandit` — SSL options are nested under | |
| 193 | `thousand_island_options: [transport_options: [...]]`, as | |
| 194 | required by `Bandit.PhoenixAdapter`. | |
| 195 | ||
| 196 | ## Examples | |
| 197 | ||
| 198 | # Cowboy (default) | |
| 199 | config :my_app, MyAppWeb.Endpoint, | |
| 200 | https: | |
| 201 | Snippy.phx_endpoint_config( | |
| 202 | prefix: "MYAPP", | |
| 203 | port: 4443, | |
| 204 | cipher_suite: :strong | |
| 205 | ) | |
| 206 | ||
| 207 | # Bandit | |
| 208 | config :my_app, MyAppWeb.Endpoint, | |
| 209 | https: | |
| 210 | Snippy.phx_endpoint_config( | |
| 211 | prefix: "MYAPP", | |
| 212 | adapter: :bandit, | |
| 213 | port: 4443 | |
| 214 | ) | |
| 215 | """ | |
| 216 | @spec phx_endpoint_config(keyword()) :: keyword() | |
| 217 | def phx_endpoint_config(opts \\ []) do | |
| 218 | 8 | {snippy_opts, transport_opts} = split_snippy_opts(opts) |
| 219 | 8 | adapter = Keyword.get(snippy_opts, :adapter, :cowboy) |
| 220 | 8 | ssl = ssl_opts(snippy_opts) |
| 221 | ||
| 222 | 8 | case adapter do |
| 223 | :bandit -> | |
| 224 | 3 | existing_ti = Keyword.get(transport_opts, :thousand_island_options, []) |
| 225 | 3 | existing_to = Keyword.get(existing_ti, :transport_options, []) |
| 226 | 3 | merged_to = Keyword.merge(existing_to, ssl) |
| 227 | 3 | merged_ti = Keyword.put(existing_ti, :transport_options, merged_to) |
| 228 | 3 | transport_opts |> Keyword.put(:thousand_island_options, merged_ti) |
| 229 | ||
| 230 | _cowboy -> | |
| 231 | 5 | Keyword.merge(transport_opts, ssl) |
| 232 | end | |
| 233 | end | |
| 234 | ||
| 235 | # --------------------------------------------------------- Internals --- | |
| 236 | ||
| 237 | @snippy_opt_keys [ | |
| 238 | :adapter, | |
| 239 | :prefix, | |
| 240 | :case_sensitive, | |
| 241 | :env, | |
| 242 | :reload_interval_ms, | |
| 243 | :default_hostname, | |
| 244 | :expiry_grace_seconds, | |
| 245 | :public_ca_validation, | |
| 246 | :only, | |
| 247 | :keys, | |
| 248 | :discovered_certs | |
| 249 | ] | |
| 250 | ||
| 251 | defp split_snippy_opts(opts) do | |
| 252 | 8 | Keyword.split(opts, @snippy_opt_keys) |
| 253 | end | |
| 254 | ||
| 255 | defp resolve_groups(opts) do | |
| 256 | 27 | cond do |
| 257 | Keyword.has_key?(opts, :discovered_certs) -> | |
| 258 | 6 | %Discovery{groups: groups} = Keyword.fetch!(opts, :discovered_certs) |
| 259 | 6 | Lookup.hydrate_groups(groups) |
| 260 | ||
| 261 | 21 | Keyword.has_key?(opts, :env) -> |
| 262 | # Isolated discovery: bypass the shared Store entirely. Useful in | |
| 263 | # tests and any caller that wants to pin discovery to a specific | |
| 264 | # env without touching shared state. | |
| 265 | 18 | {:ok, %Discovery{groups: groups}} = Store.discover(opts) |
| 266 | 18 | Lookup.hydrate_groups(groups) |
| 267 | ||
| 268 | 3 | true -> |
| 269 | 3 | prefixes = Discovery.normalize_prefixes!(opts[:prefix]) |
| 270 | 3 | Store.lookup_groups(prefixes, scan_and_lookup_opts(opts)) |
| 271 | end | |
| 272 | end | |
| 273 | ||
| 274 | defp scan_and_lookup_opts(opts) do | |
| 275 | 3 | Keyword.take(opts, [ |
| 276 | :case_sensitive, | |
| 277 | :env, | |
| 278 | :reload_interval_ms, | |
| 279 | :expiry_grace_seconds, | |
| 280 | :public_ca_validation | |
| 281 | ]) | |
| 282 | end | |
| 283 | ||
| 284 | defp lookup_opts(opts) do | |
| 285 | 27 | Keyword.take(opts, [:only, :keys, :default_hostname]) |
| 286 | end | |
| 287 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Snippy.Application do | |
| 1 | @moduledoc """ | |
| 2 | OTP application callback for Snippy. | |
| 3 | ||
| 4 | Starts a `:one_for_all` supervisor with the ETS table owner and the | |
| 5 | discovery store as siblings. | |
| 6 | ||
| 7 | ## Restart intensity | |
| 8 | ||
| 9 | Web servers often run hot enough that a brief burst of transient errors | |
| 10 | (a flaky NFS mount, a momentary DNS hiccup during a reload, ...) can | |
| 11 | crash the store several times in quick succession. With OTP's stock | |
| 12 | supervisor budget that's enough to take Snippy down entirely. | |
| 13 | ||
| 14 | The default budget here is forgiving: it tolerates roughly a 20% failure | |
| 15 | rate on a server handling 50 requests per second over a 15-second | |
| 16 | window (about 150 failures). Tune via application config if you want | |
| 17 | tighter or looser bounds: | |
| 18 | ||
| 19 | # config/runtime.exs | |
| 20 | config :snippy, | |
| 21 | max_restarts: 150, | |
| 22 | max_seconds: 15 | |
| 23 | """ | |
| 24 | use Application | |
| 25 | ||
| 26 | @default_max_restarts 150 | |
| 27 | @default_max_seconds 15 | |
| 28 | ||
| 29 | @impl true | |
| 30 | def start(_type, _args) do | |
| 31 | 1 | Snippy.OTPCheck.check!() |
| 32 | ||
| 33 | 1 | children = [ |
| 34 | {Task.Supervisor, name: Snippy.TaskSupervisor}, | |
| 35 | Snippy.TableOwner, | |
| 36 | Snippy.Store | |
| 37 | ] | |
| 38 | ||
| 39 | 1 | opts = [ |
| 40 | strategy: :one_for_all, | |
| 41 | name: Snippy.Supervisor, | |
| 42 | max_restarts: Application.get_env(:snippy, :max_restarts, @default_max_restarts), | |
| 43 | max_seconds: Application.get_env(:snippy, :max_seconds, @default_max_seconds) | |
| 44 | ] | |
| 45 | ||
| 46 | 1 | Supervisor.start_link(children, opts) |
| 47 | end | |
| 48 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Snippy.Decoder do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | require Logger | |
| 4 | ||
| 5 | # ---------------------------------------------------------------- PEM I/O -- | |
| 6 | ||
| 7 | def decode_pem(pem_string) when is_binary(pem_string) do | |
| 8 | 336 | case :public_key.pem_decode(pem_string) do |
| 9 | 7 | [] -> {:error, :invalid_pem} |
| 10 | 329 | entries -> {:ok, entries} |
| 11 | end | |
| 12 | end | |
| 13 | ||
| 14 | def decode_pem_file(path) when is_binary(path) do | |
| 15 | 2 | case File.read(path) do |
| 16 | 1 | {:ok, contents} -> decode_pem(contents) |
| 17 | 1 | {:error, reason} -> {:error, {:file_read, reason, path}} |
| 18 | end | |
| 19 | end | |
| 20 | ||
| 21 | # ----------------------------------------------------------- Certificates -- | |
| 22 | ||
| 23 | def decode_certs(pem_string) when is_binary(pem_string) do | |
| 24 | 136 | with {:ok, entries} <- decode_pem(pem_string) do |
| 25 | 130 | ders = for {:Certificate, der, :not_encrypted} <- entries, do: der |
| 26 | ||
| 27 | 130 | case ders do |
| 28 | 1 | [] -> {:error, :no_certificates_found} |
| 29 | 129 | list -> {:ok, list} |
| 30 | end | |
| 31 | end | |
| 32 | end | |
| 33 | ||
| 34 | def decode_certs_file(path) when is_binary(path) do | |
| 35 | 7 | with {:ok, contents} <- read_file(path) do |
| 36 | 6 | decode_certs(contents) |
| 37 | end | |
| 38 | end | |
| 39 | ||
| 40 | # ------------------------------------------------------------ Private keys -- | |
| 41 | # | |
| 42 | # We accept whatever PEM the user supplies (traditional RSAPrivateKey / | |
| 43 | # ECPrivateKey or PKCS#8 PrivateKeyInfo, encrypted or not). The decoded | |
| 44 | # record from :public_key.pem_entry_decode/1 is wrapped with the original | |
| 45 | # PEM tag and the (decrypted) DER bytes so :ssl can be handed exactly the | |
| 46 | # form it expects. | |
| 47 | ||
| 48 | def decode_key(pem_string, password \\ nil) when is_binary(pem_string) do | |
| 49 | 114 | with {:ok, entries} <- decode_pem(pem_string) do |
| 50 | 114 | case find_key_entry(entries) do |
| 51 | 1 | nil -> {:error, :no_key_found} |
| 52 | 99 | {_type, _der, :not_encrypted} = entry -> decode_unencrypted_key(entry) |
| 53 | 14 | {_type, _der, _cipher} = entry -> decode_encrypted_key(entry, password) |
| 54 | end | |
| 55 | end | |
| 56 | end | |
| 57 | ||
| 58 | def decode_key_file(path, password \\ nil) when is_binary(path) do | |
| 59 | 9 | with {:ok, contents} <- read_file(path) do |
| 60 | 9 | decode_key(contents, password) |
| 61 | end | |
| 62 | end | |
| 63 | ||
| 64 | @doc false | |
| 65 | def find_key_entry(entries) do | |
| 66 | 120 | Enum.find(entries, fn |
| 67 | 2 | {:RSAPrivateKey, _, _} -> true |
| 68 | 5 | {:ECPrivateKey, _, _} -> true |
| 69 | 1 | {:DSAPrivateKey, _, _} -> true |
| 70 | 109 | {:PrivateKeyInfo, _, _} -> true |
| 71 | 1 | {:EncryptedPrivateKeyInfo, _, _} -> true |
| 72 | 3 | _ -> false |
| 73 | end) | |
| 74 | end | |
| 75 | ||
| 76 | 99 | defp decode_unencrypted_key({_type, _der, :not_encrypted} = entry) do |
| 77 | 99 | record = :public_key.pem_entry_decode(entry) |
| 78 | {:ok, build_key(entry, record)} | |
| 79 | rescue | |
| 80 | 1 | _ -> {:error, :invalid_key} |
| 81 | end | |
| 82 | ||
| 83 | 2 | defp decode_encrypted_key(_entry, nil), do: {:error, :encrypted_key_no_password} |
| 84 | ||
| 85 | defp decode_encrypted_key(entry, password) when is_binary(password) do | |
| 86 | 12 | trimmed = String.trim_trailing(password) |
| 87 | ||
| 88 | 12 | case try_decode(entry, String.to_charlist(trimmed)) do |
| 89 | {:ok, _} = ok -> | |
| 90 | 8 | ok |
| 91 | ||
| 92 | {:error, _} when trimmed != password -> | |
| 93 | 2 | case try_decode(entry, String.to_charlist(password)) do |
| 94 | 1 | {:ok, _} = ok -> ok |
| 95 | 1 | {:error, _} -> {:error, :bad_password} |
| 96 | end | |
| 97 | ||
| 98 | 2 | {:error, _} -> |
| 99 | {:error, :bad_password} | |
| 100 | end | |
| 101 | end | |
| 102 | ||
| 103 | 14 | defp try_decode(entry, charlist_password) do |
| 104 | 14 | record = :public_key.pem_entry_decode(entry, charlist_password) |
| 105 | {:ok, build_key(entry, record)} | |
| 106 | rescue | |
| 107 | 5 | _ -> {:error, :decrypt_failed} |
| 108 | end | |
| 109 | ||
| 110 | # The map we hand to the rest of Snippy. We always re-encode the record | |
| 111 | # so `:asn1_type` and `:der` are consistent: `pem_entry_decode/1` for | |
| 112 | # PKCS#8 unwraps the inner key (e.g. returns an `:RSAPrivateKey` | |
| 113 | # record), but the original PEM `:der` would still be PKCS#8 — passing | |
| 114 | # `{:RSAPrivateKey, <pkcs8-der>}` to `:ssl` then blows up at handshake | |
| 115 | # time. Re-encoding from the record gives the matching DER. | |
| 116 | defp build_key({pem_type, _der, :not_encrypted}, record) do | |
| 117 | 98 | asn1_type = record_tag(record) |
| 118 | ||
| 119 | 98 | %{ |
| 120 | pem_type: pem_type, | |
| 121 | record: record, | |
| 122 | asn1_type: asn1_type, | |
| 123 | der: :public_key.der_encode(asn1_type, record) | |
| 124 | } | |
| 125 | end | |
| 126 | ||
| 127 | defp build_key({pem_type, _enc_der, _cipher}, record) do | |
| 128 | 9 | asn1_type = record_tag(record) |
| 129 | ||
| 130 | 9 | %{ |
| 131 | pem_type: pem_type, | |
| 132 | record: record, | |
| 133 | asn1_type: asn1_type, | |
| 134 | der: :public_key.der_encode(asn1_type, record) | |
| 135 | } | |
| 136 | end | |
| 137 | ||
| 138 | defp record_tag(record) when is_tuple(record) and tuple_size(record) > 0, | |
| 139 | 107 | do: :erlang.element(1, record) |
| 140 | ||
| 141 | # ------------------------------------------------- Key type classification -- | |
| 142 | ||
| 143 | 200 | def key_type(%{record: record}), do: record_to_type(record) |
| 144 | 2 | def key_type(_), do: :other |
| 145 | ||
| 146 | @doc false | |
| 147 | 184 | def record_to_type({:RSAPrivateKey, _, _, _, _, _, _, _, _, _, _}), do: :rsa |
| 148 | 1 | def record_to_type({:ECPrivateKey, _, _, _, _}), do: :ecdsa |
| 149 | 7 | def record_to_type({:ECPrivateKey, _, _, _, _, _}), do: :ecdsa |
| 150 | 1 | def record_to_type({:DSAPrivateKey, _, _, _, _, _}), do: :dsa |
| 151 | ||
| 152 | def record_to_type({:PrivateKeyInfo, _v, alg, _key, _attrs}) do | |
| 153 | 6 | case alg_oid(alg) do |
| 154 | 1 | {1, 2, 840, 113_549, 1, 1, 1} -> :rsa |
| 155 | 1 | {1, 2, 840, 10_045, 2, 1} -> :ecdsa |
| 156 | 2 | {1, 3, 101, 112} -> :eddsa |
| 157 | 1 | {1, 3, 101, 113} -> :eddsa |
| 158 | 1 | _ -> :other |
| 159 | end | |
| 160 | end | |
| 161 | ||
| 162 | 4 | def record_to_type(_), do: :other |
| 163 | ||
| 164 | @doc false | |
| 165 | 10 | def alg_oid({:PrivateKeyInfo_privateKeyAlgorithm, oid, _}), do: oid |
| 166 | 4 | def alg_oid({:AlgorithmIdentifier, oid, _}), do: oid |
| 167 | 1 | def alg_oid({:PublicKeyAlgorithm, oid, _}), do: oid |
| 168 | 1 | def alg_oid({:SignatureAlgorithm, oid, _}), do: oid |
| 169 | ||
| 170 | @doc false | |
| 171 | 1 | def alg_params({:PrivateKeyInfo_privateKeyAlgorithm, _, p}), do: p |
| 172 | 3 | def alg_params({:AlgorithmIdentifier, _, p}), do: p |
| 173 | 3 | def alg_params({:PublicKeyAlgorithm, _, p}), do: p |
| 174 | 1 | def alg_params({:SignatureAlgorithm, _, p}), do: p |
| 175 | ||
| 176 | # --------------------------------------------------- Cert OTP destructuring -- | |
| 177 | ||
| 178 | def decode_otp_cert(der) when is_binary(der) do | |
| 179 | 381 | :public_key.pkix_decode_cert(der, :otp) |
| 180 | end | |
| 181 | ||
| 182 | 380 | defp tbs_of({:OTPCertificate, tbs, _sig_alg, _sig}), do: tbs |
| 183 | ||
| 184 | defp validity_of( | |
| 185 | {:OTPTBSCertificate, _v, _serial, _sig, _issuer, validity, _subj, _spki, _iuid, _suid, | |
| 186 | _exts} | |
| 187 | ), | |
| 188 | 182 | do: validity |
| 189 | ||
| 190 | defp subject_of( | |
| 191 | {:OTPTBSCertificate, _v, _serial, _sig, _issuer, _val, subject, _spki, _iuid, _suid, | |
| 192 | _exts} | |
| 193 | ), | |
| 194 | 104 | do: subject |
| 195 | ||
| 196 | defp spki_of( | |
| 197 | {:OTPTBSCertificate, _v, _serial, _sig, _issuer, _val, _subj, spki, _iuid, _suid, _exts} | |
| 198 | ), | |
| 199 | 94 | do: spki |
| 200 | ||
| 201 | defp extensions_of( | |
| 202 | {:OTPTBSCertificate, _v, _serial, _sig, _issuer, _val, _subj, _spki, _iuid, _suid, exts} | |
| 203 | ), | |
| 204 | 104 | do: exts |
| 205 | ||
| 206 | # ---------------------------------------------------------- Cert validity -- | |
| 207 | ||
| 208 | def cert_validity(der) when is_binary(der) do | |
| 209 | 182 | {:Validity, not_before, not_after} = |
| 210 | der |> decode_otp_cert() |> tbs_of() |> validity_of() | |
| 211 | ||
| 212 | {parse_time(not_before), parse_time(not_after)} | |
| 213 | end | |
| 214 | ||
| 215 | def cert_valid_now?(der) when is_binary(der) do | |
| 216 | 2 | {nb, na} = cert_validity(der) |
| 217 | 2 | now = DateTime.utc_now() |
| 218 | 2 | DateTime.compare(now, nb) in [:gt, :eq] and DateTime.compare(now, na) in [:lt, :eq] |
| 219 | end | |
| 220 | ||
| 221 | @doc false | |
| 222 | def parse_time({:utcTime, charlist}) do | |
| 223 | 366 | <<yy::binary-2, mm::binary-2, dd::binary-2, hh::binary-2, mi::binary-2, ss::binary-2, "Z">> = |
| 224 | List.to_string(charlist) | |
| 225 | ||
| 226 | 366 | yyyy = |
| 227 | case String.to_integer(yy) do | |
| 228 | 1 | y when y >= 50 -> 1900 + y |
| 229 | 365 | y -> 2000 + y |
| 230 | end | |
| 231 | ||
| 232 | 366 | build_dt(yyyy, mm, dd, hh, mi, ss) |
| 233 | end | |
| 234 | ||
| 235 | def parse_time({:generalTime, charlist}) do | |
| 236 | 1 | <<yyyy::binary-4, mm::binary-2, dd::binary-2, hh::binary-2, mi::binary-2, ss::binary-2, "Z">> = |
| 237 | List.to_string(charlist) | |
| 238 | ||
| 239 | 1 | build_dt(String.to_integer(yyyy), mm, dd, hh, mi, ss) |
| 240 | end | |
| 241 | ||
| 242 | defp build_dt(yyyy, mm, dd, hh, mi, ss) do | |
| 243 | 367 | {:ok, naive} = |
| 244 | NaiveDateTime.new( | |
| 245 | yyyy, | |
| 246 | String.to_integer(mm), | |
| 247 | String.to_integer(dd), | |
| 248 | String.to_integer(hh), | |
| 249 | String.to_integer(mi), | |
| 250 | String.to_integer(ss) | |
| 251 | ) | |
| 252 | ||
| 253 | 367 | DateTime.from_naive!(naive, "Etc/UTC") |
| 254 | end | |
| 255 | ||
| 256 | # ------------------------------------------------- Hostname (CN + SAN) --- | |
| 257 | ||
| 258 | def cert_hostnames(der) when is_binary(der) do | |
| 259 | 104 | tbs = der |> decode_otp_cert() |> tbs_of() |
| 260 | 104 | cn = subject_cn(subject_of(tbs)) |
| 261 | 104 | sans = san_dns_names(extensions_of(tbs)) |
| 262 | ||
| 263 | sans | |
| 264 | |> Kernel.++(List.wrap(cn)) | |
| 265 | 207 | |> Enum.map(&to_string/1) |
| 266 | 104 | |> Enum.uniq() |
| 267 | end | |
| 268 | ||
| 269 | @doc false | |
| 270 | def subject_cn({:rdnSequence, rdn_lists}) do | |
| 271 | 106 | Enum.find_value(rdn_lists, fn attrs -> |
| 272 | 112 | Enum.find_value(attrs, fn |
| 273 | 106 | {:AttributeTypeAndValue, {2, 5, 4, 3}, value} -> string_value(value) |
| 274 | 6 | _ -> nil |
| 275 | end) | |
| 276 | end) | |
| 277 | end | |
| 278 | ||
| 279 | 2 | def subject_cn(_), do: nil |
| 280 | ||
| 281 | @doc false | |
| 282 | 107 | def string_value({:utf8String, v}), do: v |
| 283 | 1 | def string_value({:printableString, v}), do: List.to_string(v) |
| 284 | 1 | def string_value({:ia5String, v}), do: List.to_string(v) |
| 285 | 1 | def string_value(v) when is_list(v), do: List.to_string(v) |
| 286 | 1 | def string_value(v) when is_binary(v), do: v |
| 287 | 2 | def string_value(_), do: nil |
| 288 | ||
| 289 | @doc false | |
| 290 | 1 | def san_dns_names(:asn1_NOVALUE), do: [] |
| 291 | 1 | def san_dns_names(nil), do: [] |
| 292 | ||
| 293 | def san_dns_names(extensions) when is_list(extensions) do | |
| 294 | 106 | Enum.find_value(extensions, [], fn |
| 295 | {:Extension, {2, 5, 29, 17}, _critical, names} when is_list(names) -> | |
| 296 | 104 | for name <- names, dns = dns_name(name), dns != nil, do: dns |
| 297 | ||
| 298 | 3 | _ -> |
| 299 | nil | |
| 300 | end) | |
| 301 | end | |
| 302 | ||
| 303 | @doc false | |
| 304 | 104 | def dns_name({:dNSName, n}) when is_list(n), do: List.to_string(n) |
| 305 | 2 | def dns_name({:dNSName, n}) when is_binary(n), do: n |
| 306 | 4 | def dns_name(_), do: nil |
| 307 | ||
| 308 | # --------------------------------------------------------- Fingerprints -- | |
| 309 | ||
| 310 | def spki_fingerprint(cert_der) when is_binary(cert_der) do | |
| 311 | # Decode with :plain to get an asn1-encodable SubjectPublicKeyInfo record; | |
| 312 | # the :otp form is post-processed and not directly re-encodable. | |
| 313 | 90 | {:Certificate, tbs, _sig_alg, _sig} = :public_key.pkix_decode_cert(cert_der, :plain) |
| 314 | 90 | spki = elem_plain_spki(tbs) |
| 315 | 90 | der = :public_key.der_encode(:SubjectPublicKeyInfo, spki) |
| 316 | 90 | :crypto.hash(:sha256, der) |
| 317 | end | |
| 318 | ||
| 319 | defp elem_plain_spki( | |
| 320 | {:TBSCertificate, _v, _serial, _sig, _issuer, _val, _subj, spki, _iuid, _suid, _exts} | |
| 321 | ), | |
| 322 | 90 | do: spki |
| 323 | ||
| 324 | 89 | def key_fingerprint(%{der: der}), do: :crypto.hash(:sha256, der) |
| 325 | ||
| 326 | def fingerprint_hex(hash) when is_binary(hash) do | |
| 327 | hash | |
| 328 | |> Base.encode16(case: :lower) | |
| 329 | |> String.graphemes() | |
| 330 | |> Enum.chunk_every(2) | |
| 331 | 2 | |> Enum.map_join(":", &Enum.join/1) |
| 332 | end | |
| 333 | ||
| 334 | # --------------------------------------------------- Cert/key match check -- | |
| 335 | # | |
| 336 | # Sign a known plaintext with the private key and verify with the SPKI from | |
| 337 | # the cert. Works for any private key shape (PKCS#1, PKCS#8, EC, EdDSA). | |
| 338 | ||
| 339 | @match_message <<"snippy match probe", 0::32>> | |
| 340 | ||
| 341 | 95 | def cert_key_match?(cert_der, %{} = key) do |
| 342 | 95 | cert_pub = cert_public_key(cert_der) |
| 343 | 94 | {digest, signing_record} = digest_and_signer(key) |
| 344 | 94 | sig = :public_key.sign(@match_message, digest, signing_record) |
| 345 | 94 | :public_key.verify(@match_message, digest, sig, cert_pub) |
| 346 | rescue | |
| 347 | 1 | _ -> false |
| 348 | end | |
| 349 | ||
| 350 | @doc false | |
| 351 | def digest_and_signer(%{record: record} = key) do | |
| 352 | 96 | case key_type(key) do |
| 353 | 1 | :eddsa -> {:none, record} |
| 354 | 95 | _ -> {:sha256, signer_record(record)} |
| 355 | end | |
| 356 | end | |
| 357 | ||
| 358 | # OTP's :public_key.sign/3 does not always accept a PKCS#8 PrivateKeyInfo | |
| 359 | # for RSA/EC; convert to traditional form when needed. | |
| 360 | @doc false | |
| 361 | def signer_record({:PrivateKeyInfo, _v, alg, octets, _attrs} = original) do | |
| 362 | 3 | case alg_oid(alg) do |
| 363 | 1 | {1, 2, 840, 113_549, 1, 1, 1} -> :public_key.der_decode(:RSAPrivateKey, octets) |
| 364 | 1 | {1, 2, 840, 10_045, 2, 1} -> :public_key.der_decode(:ECPrivateKey, octets) |
| 365 | 1 | _ -> original |
| 366 | end | |
| 367 | end | |
| 368 | ||
| 369 | 97 | def signer_record(other), do: other |
| 370 | ||
| 371 | # Reconstruct a public-key value :public_key.verify/4 will accept. | |
| 372 | defp cert_public_key(cert_der) do | |
| 373 | 95 | spki = cert_der |> decode_otp_cert() |> tbs_of() |> spki_of() |
| 374 | 94 | spki_to_public(spki) |
| 375 | end | |
| 376 | ||
| 377 | @doc false | |
| 378 | 93 | def spki_to_public({:OTPSubjectPublicKeyInfo, _alg, {:RSAPublicKey, _, _} = rsa}), do: rsa |
| 379 | ||
| 380 | 3 | def spki_to_public({:OTPSubjectPublicKeyInfo, alg, {:ECPoint, _} = point}) do |
| 381 | {point, alg_params(alg)} | |
| 382 | end | |
| 383 | ||
| 384 | 1 | def spki_to_public({:OTPSubjectPublicKeyInfo, alg, point}) when is_binary(point) do |
| 385 | {{:ECPoint, point}, alg_params(alg)} | |
| 386 | end | |
| 387 | ||
| 388 | def spki_to_public({:OTPSubjectPublicKeyInfo, alg, public}) do | |
| 389 | 3 | case alg_oid(alg) do |
| 390 | 1 | {1, 3, 101, 112} -> {:ed_pub, :ed25519, public} |
| 391 | 1 | {1, 3, 101, 113} -> {:ed_pub, :ed448, public} |
| 392 | 1 | _ -> public |
| 393 | end | |
| 394 | end | |
| 395 | ||
| 396 | # ------------------------------------------------------- Chain validation -- | |
| 397 | ||
| 398 | @doc """ | |
| 399 | Validate `leaf_der` against a list of CA DERs. | |
| 400 | ||
| 401 | We pick the last CA in the list as the trust anchor (root). Any | |
| 402 | intermediates appear before it in `ca_ders`, ordered from leaf side toward | |
| 403 | root. | |
| 404 | """ | |
| 405 | 1 | def validate_chain(_leaf_der, []), do: {:error, :no_ca} |
| 406 | ||
| 407 | 11 | def validate_chain(leaf_der, ca_ders) when is_list(ca_ders) do |
| 408 | 11 | {trust_anchor_der, intermediates} = List.pop_at(ca_ders, -1) |
| 409 | 11 | chain = intermediates ++ [leaf_der] |
| 410 | ||
| 411 | 11 | case :public_key.pkix_path_validation(trust_anchor_der, chain, []) do |
| 412 | 8 | {:ok, _} -> :ok |
| 413 | 2 | {:error, reason} -> {:error, reason} |
| 414 | end | |
| 415 | rescue | |
| 416 | 1 | e -> {:error, {:validation_exception, Exception.message(e)}} |
| 417 | end | |
| 418 | ||
| 419 | def validate_against_castore(leaf_der, intermediates \\ []) do | |
| 420 | 83 | if Code.ensure_loaded?(CAStore) and function_exported?(CAStore, :file_path, 0) do |
| 421 | 83 | validate_with_castore(leaf_der, intermediates) |
| 422 | else | |
| 423 | {:error, :castore_not_available} | |
| 424 | end | |
| 425 | end | |
| 426 | ||
| 427 | defp validate_with_castore(leaf_der, intermediates) do | |
| 428 | 83 | with {:ok, pem} <- File.read(CAStore.file_path()), |
| 429 | 82 | {:ok, ca_entries} <- decode_pem(pem) do |
| 430 | 82 | ca_ders = for {:Certificate, der, :not_encrypted} <- ca_entries, do: der |
| 431 | 82 | try_each_root(leaf_der, intermediates, ca_ders) |
| 432 | else | |
| 433 | 1 | {:error, reason} -> {:error, {:castore, reason}} |
| 434 | end | |
| 435 | end | |
| 436 | ||
| 437 | @doc false | |
| 438 | def try_each_root(leaf_der, intermediates, ca_ders) do | |
| 439 | 85 | chain = intermediates ++ [leaf_der] |
| 440 | ||
| 441 | 85 | Enum.reduce_while(ca_ders, {:error, :no_match}, fn root_der, _acc -> |
| 442 | 11894 | try do |
| 443 | 11894 | case :public_key.pkix_path_validation(root_der, chain, []) do |
| 444 | 1 | {:ok, _} -> {:halt, :ok} |
| 445 | 11602 | {:error, _} -> {:cont, {:error, :no_match}} |
| 446 | end | |
| 447 | rescue | |
| 448 | 291 | _ -> {:cont, {:error, :no_match}} |
| 449 | end | |
| 450 | end) | |
| 451 | end | |
| 452 | ||
| 453 | # ------------------------------------------------------------- Helpers --- | |
| 454 | ||
| 455 | defp read_file(path) do | |
| 456 | 16 | case File.read(path) do |
| 457 | 15 | {:ok, contents} -> {:ok, contents} |
| 458 | 1 | {:error, reason} -> {:error, {:file_read, reason, path}} |
| 459 | end | |
| 460 | end | |
| 461 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Snippy.Discovery do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | require Logger | |
| 4 | alias Snippy.Decoder | |
| 5 | ||
| 6 | defmodule Group do | |
| 7 | @moduledoc false | |
| 8 | defstruct [ | |
| 9 | :prefix, | |
| 10 | :key, | |
| 11 | :hostnames, | |
| 12 | :has_password?, | |
| 13 | :has_ca_chain?, | |
| 14 | :cert_source, | |
| 15 | :key_source, | |
| 16 | :spki_fingerprint, | |
| 17 | :key_fingerprint, | |
| 18 | :key_type, | |
| 19 | :not_before, | |
| 20 | :not_after, | |
| 21 | :chain_validation, | |
| 22 | :chain_validation_reason, | |
| 23 | # Internal: the :ssl `:certs_keys` map for this group. Populated when | |
| 24 | # the group is materialized; nil on stripped public-handle groups | |
| 25 | # returned from `Snippy.discover_certificates/1`. | |
| 26 | :ssl_payload | |
| 27 | ] | |
| 28 | end | |
| 29 | ||
| 30 | defstruct id: nil, | |
| 31 | table: :snippy_certs, | |
| 32 | default_hostname: nil, | |
| 33 | reload_interval_ms: nil, | |
| 34 | groups: [], | |
| 35 | errors: [] | |
| 36 | ||
| 37 | # Suffix table: maps suffix -> {slot, kind} | |
| 38 | # slot: :cert | :key | :password | :ca | |
| 39 | # kind: :inline | :file | |
| 40 | ||
| 41 | @suffixes [ | |
| 42 | {"_CRT", :cert, :inline}, | |
| 43 | {"_CRT_FILE", :cert, :file}, | |
| 44 | {"_CERT", :cert, :inline}, | |
| 45 | {"_CERT_FILE", :cert, :file}, | |
| 46 | {"_KEY", :key, :inline}, | |
| 47 | {"_KEY_FILE", :key, :file}, | |
| 48 | {"_PWD", :password, :inline}, | |
| 49 | {"_PWD_FILE", :password, :file}, | |
| 50 | {"_PASS", :password, :inline}, | |
| 51 | {"_PASS_FILE", :password, :file}, | |
| 52 | {"_PASSWD", :password, :inline}, | |
| 53 | {"_PASSWD_FILE", :password, :file}, | |
| 54 | {"_PASSWORD", :password, :inline}, | |
| 55 | {"_PASSWORD_FILE", :password, :file}, | |
| 56 | {"_CACRT", :ca, :inline}, | |
| 57 | {"_CACRT_FILE", :ca, :file}, | |
| 58 | {"_CACERT", :ca, :inline}, | |
| 59 | {"_CACERT_FILE", :ca, :file} | |
| 60 | ] | |
| 61 | ||
| 62 | # Sorted longest-first so longer suffixes win in greedy matching | |
| 63 | @suffixes_sorted Enum.sort_by(@suffixes, fn {s, _, _} -> -byte_size(s) end) | |
| 64 | ||
| 65 | 2653 | def suffixes, do: @suffixes_sorted |
| 66 | ||
| 67 | # ----------------------------------------------------------- Phase 1: scan | |
| 68 | ||
| 69 | @doc """ | |
| 70 | Cheap, broad env scan. | |
| 71 | ||
| 72 | Walks the environment and emits one entry per env var whose name ends in | |
| 73 | one of our recognized suffixes. **Does not** decode PEM, read cert/key | |
| 74 | files, or validate anything. The full var name is preserved so later | |
| 75 | phases can split off whatever prefix the caller is interested in. | |
| 76 | ||
| 77 | Options: | |
| 78 | * `:env` - env map override (default: `System.get_env/0`) | |
| 79 | * `:case_sensitive` - default `true`. When `false`, suffix matching is | |
| 80 | case-insensitive (we always upcase the recognition surface). | |
| 81 | ||
| 82 | Returns a list of maps: | |
| 83 | ||
| 84 | %{var: "MYAPP_API_CRT_FILE", suffix: "_CRT_FILE", slot: :cert, | |
| 85 | kind: :file, val: "/run/secrets/api.crt.pem"} | |
| 86 | """ | |
| 87 | def scan_all(opts \\ []) do | |
| 88 | 449 | env = opts[:env] || System.get_env() |
| 89 | 449 | case_sensitive = Keyword.get(opts, :case_sensitive, true) |
| 90 | ||
| 91 | 449 | Enum.flat_map(env, fn {var, val} -> |
| 92 | 5208 | candidate = if case_sensitive, do: var, else: String.upcase(var) |
| 93 | ||
| 94 | 5208 | case match_suffix_only(candidate) do |
| 95 | 2436 | {:ok, suffix, slot, kind} -> |
| 96 | [%{var: var, suffix: suffix, slot: slot, kind: kind, val: val}] | |
| 97 | ||
| 98 | 2772 | :no_match -> |
| 99 | [] | |
| 100 | end | |
| 101 | end) | |
| 102 | end | |
| 103 | ||
| 104 | defp match_suffix_only(var) do | |
| 105 | 5208 | Enum.find_value(@suffixes_sorted, :no_match, fn {suffix, slot, kind} -> |
| 106 | 74226 | if String.ends_with?(var, suffix) and byte_size(var) > byte_size(suffix) do |
| 107 | 2436 | {:ok, suffix, slot, kind} |
| 108 | end | |
| 109 | end) | |
| 110 | end | |
| 111 | ||
| 112 | # ------------------------------------- Phase 1.5: filter scan by prefix(es) | |
| 113 | ||
| 114 | @doc """ | |
| 115 | Given the output of `scan_all/1` and a list of normalized (uppercased) | |
| 116 | prefixes, return entries whose var name starts with `<prefix>_` and whose | |
| 117 | remainder (after stripping the prefix and the trailing suffix) is non-empty. | |
| 118 | ||
| 119 | Each output entry adds `:prefix` and `:key` (both uppercase) to the input | |
| 120 | shape. | |
| 121 | """ | |
| 122 | def filter_by_prefixes(entries, prefixes, case_sensitive \\ true) do | |
| 123 | 241 | Enum.flat_map(entries, fn entry -> |
| 124 | 1166 | var_search = if case_sensitive, do: entry.var, else: String.upcase(entry.var) |
| 125 | ||
| 126 | Enum.find_value(prefixes, [], fn prefix -> | |
| 127 | 1166 | peel_prefix(entry, var_search, prefix) |
| 128 | end) | |
| 129 | 1166 | |> List.wrap() |
| 130 | end) | |
| 131 | end | |
| 132 | ||
| 133 | defp peel_prefix(entry, var_search, prefix) do | |
| 134 | 1166 | suffix = entry.suffix |
| 135 | ||
| 136 | 1166 | cond do |
| 137 | prefix == "" -> | |
| 138 | 629 | peel_no_prefix(entry, var_search, suffix) |
| 139 | ||
| 140 | 310 | String.starts_with?(var_search, prefix <> "_") and |
| 141 | 537 | String.ends_with?(var_search, suffix) -> |
| 142 | 227 | peel_with_prefix(entry, var_search, prefix, suffix) |
| 143 | ||
| 144 | 310 | true -> |
| 145 | nil | |
| 146 | end | |
| 147 | end | |
| 148 | ||
| 149 | defp peel_no_prefix(entry, var_search, suffix) do | |
| 150 | 629 | body_len = byte_size(var_search) - byte_size(suffix) |
| 151 | ||
| 152 | 629 | with true <- body_len > 0, |
| 153 | 629 | key = binary_part(var_search, 0, body_len) |> String.trim_leading("_"), |
| 154 | 629 | true <- key != "" do |
| 155 | 629 | Map.merge(entry, %{prefix: "", key: key}) |
| 156 | else | |
| 157 | _ -> nil | |
| 158 | end | |
| 159 | end | |
| 160 | ||
| 161 | defp peel_with_prefix(entry, var_search, prefix, suffix) do | |
| 162 | 227 | body_start = byte_size(prefix) + 1 |
| 163 | 227 | body_len = byte_size(var_search) - body_start - byte_size(suffix) |
| 164 | ||
| 165 | 227 | with true <- body_len > 0, |
| 166 | 227 | key = binary_part(var_search, body_start, body_len), |
| 167 | 227 | true <- key != "" do |
| 168 | 227 | Map.merge(entry, %{prefix: prefix, key: key}) |
| 169 | else | |
| 170 | _ -> nil | |
| 171 | end | |
| 172 | end | |
| 173 | ||
| 174 | # --------------------------------------------------------- Phase 2: groups | |
| 175 | ||
| 176 | @doc """ | |
| 177 | Given a list of prefix-tagged entries (from `filter_by_prefixes/3`), | |
| 178 | group them by `{prefix, key}` and return a list of raw group maps suitable | |
| 179 | for `materialize_group/2`. | |
| 180 | """ | |
| 181 | def group_entries(entries) do | |
| 182 | entries | |
| 183 | 227 | |> Enum.group_by(fn e -> {e.prefix, e.key} end) |
| 184 | 91 | |> Enum.map(fn {{prefix, key}, group_entries} -> |
| 185 | 105 | build_raw_group(prefix, key, group_entries) |
| 186 | end) | |
| 187 | end | |
| 188 | ||
| 189 | defp build_raw_group(prefix, key, entries) do | |
| 190 | 105 | password = extract_password!(entries, prefix, key) |
| 191 | 104 | cert = Enum.find(entries, &(&1.slot == :cert)) |
| 192 | 104 | key_var = Enum.find(entries, &(&1.slot == :key)) |
| 193 | 104 | ca = Enum.find(entries, &(&1.slot == :ca)) |
| 194 | ||
| 195 | 104 | %{ |
| 196 | prefix: prefix, | |
| 197 | key: key, | |
| 198 | cert: cert, | |
| 199 | key_var: key_var, | |
| 200 | password: password, | |
| 201 | ca: ca | |
| 202 | } | |
| 203 | end | |
| 204 | ||
| 205 | defp extract_password!(entries, prefix, key) do | |
| 206 | 105 | pw_entries = Enum.filter(entries, &(&1.slot == :password)) |
| 207 | ||
| 208 | 105 | case pw_entries do |
| 209 | 97 | [] -> |
| 210 | nil | |
| 211 | ||
| 212 | [single] -> | |
| 213 | 7 | single |
| 214 | ||
| 215 | multiple -> | |
| 216 | 1 | names = Enum.map(multiple, & &1.var) |
| 217 | ||
| 218 | 1 | raise ArgumentError, |
| 219 | 1 | "Snippy: multiple password variables for prefix=#{inspect(prefix)} key=#{key}: #{Enum.join(names, ", ")}" |
| 220 | end | |
| 221 | end | |
| 222 | ||
| 223 | # ------------------------------- Phase 3: materialize ONE group on demand | |
| 224 | ||
| 225 | @doc """ | |
| 226 | Given a raw group (as built by `group_entries/1`) and validation opts, | |
| 227 | produce either `{:ok, %Group{}}` (the struct includes a private | |
| 228 | `:__ssl_payload__` map for use by lookup) or `{:error, reason}`. | |
| 229 | ||
| 230 | Reads files, decodes PEM, validates, builds the SSL payload. This is the | |
| 231 | expensive phase; it must only run for groups whose `(prefix, key)` an | |
| 232 | actual helper is asking about. | |
| 233 | ||
| 234 | Options: | |
| 235 | * `:expiry_grace_seconds` - default 0 | |
| 236 | * `:public_ca_validation` - `:auto | :always | :never`, default `:auto` | |
| 237 | """ | |
| 238 | def materialize_group(raw_group, opts \\ []) do | |
| 239 | 106 | grace = Keyword.get(opts, :expiry_grace_seconds, 0) |
| 240 | 106 | public_ca = Keyword.get(opts, :public_ca_validation, :auto) |
| 241 | ||
| 242 | 106 | if public_ca == :always and not castore_available?() do |
| 243 | {:error, :castore_required_for_always_validation} | |
| 244 | else | |
| 245 | 106 | with {:ok, prepared} <- materialize_prepare(raw_group) do |
| 246 | 101 | validate_group(prepared, grace, public_ca) |
| 247 | end | |
| 248 | end | |
| 249 | end | |
| 250 | ||
| 251 | # ---------------------------------------------------- normalize prefixes | |
| 252 | ||
| 253 | def normalize_prefixes!(nil) do | |
| 254 | 2 | raise ArgumentError, "Snippy: :prefix option is required" |
| 255 | end | |
| 256 | ||
| 257 | def normalize_prefixes!(prefix) when is_list(prefix) do | |
| 258 | prefix | |
| 259 | |> Enum.map(&normalize_prefix!/1) | |
| 260 | |> Enum.uniq() | |
| 261 | 5 | |> validate_no_overlap!() |
| 262 | end | |
| 263 | ||
| 264 | 82 | def normalize_prefixes!(prefix) do |
| 265 | [normalize_prefix!(prefix)] | |
| 266 | end | |
| 267 | ||
| 268 | defp normalize_prefix!(:elixir), | |
| 269 | 2 | do: raise(ArgumentError, "Snippy: :elixir is not a valid prefix") |
| 270 | ||
| 271 | defp normalize_prefix!(nil), | |
| 272 | 1 | do: raise(ArgumentError, "Snippy: nil is not a valid prefix") |
| 273 | ||
| 274 | defp normalize_prefix!(true), | |
| 275 | 2 | do: raise(ArgumentError, "Snippy: true is not a valid prefix") |
| 276 | ||
| 277 | defp normalize_prefix!(false), | |
| 278 | 1 | do: raise(ArgumentError, "Snippy: false is not a valid prefix") |
| 279 | ||
| 280 | defp normalize_prefix!(p) when is_atom(p) do | |
| 281 | 4 | p |> Atom.to_string() |> String.upcase() |
| 282 | end | |
| 283 | ||
| 284 | 1 | defp normalize_prefix!("") do |
| 285 | "" | |
| 286 | end | |
| 287 | ||
| 288 | defp normalize_prefix!(p) when is_binary(p) do | |
| 289 | 80 | String.upcase(p) |
| 290 | end | |
| 291 | ||
| 292 | defp normalize_prefix!(other) do | |
| 293 | 1 | raise ArgumentError, "Snippy: invalid prefix #{inspect(other)}" |
| 294 | end | |
| 295 | ||
| 296 | defp validate_no_overlap!(prefixes) do | |
| 297 | 4 | for a <- prefixes, b <- prefixes, a != b do |
| 298 | 4 | if a != "" and b != "" and String.starts_with?(b, a <> "_") do |
| 299 | 2 | raise ArgumentError, |
| 300 | "Snippy: ambiguous prefixes: #{inspect(a)} is a prefix of #{inspect(b)}" | |
| 301 | end | |
| 302 | end | |
| 303 | ||
| 304 | 2 | prefixes |
| 305 | end | |
| 306 | ||
| 307 | defp castore_available? do | |
| 308 | 82 | Code.ensure_loaded?(CAStore) and function_exported?(CAStore, :file_path, 0) |
| 309 | end | |
| 310 | ||
| 311 | # ----------------------------------------------------- Materialization --- | |
| 312 | ||
| 313 | 1 | defp materialize_prepare(%{cert: nil, key_var: nil}) do |
| 314 | {:error, :no_cert_or_key} | |
| 315 | end | |
| 316 | ||
| 317 | 1 | defp materialize_prepare(%{cert: nil}) do |
| 318 | {:error, :key_without_cert} | |
| 319 | end | |
| 320 | ||
| 321 | 1 | defp materialize_prepare(%{key_var: nil}) do |
| 322 | {:error, :cert_without_key} | |
| 323 | end | |
| 324 | ||
| 325 | defp materialize_prepare(g) do | |
| 326 | 103 | case resolve_password(g.password) do |
| 327 | {:ok, password_str} -> | |
| 328 | 101 | cert = g.cert |
| 329 | 101 | key_var = g.key_var |
| 330 | ||
| 331 | 101 | prepared = |
| 332 | Map.merge(g, %{ | |
| 333 | 101 | cert_kind: cert.kind, |
| 334 | 101 | cert_var: cert.var, |
| 335 | 101 | cert_val: cert.val, |
| 336 | 101 | key_kind: key_var.kind, |
| 337 | 101 | key_var_name: key_var.var, |
| 338 | 101 | key_val: key_var.val, |
| 339 | password_str: password_str | |
| 340 | }) | |
| 341 | ||
| 342 | {:ok, prepared} | |
| 343 | ||
| 344 | {:error, _} = err -> | |
| 345 | 1 | err |
| 346 | end | |
| 347 | end | |
| 348 | ||
| 349 | 94 | defp resolve_password(nil), do: {:ok, nil} |
| 350 | ||
| 351 | 6 | defp resolve_password(%{kind: :inline, val: val}), do: {:ok, val} |
| 352 | ||
| 353 | defp resolve_password(%{kind: :file, var: var, val: path}) do | |
| 354 | 2 | Logger.warning("snippy: #{var} loads password from file (#{path})") |
| 355 | ||
| 356 | 2 | case File.read(path) do |
| 357 | 1 | {:ok, contents} -> {:ok, contents} |
| 358 | 1 | {:error, reason} -> {:error, {:password_file, reason, path}} |
| 359 | end | |
| 360 | end | |
| 361 | ||
| 362 | defp validate_group(g, grace, public_ca) do | |
| 363 | 101 | label = "#{inspect(g.prefix)}/#{g.key}" |
| 364 | ||
| 365 | 101 | with {:ok, cert_ders} <- load_cert_chain(g), |
| 366 | 94 | {:ok, key} <- load_key(g), |
| 367 | 91 | :ok <- check_match(cert_ders, key, label), |
| 368 | 91 | :ok <- check_validity(cert_ders, label, grace), |
| 369 | 89 | {:ok, ca_ders} <- load_ca_chain(g), |
| 370 | 89 | {chain_status, chain_reason} <- |
| 371 | validate_chain_or_castore(hd(cert_ders), ca_ders, public_ca, label) do | |
| 372 | 89 | build_group_struct(g, cert_ders, key, ca_ders, chain_status, chain_reason) |
| 373 | end | |
| 374 | end | |
| 375 | ||
| 376 | defp load_cert_chain(%{cert_kind: :inline, cert_val: pem}) do | |
| 377 | 97 | Decoder.decode_certs(pem) |
| 378 | end | |
| 379 | ||
| 380 | defp load_cert_chain(%{cert_kind: :file, cert_val: path}) do | |
| 381 | 4 | Decoder.decode_certs_file(path) |
| 382 | end | |
| 383 | ||
| 384 | defp load_key(%{key_kind: :inline, key_val: pem, password_str: password}) do | |
| 385 | 84 | Decoder.decode_key(pem, password) |
| 386 | end | |
| 387 | ||
| 388 | 10 | defp load_key(%{key_kind: :file, key_val: path, password_str: password}) do |
| 389 | 10 | if path |> File.read!() |> String.contains?("ENCRYPTED") and password == nil do |
| 390 | 1 | Logger.warning("snippy: encrypted key file #{path} but no password set; will likely fail") |
| 391 | end | |
| 392 | ||
| 393 | 9 | Decoder.decode_key_file(path, password) |
| 394 | rescue | |
| 395 | 1 | e in File.Error -> |
| 396 | 1 | {:error, {:file_read, e.reason, path}} |
| 397 | end | |
| 398 | ||
| 399 | defp check_match([leaf | _], key, _label) do | |
| 400 | 91 | if Decoder.cert_key_match?(leaf, key) do |
| 401 | :ok | |
| 402 | else | |
| 403 | {:error, :cert_key_mismatch} | |
| 404 | end | |
| 405 | end | |
| 406 | ||
| 407 | 91 | defp check_validity([leaf | _], _label, grace) do |
| 408 | 91 | {not_before, not_after} = Decoder.cert_validity(leaf) |
| 409 | 91 | now = DateTime.utc_now() |
| 410 | 91 | grace_dt_after = DateTime.add(not_after, grace, :second) |
| 411 | ||
| 412 | 91 | cond do |
| 413 | 1 | DateTime.compare(now, not_before) == :lt -> |
| 414 | {:error, {:not_yet_valid, not_before}} | |
| 415 | ||
| 416 | 90 | DateTime.compare(now, grace_dt_after) == :gt -> |
| 417 | {:error, {:expired, not_after}} | |
| 418 | ||
| 419 | 89 | true -> |
| 420 | :ok | |
| 421 | end | |
| 422 | rescue | |
| 423 | 0 | ArgumentError -> :ok |
| 424 | end | |
| 425 | ||
| 426 | 81 | defp load_ca_chain(%{ca: nil}), do: {:ok, []} |
| 427 | ||
| 428 | defp load_ca_chain(%{ca: %{kind: :inline, val: pem}}) do | |
| 429 | 5 | Decoder.decode_certs(pem) |
| 430 | end | |
| 431 | ||
| 432 | defp load_ca_chain(%{ca: %{kind: :file, val: path}}) do | |
| 433 | 3 | Decoder.decode_certs_file(path) |
| 434 | end | |
| 435 | ||
| 436 | defp validate_chain_or_castore(leaf, [], public_ca, label) do | |
| 437 | 81 | try_public_ca(leaf, [], public_ca, label) |
| 438 | end | |
| 439 | ||
| 440 | defp validate_chain_or_castore(leaf, intermediates, public_ca, label) do | |
| 441 | 8 | case Decoder.validate_chain(leaf, intermediates) do |
| 442 | 7 | :ok -> |
| 443 | {:ok_chain, nil} | |
| 444 | ||
| 445 | {:error, reason} -> | |
| 446 | 1 | Logger.warning( |
| 447 | 1 | "snippy: #{label}: chain validation against provided CA failed: #{inspect(reason)}" |
| 448 | ) | |
| 449 | ||
| 450 | 1 | try_public_ca(leaf, intermediates, public_ca, label) |
| 451 | end | |
| 452 | end | |
| 453 | ||
| 454 | defp try_public_ca(leaf, intermediates, mode, label) do | |
| 455 | 82 | cond do |
| 456 | mode == :never -> | |
| 457 | 1 | log_self_signed(label) |
| 458 | {:ok_self, nil} | |
| 459 | ||
| 460 | 81 | castore_available?() -> |
| 461 | 80 | validate_against_castore(leaf, intermediates, mode, label) |
| 462 | ||
| 463 | 1 | true -> |
| 464 | 1 | log_self_signed(label) |
| 465 | {:ok_self, nil} | |
| 466 | end | |
| 467 | end | |
| 468 | ||
| 469 | defp validate_against_castore(leaf, intermediates, mode, label) do | |
| 470 | 80 | case Decoder.validate_against_castore(leaf, intermediates) do |
| 471 | :ok -> | |
| 472 | 1 | Logger.info("snippy: #{label}: validated against public CA bundle") |
| 473 | {:ok_public, nil} | |
| 474 | ||
| 475 | 1 | {:error, reason} when mode == :always -> |
| 476 | {:error_chain, reason} | |
| 477 | ||
| 478 | {:error, reason} -> | |
| 479 | 78 | Logger.warning( |
| 480 | 78 | "snippy: #{label}: public CA validation failed: #{inspect(reason)}; accepting" |
| 481 | ) | |
| 482 | ||
| 483 | 78 | log_self_signed(label) |
| 484 | {:ok_self, reason} | |
| 485 | end | |
| 486 | end | |
| 487 | ||
| 488 | defp log_self_signed(label) do | |
| 489 | 80 | Logger.info("snippy: #{label}: no chain validation; trusting cert as-is") |
| 490 | end | |
| 491 | ||
| 492 | defp build_group_struct(g, cert_ders, key, ca_ders, :error_chain, reason) do | |
| 493 | 1 | _ = g |
| 494 | 1 | _ = cert_ders |
| 495 | 1 | _ = key |
| 496 | 1 | _ = ca_ders |
| 497 | {:error, {:public_ca_required, reason}} | |
| 498 | end | |
| 499 | ||
| 500 | defp build_group_struct(g, cert_ders, key, ca_ders, chain_status, chain_reason) do | |
| 501 | 88 | [leaf | _] = cert_ders |
| 502 | 88 | {not_before, not_after} = Decoder.cert_validity(leaf) |
| 503 | 88 | hostnames = Decoder.cert_hostnames(leaf) |
| 504 | 88 | payload = build_ssl_payload(cert_ders, key, ca_ders, g) |
| 505 | ||
| 506 | 88 | group = |
| 507 | struct!(Group, | |
| 508 | 88 | prefix: g.prefix, |
| 509 | 88 | key: g.key, |
| 510 | hostnames: hostnames, | |
| 511 | 88 | has_password?: g.password != nil, |
| 512 | has_ca_chain?: ca_ders != [], | |
| 513 | 88 | cert_source: g.cert_kind, |
| 514 | 88 | key_source: g.key_kind, |
| 515 | spki_fingerprint: Decoder.spki_fingerprint(leaf), | |
| 516 | key_fingerprint: Decoder.key_fingerprint(key), | |
| 517 | key_type: Decoder.key_type(key), | |
| 518 | not_before: not_before, | |
| 519 | not_after: not_after, | |
| 520 | chain_validation: chain_status, | |
| 521 | chain_validation_reason: chain_reason, | |
| 522 | ssl_payload: payload | |
| 523 | ) | |
| 524 | ||
| 525 | {:ok, group} | |
| 526 | end | |
| 527 | ||
| 528 | defp build_ssl_payload(cert_ders, key, ca_ders, g) do | |
| 529 | 88 | full_chain = cert_ders ++ ca_ders |
| 530 | ||
| 531 | 88 | base = |
| 532 | 88 | if g.cert_kind == :file and ca_ders == [] do |
| 533 | 3 | %{certfile: g.cert_val} |
| 534 | else | |
| 535 | 85 | %{cert: full_chain} |
| 536 | end | |
| 537 | ||
| 538 | 88 | base = |
| 539 | 88 | if g.key_kind == :file and ca_ders == [] do |
| 540 | 7 | Map.put(base, :keyfile, g.key_val) |
| 541 | else | |
| 542 | 81 | Map.put(base, :key, ssl_key_form(key)) |
| 543 | end | |
| 544 | ||
| 545 | 88 | case g.password_str do |
| 546 | 82 | nil -> base |
| 547 | 6 | pw -> Map.put(base, :password, pw) |
| 548 | end | |
| 549 | end | |
| 550 | ||
| 551 | 81 | defp ssl_key_form(%{asn1_type: type, der: der}) do |
| 552 | {type, der} | |
| 553 | end | |
| 554 | ||
| 555 | @doc """ | |
| 556 | Format a materialization error reason as a human-readable string for logs. | |
| 557 | """ | |
| 558 | 3 | def format_error({:file_read, reason, path}), do: "cannot read #{path}: #{inspect(reason)}" |
| 559 | ||
| 560 | def format_error({:password_file, reason, path}), | |
| 561 | 1 | do: "cannot read password file #{path}: #{inspect(reason)}" |
| 562 | ||
| 563 | 6 | def format_error(:invalid_pem), do: "invalid PEM" |
| 564 | 1 | def format_error(:no_certificates_found), do: "no certificates found" |
| 565 | 1 | def format_error(:no_key_found), do: "no private key found" |
| 566 | 2 | def format_error(:bad_password), do: "wrong password (or unable to decrypt)" |
| 567 | 2 | def format_error(:encrypted_key_no_password), do: "key is encrypted but no password set" |
| 568 | 1 | def format_error(:cert_key_mismatch), do: "cert public key does not match private key" |
| 569 | 1 | def format_error(:no_cert_or_key), do: "no cert or key found for group" |
| 570 | 1 | def format_error(:key_without_cert), do: "key present but no certificate" |
| 571 | 1 | def format_error(:cert_without_key), do: "certificate present but no key" |
| 572 | ||
| 573 | 1 | def format_error(:castore_required_for_always_validation), |
| 574 | do: "public_ca_validation: :always requires the :castore dependency" | |
| 575 | ||
| 576 | 2 | def format_error({:not_yet_valid, t}), do: "not yet valid (notBefore=#{t})" |
| 577 | 2 | def format_error({:expired, t}), do: "expired (notAfter=#{t})" |
| 578 | ||
| 579 | def format_error({:public_ca_required, reason}), | |
| 580 | 2 | do: "public CA validation required and failed: #{inspect(reason)}" |
| 581 | ||
| 582 | 4 | def format_error(other), do: inspect(other) |
| 583 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Snippy.Lookup do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | require Logger | |
| 4 | ||
| 5 | alias Snippy.Discovery.Group | |
| 6 | alias Snippy.Wildcard | |
| 7 | ||
| 8 | # All public entry points work over a list of fully-materialized %Group{}s | |
| 9 | # (which carry the private :__ssl_payload__ map). This list comes from | |
| 10 | # either: | |
| 11 | # * `Snippy.Store.lookup_groups/2` (the live shared scan), or | |
| 12 | # * the user-supplied `:discovered_certs` (a %Discovery{} from | |
| 13 | # `Snippy.discover_certificates/1`), in which case the caller must | |
| 14 | # hydrate the payloads from the Store before calling us — see | |
| 15 | # `hydrate_groups/1`. | |
| 16 | ||
| 17 | # ---------------------------------------------------- Hydration helpers --- | |
| 18 | ||
| 19 | @doc """ | |
| 20 | Given a list of Groups from a `%Discovery{}`, return them with their | |
| 21 | `ssl_payload` populated. | |
| 22 | ||
| 23 | If a group already has a non-nil `ssl_payload` (i.e. it came from an | |
| 24 | isolated discovery), it's returned as-is. If the payload is nil | |
| 25 | (stripped before being placed on a public handle from a shared | |
| 26 | discovery), look the full version up in the Store's ETS by | |
| 27 | `(prefix, key)`. Groups without a materialized entry are dropped silently. | |
| 28 | """ | |
| 29 | def hydrate_groups(groups) do | |
| 30 | 27 | Enum.flat_map(groups, fn |
| 31 | 35 | %Group{ssl_payload: payload} = g when not is_nil(payload) -> |
| 32 | [g] | |
| 33 | ||
| 34 | %Group{prefix: pfx, key: key} -> | |
| 35 | 2 | case Snippy.Store.materialized_group(pfx, key) do |
| 36 | 1 | nil -> [] |
| 37 | 1 | %Group{} = g -> [g] |
| 38 | end | |
| 39 | end) | |
| 40 | end | |
| 41 | ||
| 42 | # ---------------------------------------------------------------- API --- | |
| 43 | ||
| 44 | @doc """ | |
| 45 | Build the SNI fun for a list of materialized groups. | |
| 46 | ||
| 47 | Returns a closure suitable for the `:sni_fun` :ssl option. | |
| 48 | """ | |
| 49 | def sni_fun(groups, opts) do | |
| 50 | 27 | scope = build_scope(groups, opts) |
| 51 | 27 | fallback = fallback_entries(groups, scope) |
| 52 | 27 | scoped_groups = scoped(groups, scope) |
| 53 | ||
| 54 | 27 | fn host -> |
| 55 | 31 | host_norm = normalize(host) |
| 56 | 31 | matches = entries_for_host(scoped_groups, host_norm) |
| 57 | ||
| 58 | 31 | cond do |
| 59 | 25 | matches != [] -> [certs_keys: ssl_payloads(matches)] |
| 60 | 6 | fallback != [] -> [certs_keys: ssl_payloads(fallback)] |
| 61 | 2 | true -> [] |
| 62 | end | |
| 63 | end | |
| 64 | end | |
| 65 | ||
| 66 | @doc """ | |
| 67 | Build keyword opts for `:ssl.listen/2` (and equivalents). | |
| 68 | """ | |
| 69 | def ssl_opts(groups, opts) do | |
| 70 | 24 | scope = build_scope(groups, opts) |
| 71 | 24 | fallback = fallback_entries(groups, scope) |
| 72 | 24 | fun = sni_fun(groups, opts) |
| 73 | ||
| 74 | [ | |
| 75 | sni_fun: fun, | |
| 76 | certs_keys: ssl_payloads(fallback) | |
| 77 | ] | |
| 78 | end | |
| 79 | ||
| 80 | # ----------------------------------------------------------------- Scope | |
| 81 | ||
| 82 | defp build_scope(groups, opts) do | |
| 83 | 51 | %{ |
| 84 | only: Keyword.get(opts, :only, nil), | |
| 85 | keys: opts |> Keyword.get(:keys, nil) |> normalize_keys(), | |
| 86 | default_hostname: Keyword.get(opts, :default_hostname, nil), | |
| 87 | groups: groups | |
| 88 | } | |
| 89 | end | |
| 90 | ||
| 91 | 45 | defp normalize_keys(nil), do: nil |
| 92 | ||
| 93 | defp normalize_keys(list) when is_list(list) do | |
| 94 | 6 | Enum.map(list, fn |
| 95 | 4 | a when is_atom(a) -> a |> Atom.to_string() |> String.upcase() |
| 96 | 2 | s when is_binary(s) -> String.upcase(s) |
| 97 | end) | |
| 98 | end | |
| 99 | ||
| 100 | 52 | defp scoped(groups, %{only: nil, keys: nil}), do: groups |
| 101 | ||
| 102 | defp scoped(groups, scope) do | |
| 103 | 24 | Enum.filter(groups, fn g -> in_scope?(g, scope) end) |
| 104 | end | |
| 105 | ||
| 106 | defp in_scope?(%Group{} = group, scope) do | |
| 107 | 37 | key_match?(group.key, scope.keys) or host_match?(group, scope.only) |
| 108 | end | |
| 109 | ||
| 110 | 19 | defp key_match?(_group_key, nil), do: false |
| 111 | 18 | defp key_match?(group_key, keys), do: group_key in keys |
| 112 | ||
| 113 | 9 | defp host_match?(_group, nil), do: false |
| 114 | ||
| 115 | defp host_match?(%Group{} = group, only_patterns) do | |
| 116 | 19 | Enum.any?(only_patterns, fn pattern -> |
| 117 | 19 | Enum.any?(group.hostnames, fn ghost -> |
| 118 | 19 | Wildcard.match?(pattern, ghost) or Wildcard.match?(ghost, pattern) or |
| 119 | 12 | Wildcard.normalize(pattern) == Wildcard.normalize(ghost) |
| 120 | end) | |
| 121 | end) | |
| 122 | end | |
| 123 | ||
| 124 | # ------------------------------------------------------- Host resolution | |
| 125 | ||
| 126 | defp entries_for_host(groups, host_norm) do | |
| 127 | 39 | case Enum.filter(groups, &group_matches_exact?(&1, host_norm)) do |
| 128 | [] -> | |
| 129 | 15 | tail = host_norm |> String.split(".") |> tl() |
| 130 | 15 | Enum.filter(groups, &group_matches_wild?(&1, tail)) |
| 131 | ||
| 132 | exact -> | |
| 133 | 24 | exact |
| 134 | end | |
| 135 | end | |
| 136 | ||
| 137 | defp group_matches_exact?(%Group{hostnames: hostnames}, host_norm) do | |
| 138 | 72 | Enum.any?(hostnames, &exact_pattern_matches?(&1, host_norm)) |
| 139 | end | |
| 140 | ||
| 141 | defp exact_pattern_matches?(pat, host_norm) do | |
| 142 | 85 | case Wildcard.parse(pat) do |
| 143 | 72 | {:exact, labels} -> Enum.join(labels, ".") == host_norm |
| 144 | 13 | _ -> false |
| 145 | end | |
| 146 | end | |
| 147 | ||
| 148 | defp group_matches_wild?(%Group{hostnames: hostnames}, tail) do | |
| 149 | 20 | Enum.any?(hostnames, &wild_pattern_matches?(&1, tail)) |
| 150 | end | |
| 151 | ||
| 152 | defp wild_pattern_matches?(pat, tail) do | |
| 153 | 22 | case Wildcard.parse(pat) do |
| 154 | 7 | {:wild, labels} -> labels == tail |
| 155 | 15 | _ -> false |
| 156 | end | |
| 157 | end | |
| 158 | ||
| 159 | # ---------------------------------------------------- Fallback entries | |
| 160 | ||
| 161 | 2 | defp fallback_entries([], _scope), do: [] |
| 162 | ||
| 163 | defp fallback_entries(groups, %{default_hostname: nil} = scope) do | |
| 164 | 41 | case scoped(groups, scope) do |
| 165 | 2 | [] -> [hd(groups)] |
| 166 | 39 | [first | _] = scoped_groups -> [first | List.delete(scoped_groups, first)] |> Enum.take(1) |
| 167 | end | |
| 168 | end | |
| 169 | ||
| 170 | defp fallback_entries(groups, %{default_hostname: host} = scope) do | |
| 171 | 8 | host_norm = normalize(host) |
| 172 | 8 | scoped_groups = scoped(groups, scope) |
| 173 | 8 | matches = entries_for_host(scoped_groups, host_norm) |
| 174 | ||
| 175 | 8 | cond do |
| 176 | matches != [] -> | |
| 177 | 4 | matches |
| 178 | ||
| 179 | 4 | scope.only != nil -> |
| 180 | 2 | Logger.warning( |
| 181 | "snippy: default_hostname #{inspect(host)} excluded by scope; non-SNI fallback empty" | |
| 182 | ) | |
| 183 | ||
| 184 | [] | |
| 185 | ||
| 186 | 2 | true -> |
| 187 | [] | |
| 188 | end | |
| 189 | end | |
| 190 | ||
| 191 | # ---------------------------------------------------- Payload extraction | |
| 192 | ||
| 193 | defp ssl_payloads(groups) do | |
| 194 | 53 | Enum.map(groups, fn %Group{ssl_payload: payload} -> payload end) |
| 195 | end | |
| 196 | ||
| 197 | 15 | defp normalize(host) when is_binary(host), do: Wildcard.normalize(host) |
| 198 | 24 | defp normalize(host) when is_list(host), do: host |> List.to_string() |> Wildcard.normalize() |
| 199 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Snippy.OTPCheck do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | @min_otp 25 | |
| 4 | ||
| 5 | @otp_release :erlang.system_info(:otp_release) |> List.to_integer() | |
| 6 | ||
| 7 | if @otp_release < @min_otp do | |
| 8 | raise CompileError, | |
| 9 | description: "Snippy requires OTP >= #{@min_otp}, got #{@otp_release}" | |
| 10 | end | |
| 11 | ||
| 12 | def check! do | |
| 13 | 124 | otp = :erlang.system_info(:otp_release) |> List.to_integer() |
| 14 | ||
| 15 | 124 | if otp < @min_otp do |
| 16 | 0 | raise "Snippy requires OTP >= #{@min_otp}, got #{otp}" |
| 17 | end | |
| 18 | ||
| 19 | :ok | |
| 20 | end | |
| 21 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Snippy.Store do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | # Snippy.Store owns a single, shared, broad scan of the env (no prefix | |
| 4 | # filtering). Helpers reach in through API functions; the API functions | |
| 5 | # check ETS atomically first, falling back to a synchronous GenServer.call | |
| 6 | # only on miss. Both successful materializations *and* errors are cached | |
| 7 | # in ETS to avoid recomputation; the API functions unwrap errors back into | |
| 8 | # error returns at the boundary. | |
| 9 | ||
| 10 | use GenServer | |
| 11 | require Logger | |
| 12 | ||
| 13 | alias Snippy.Discovery | |
| 14 | alias Snippy.Discovery.Group | |
| 15 | ||
| 16 | @table Snippy.TableOwner.table_name() | |
| 17 | ||
| 18 | # ETS row keys: | |
| 19 | # {:scan_meta} -> %{seq, scanned_at, scan_opts} | |
| 20 | # {:scan, seq, n} -> %{var, suffix, slot, kind, val} | |
| 21 | # {:materialized, prefix_up, key_up} -> {:ok, %Group{}} | {:error, reason} | |
| 22 | # {:exact, prefix_up, key_up, host} -> :present | |
| 23 | # {:wild, prefix_up, key_up, labels} -> :present | |
| 24 | ||
| 25 | @scan_timeout_ms 5_000 | |
| 26 | ||
| 27 | defmodule ScanError do | |
| 28 | defexception [:message, :reason] | |
| 29 | ||
| 30 | @impl true | |
| 31 | def exception(opts) do | |
| 32 | 2 | %__MODULE__{ |
| 33 | reason: opts[:reason], | |
| 34 | message: "snippy: scan failed: #{inspect(opts[:reason])}" | |
| 35 | } | |
| 36 | end | |
| 37 | end | |
| 38 | ||
| 39 | # ----------------------------------------------------------------- API --- | |
| 40 | ||
| 41 | def start_link(_opts \\ []) do | |
| 42 | 1 | GenServer.start_link(__MODULE__, [], name: __MODULE__) |
| 43 | end | |
| 44 | ||
| 45 | @doc """ | |
| 46 | Make sure a recent scan exists. Idempotent. | |
| 47 | ||
| 48 | Calling-process fast path: if the ETS `:scan_meta` row exists and the | |
| 49 | scan is still fresh (or there's no reload interval), return `:ok` | |
| 50 | without calling the GenServer. | |
| 51 | """ | |
| 52 | def ensure_scanned(opts \\ []) do | |
| 53 | 33 | case current_scan() do |
| 54 | 6 | {:ok, _meta} -> |
| 55 | :ok | |
| 56 | ||
| 57 | :missing -> | |
| 58 | 27 | synchronous_scan(opts) |
| 59 | end | |
| 60 | end | |
| 61 | ||
| 62 | defp synchronous_scan(opts) do | |
| 63 | 27 | case GenServer.call(__MODULE__, {:scan, opts}, scan_call_timeout()) do |
| 64 | 25 | :ok -> :ok |
| 65 | 2 | {:error, reason} -> raise ScanError, reason: reason |
| 66 | end | |
| 67 | end | |
| 68 | ||
| 69 | defp scan_call_timeout do | |
| 70 | 32 | Application.get_env(:snippy, :scan_timeout_ms, @scan_timeout_ms) + 5_000 |
| 71 | end | |
| 72 | ||
| 73 | defp infrastructure_available? do | |
| 74 | 26 | :ets.whereis(@table) != :undefined |
| 75 | end | |
| 76 | ||
| 77 | defp current_scan do | |
| 78 | 66 | case :ets.whereis(@table) do |
| 79 | 0 | :undefined -> |
| 80 | :missing | |
| 81 | ||
| 82 | _tid -> | |
| 83 | 66 | case :ets.lookup(@table, :scan_meta) do |
| 84 | 13 | [{:scan_meta, meta}] -> {:ok, meta} |
| 85 | 53 | [] -> :missing |
| 86 | end | |
| 87 | end | |
| 88 | end | |
| 89 | ||
| 90 | @doc """ | |
| 91 | Look up materialized groups for the given normalized prefixes. | |
| 92 | ||
| 93 | Errors are silently dropped (they were logged at materialization time). | |
| 94 | """ | |
| 95 | def lookup_groups(prefixes, opts \\ []) when is_list(prefixes) do | |
| 96 | 18 | if infrastructure_available?() do |
| 97 | 14 | shared_lookup_groups(prefixes, opts) |
| 98 | else | |
| 99 | 4 | local_lookup_groups(prefixes, opts) |
| 100 | end | |
| 101 | end | |
| 102 | ||
| 103 | defp shared_lookup_groups(prefixes, opts) do | |
| 104 | 14 | :ok = ensure_scanned(opts) |
| 105 | ||
| 106 | 14 | case_sensitive = Keyword.get(opts, :case_sensitive, true) |
| 107 | 14 | raw_groups = collect_raw_groups(prefixes, case_sensitive) |
| 108 | ||
| 109 | 14 | Enum.flat_map(raw_groups, fn raw -> |
| 110 | 16 | case fetch_or_materialize(raw, opts) do |
| 111 | 12 | {:ok, group} -> [group] |
| 112 | 4 | {:error, _reason} -> [] |
| 113 | end | |
| 114 | end) | |
| 115 | end | |
| 116 | ||
| 117 | defp local_lookup_groups(prefixes, opts) do | |
| 118 | 4 | case_sensitive = Keyword.get(opts, :case_sensitive, true) |
| 119 | ||
| 120 | 4 | raw_groups = |
| 121 | opts | |
| 122 | |> Discovery.scan_all() | |
| 123 | |> Discovery.filter_by_prefixes(prefixes, case_sensitive) | |
| 124 | |> Discovery.group_entries() | |
| 125 | ||
| 126 | 4 | Enum.flat_map(raw_groups, fn raw -> |
| 127 | 5 | case Discovery.materialize_group(raw, opts) do |
| 128 | 4 | {:ok, group} -> [group] |
| 129 | 1 | {:error, _reason} -> [] |
| 130 | end | |
| 131 | end) | |
| 132 | end | |
| 133 | ||
| 134 | defp collect_raw_groups(prefixes, case_sensitive) do | |
| 135 | 21 | entries = scan_rows() |
| 136 | ||
| 137 | entries | |
| 138 | |> Discovery.filter_by_prefixes(prefixes, case_sensitive) | |
| 139 | 21 | |> Discovery.group_entries() |
| 140 | end | |
| 141 | ||
| 142 | defp scan_rows do | |
| 143 | :ets.tab2list(@table) | |
| 144 | 21 | |> Enum.flat_map(fn |
| 145 | 46 | {{:scan, _seq, _n}, payload} -> [payload] |
| 146 | 25 | _ -> [] |
| 147 | end) | |
| 148 | end | |
| 149 | ||
| 150 | @doc """ | |
| 151 | Re-scan and clear all materialized + index rows. | |
| 152 | """ | |
| 153 | def reload(opts \\ []) do | |
| 154 | 5 | GenServer.call(__MODULE__, {:reload, opts}, scan_call_timeout()) |
| 155 | end | |
| 156 | ||
| 157 | @doc """ | |
| 158 | Test-only: clear all ETS state (scan, materialized, host index, meta) | |
| 159 | and reset the GenServer's seq + reload timer. Used by the test suite to | |
| 160 | isolate cases that exercise the shared Store path. Safe to call in | |
| 161 | production but generally not useful there. | |
| 162 | """ | |
| 163 | def __test_reset__ do | |
| 164 | 90 | GenServer.call(__MODULE__, :__test_reset__) |
| 165 | end | |
| 166 | ||
| 167 | @doc """ | |
| 168 | Eager diagnostic discovery: scan + materialize everything matching | |
| 169 | `:prefix`. | |
| 170 | ||
| 171 | When `:env` is provided in `opts`, runs an **isolated** discovery that | |
| 172 | does *not* touch the shared Store: a one-shot scan + materialize against | |
| 173 | the supplied env, returning a `%Discovery{}` whose groups carry their | |
| 174 | own `ssl_payload` (no Store lookup needed). Suitable for tests and for | |
| 175 | callers who want full control over when materialization happens. | |
| 176 | ||
| 177 | Without `:env`, runs against the shared Store like normal helpers do. | |
| 178 | """ | |
| 179 | def discover(opts) do | |
| 180 | 75 | Snippy.OTPCheck.check!() |
| 181 | 75 | prefixes = Discovery.normalize_prefixes!(opts[:prefix]) |
| 182 | ||
| 183 | 71 | cond do |
| 184 | 63 | Keyword.has_key?(opts, :env) -> isolated_discover(prefixes, opts) |
| 185 | 8 | not infrastructure_available?() -> isolated_discover(prefixes, opts) |
| 186 | 7 | true -> shared_discover(prefixes, opts) |
| 187 | end | |
| 188 | end | |
| 189 | ||
| 190 | defp shared_discover(prefixes, opts) do | |
| 191 | 7 | :ok = ensure_scanned(opts) |
| 192 | ||
| 193 | 7 | case_sensitive = Keyword.get(opts, :case_sensitive, true) |
| 194 | 7 | raw_groups = collect_raw_groups(prefixes, case_sensitive) |
| 195 | ||
| 196 | 7 | {groups, errors} = |
| 197 | Enum.reduce(raw_groups, {[], []}, fn raw, {gs, es} -> | |
| 198 | 7 | case fetch_or_materialize(raw, opts) do |
| 199 | 5 | {:ok, group} -> {[group | gs], es} |
| 200 | 2 | {:error, reason} -> {gs, [{raw.prefix, raw.key, reason} | es]} |
| 201 | end | |
| 202 | end) | |
| 203 | ||
| 204 | 7 | disc = %Discovery{ |
| 205 | id: make_ref(), | |
| 206 | table: @table, | |
| 207 | default_hostname: opts[:default_hostname], | |
| 208 | reload_interval_ms: opts[:reload_interval_ms], | |
| 209 | groups: Enum.reverse(groups) |> Enum.map(&strip_payload/1), | |
| 210 | errors: Enum.reverse(errors) | |
| 211 | } | |
| 212 | ||
| 213 | 7 | if disc.reload_interval_ms do |
| 214 | 2 | GenServer.cast(__MODULE__, {:set_reload_interval, disc.reload_interval_ms}) |
| 215 | end | |
| 216 | ||
| 217 | {:ok, disc} | |
| 218 | end | |
| 219 | ||
| 220 | defp isolated_discover(prefixes, opts) do | |
| 221 | 64 | case_sensitive = Keyword.get(opts, :case_sensitive, true) |
| 222 | 64 | entries = Discovery.scan_all(opts) |
| 223 | ||
| 224 | 64 | raw_groups = |
| 225 | entries | |
| 226 | |> Discovery.filter_by_prefixes(prefixes, case_sensitive) | |
| 227 | |> Discovery.group_entries() | |
| 228 | ||
| 229 | 63 | {groups, errors} = |
| 230 | Enum.reduce(raw_groups, {[], []}, fn raw, {gs, es} -> | |
| 231 | 74 | case Discovery.materialize_group(raw, opts) do |
| 232 | 66 | {:ok, group} -> |
| 233 | {[group | gs], es} | |
| 234 | ||
| 235 | {:error, reason} -> | |
| 236 | require Logger | |
| 237 | ||
| 238 | 8 | Logger.error( |
| 239 | 8 | "snippy: #{inspect(raw.prefix)}/#{raw.key}: #{Discovery.format_error(reason)}; dropping" |
| 240 | ) | |
| 241 | ||
| 242 | 8 | {gs, [{raw.prefix, raw.key, reason} | es]} |
| 243 | end | |
| 244 | end) | |
| 245 | ||
| 246 | 63 | disc = %Discovery{ |
| 247 | id: make_ref(), | |
| 248 | table: @table, | |
| 249 | default_hostname: opts[:default_hostname], | |
| 250 | reload_interval_ms: opts[:reload_interval_ms], | |
| 251 | groups: Enum.reverse(groups), | |
| 252 | errors: Enum.reverse(errors) | |
| 253 | } | |
| 254 | ||
| 255 | {:ok, disc} | |
| 256 | end | |
| 257 | ||
| 258 | 5 | defp strip_payload(%Group{} = g), do: %{g | ssl_payload: nil} |
| 259 | ||
| 260 | # Used by Snippy.Lookup to retrieve the full group (including __ssl_payload__) | |
| 261 | # given a (prefix, key). Returns nil if not materialized successfully. | |
| 262 | def materialized_group(prefix_up, key_up) do | |
| 263 | 4 | case :ets.lookup(@table, {:materialized, prefix_up, key_up}) do |
| 264 | 2 | [{_, {:ok, %Group{} = g}}] -> g |
| 265 | 2 | _ -> nil |
| 266 | end | |
| 267 | end | |
| 268 | ||
| 269 | # ------------------------------------------------- Materialization fast path | |
| 270 | ||
| 271 | defp fetch_or_materialize(raw, opts) do | |
| 272 | 23 | key = {:materialized, raw.prefix, raw.key} |
| 273 | ||
| 274 | 23 | case :ets.lookup(@table, key) do |
| 275 | [{_, cached}] -> | |
| 276 | 3 | cached |
| 277 | ||
| 278 | [] -> | |
| 279 | 20 | GenServer.call( |
| 280 | __MODULE__, | |
| 281 | {:materialize, raw, opts}, | |
| 282 | materialize_call_timeout() | |
| 283 | ) | |
| 284 | ||
| 285 | 20 | [{_, cached}] = :ets.lookup(@table, key) |
| 286 | 20 | cached |
| 287 | end | |
| 288 | end | |
| 289 | ||
| 290 | defp materialize_call_timeout do | |
| 291 | 20 | Application.get_env(:snippy, :materialize_timeout_ms, 30_000) |
| 292 | end | |
| 293 | ||
| 294 | # ---------------------------------------------------------- GenServer --- | |
| 295 | ||
| 296 | @impl true | |
| 297 | 1 | def init([]) do |
| 298 | {:ok, | |
| 299 | %{ | |
| 300 | seq: 0, | |
| 301 | reload_interval_ms: nil, | |
| 302 | reload_timer: nil | |
| 303 | }} | |
| 304 | end | |
| 305 | ||
| 306 | @impl true | |
| 307 | def handle_call({:scan, opts}, _from, state) do | |
| 308 | 27 | case current_scan() do |
| 309 | {:ok, _meta} -> | |
| 310 | # Someone already scanned while we waited for our turn. | |
| 311 | 2 | {:reply, :ok, state} |
| 312 | ||
| 313 | :missing -> | |
| 314 | 25 | case do_scan(opts) do |
| 315 | {:ok, new_seq} -> | |
| 316 | 23 | state = %{state | seq: new_seq} |
| 317 | 23 | state = maybe_record_reload_interval(state, opts) |
| 318 | 23 | {:reply, :ok, schedule_reload_if_needed(state)} |
| 319 | ||
| 320 | {:error, _reason} = err -> | |
| 321 | 2 | {:reply, err, state} |
| 322 | end | |
| 323 | end | |
| 324 | end | |
| 325 | ||
| 326 | @impl true | |
| 327 | def handle_call({:reload, opts}, _from, state) do | |
| 328 | 5 | case do_scan(opts) do |
| 329 | {:ok, new_seq} -> | |
| 330 | 3 | state = %{state | seq: new_seq} |
| 331 | 3 | state = maybe_record_reload_interval(state, opts) |
| 332 | 3 | {:reply, :ok, schedule_reload_if_needed(state)} |
| 333 | ||
| 334 | {:error, reason} -> | |
| 335 | 2 | Logger.error("snippy: reload scan failed: #{inspect(reason)}") |
| 336 | 2 | {:reply, {:error, reason}, state} |
| 337 | end | |
| 338 | end | |
| 339 | ||
| 340 | @impl true | |
| 341 | def handle_call(:__test_reset__, _from, state) do | |
| 342 | 90 | if state.reload_timer, do: Process.cancel_timer(state.reload_timer) |
| 343 | 90 | :ets.match_delete(@table, {{:scan, :_, :_}, :_}) |
| 344 | 90 | :ets.match_delete(@table, {{:materialized, :_, :_}, :_}) |
| 345 | 90 | :ets.match_delete(@table, {{:exact, :_, :_, :_}, :_}) |
| 346 | 90 | :ets.match_delete(@table, {{:wild, :_, :_, :_}, :_}) |
| 347 | 90 | :ets.delete(@table, :scan_meta) |
| 348 | 90 | {:reply, :ok, %{state | seq: 0, reload_interval_ms: nil, reload_timer: nil}} |
| 349 | end | |
| 350 | ||
| 351 | @impl true | |
| 352 | def handle_call({:materialize, raw, opts}, _from, state) do | |
| 353 | 22 | key = {:materialized, raw.prefix, raw.key} |
| 354 | ||
| 355 | 22 | case :ets.lookup(@table, key) do |
| 356 | [_] -> | |
| 357 | 1 | {:reply, :ok, state} |
| 358 | ||
| 359 | [] -> | |
| 360 | 21 | result = run_materialize(raw, opts) |
| 361 | 21 | :ets.insert(@table, {key, result}) |
| 362 | ||
| 363 | 21 | case result do |
| 364 | 16 | {:ok, %Group{} = g} -> populate_host_index(g) |
| 365 | 5 | {:error, reason} -> log_materialization_error(raw, reason) |
| 366 | end | |
| 367 | ||
| 368 | 21 | {:reply, :ok, state} |
| 369 | end | |
| 370 | end | |
| 371 | ||
| 372 | @impl true | |
| 373 | def handle_cast({:set_reload_interval, ms}, state) do | |
| 374 | 2 | state = %{state | reload_interval_ms: ms} |
| 375 | {:noreply, schedule_reload_if_needed(state)} | |
| 376 | end | |
| 377 | ||
| 378 | @impl true | |
| 379 | def handle_info({:scheduled_reload, seq}, state) do | |
| 380 | 6 | if seq == state.seq do |
| 381 | 6 | case do_scan(scan_opts_from_state(state)) do |
| 382 | 3 | {:ok, new_seq} -> |
| 383 | {:noreply, schedule_reload_if_needed(%{state | seq: new_seq})} | |
| 384 | ||
| 385 | {:error, reason} -> | |
| 386 | 3 | Logger.error("snippy: scheduled reload failed: #{inspect(reason)}") |
| 387 | {:noreply, schedule_reload_if_needed(state)} | |
| 388 | end | |
| 389 | else | |
| 390 | {:noreply, state} | |
| 391 | end | |
| 392 | end | |
| 393 | ||
| 394 | @impl true | |
| 395 | def handle_info({ref, _result}, state) when is_reference(ref) do | |
| 396 | 1 | Process.demonitor(ref, [:flush]) |
| 397 | {:noreply, state} | |
| 398 | end | |
| 399 | ||
| 400 | @impl true | |
| 401 | 1 | def handle_info({:DOWN, _ref, :process, _pid, _reason}, state) do |
| 402 | {:noreply, state} | |
| 403 | end | |
| 404 | ||
| 405 | # --------------------------------------------------------- internals --- | |
| 406 | ||
| 407 | defp do_scan(opts) do | |
| 408 | 36 | timeout = Application.get_env(:snippy, :scan_timeout_ms, @scan_timeout_ms) |
| 409 | 36 | scan_fn = Application.get_env(:snippy, :scan_fn, &Discovery.scan_all/1) |
| 410 | ||
| 411 | 36 | task = |
| 412 | Task.Supervisor.async_nolink(Snippy.TaskSupervisor, fn -> | |
| 413 | 36 | scan_fn.(opts) |
| 414 | end) | |
| 415 | ||
| 416 | 36 | case Task.yield(task, timeout) || Task.shutdown(task) do |
| 417 | {:ok, entries} -> | |
| 418 | 29 | seq = System.unique_integer([:positive, :monotonic]) |
| 419 | 29 | replace_scan(entries, seq, opts) |
| 420 | {:ok, seq} | |
| 421 | ||
| 422 | 1 | nil -> |
| 423 | {:error, :scan_timeout} | |
| 424 | ||
| 425 | 6 | {:exit, reason} -> |
| 426 | {:error, {:scan_crashed, reason}} | |
| 427 | end | |
| 428 | end | |
| 429 | ||
| 430 | defp replace_scan(entries, seq, opts) do | |
| 431 | # Drop everything dependent on the old scan. | |
| 432 | 29 | :ets.match_delete(@table, {{:scan, :_, :_}, :_}) |
| 433 | 29 | :ets.match_delete(@table, {{:materialized, :_, :_}, :_}) |
| 434 | 29 | :ets.match_delete(@table, {{:exact, :_, :_, :_}, :_}) |
| 435 | 29 | :ets.match_delete(@table, {{:wild, :_, :_, :_}, :_}) |
| 436 | 29 | :ets.delete(@table, :scan_meta) |
| 437 | ||
| 438 | 29 | rows = |
| 439 | entries | |
| 440 | |> Enum.with_index() | |
| 441 | 58 | |> Enum.map(fn {entry, n} -> {{:scan, seq, n}, entry} end) |
| 442 | ||
| 443 | 29 | if rows != [] do |
| 444 | 26 | :ets.insert(@table, rows) |
| 445 | end | |
| 446 | ||
| 447 | 29 | meta = %{ |
| 448 | seq: seq, | |
| 449 | scanned_at: System.monotonic_time(:millisecond), | |
| 450 | scan_opts: Keyword.take(opts, [:case_sensitive, :env]) | |
| 451 | } | |
| 452 | ||
| 453 | 29 | :ets.insert(@table, {:scan_meta, meta}) |
| 454 | :ok | |
| 455 | end | |
| 456 | ||
| 457 | 21 | defp run_materialize(raw, opts) do |
| 458 | 21 | Discovery.materialize_group(raw, opts) |
| 459 | rescue | |
| 460 | 1 | e -> |
| 461 | 1 | Logger.error("snippy: materialize_group raised: #{Exception.message(e)}") |
| 462 | {:error, {:materialize_exception, Exception.message(e)}} | |
| 463 | end | |
| 464 | ||
| 465 | defp populate_host_index(%Group{prefix: pfx, key: key, hostnames: hosts}) do | |
| 466 | 16 | rows = |
| 467 | Enum.map(hosts, fn host -> | |
| 468 | 17 | case Snippy.Wildcard.parse(host) do |
| 469 | 16 | {:exact, labels} -> |
| 470 | {{:exact, pfx, key, Enum.join(labels, ".")}, :present} | |
| 471 | ||
| 472 | 1 | {:wild, labels} -> |
| 473 | {{:wild, pfx, key, labels}, :present} | |
| 474 | end | |
| 475 | end) | |
| 476 | ||
| 477 | 16 | if rows != [] do |
| 478 | 16 | :ets.insert(@table, rows) |
| 479 | end | |
| 480 | ||
| 481 | :ok | |
| 482 | end | |
| 483 | ||
| 484 | defp log_materialization_error(raw, reason) do | |
| 485 | 5 | Logger.error( |
| 486 | 5 | "snippy: #{inspect(raw.prefix)}/#{raw.key}: #{Discovery.format_error(reason)}; dropping" |
| 487 | ) | |
| 488 | end | |
| 489 | ||
| 490 | defp maybe_record_reload_interval(state, opts) do | |
| 491 | 26 | case opts[:reload_interval_ms] do |
| 492 | 2 | ms when is_integer(ms) and ms > 0 -> %{state | reload_interval_ms: ms} |
| 493 | 24 | _ -> state |
| 494 | end | |
| 495 | end | |
| 496 | ||
| 497 | 25 | defp schedule_reload_if_needed(%{reload_interval_ms: nil} = state), do: state |
| 498 | ||
| 499 | defp schedule_reload_if_needed(%{reload_interval_ms: ms, seq: seq} = state) | |
| 500 | when is_integer(ms) and ms > 0 do | |
| 501 | 9 | if state.reload_timer, do: Process.cancel_timer(state.reload_timer) |
| 502 | 9 | timer = Process.send_after(self(), {:scheduled_reload, seq}, ms) |
| 503 | 9 | %{state | reload_timer: timer} |
| 504 | end | |
| 505 | ||
| 506 | defp scan_opts_from_state(_state) do | |
| 507 | # Scheduled reloads always re-scan from real env (or the last persisted | |
| 508 | # opts in scan_meta). | |
| 509 | 6 | case current_scan() do |
| 510 | 5 | {:ok, %{scan_opts: opts}} -> opts |
| 511 | 1 | _ -> [] |
| 512 | end | |
| 513 | end | |
| 514 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Snippy.TableOwner do | |
| 1 | @moduledoc false | |
| 2 | # GenServer whose only job is to own the named ETS table backing all | |
| 3 | # discovered certificate state. By making it a separate process under | |
| 4 | # `:one_for_all` supervision, if this process dies the whole tree restarts | |
| 5 | # (we'll just reload from env). | |
| 6 | ||
| 7 | use GenServer | |
| 8 | require Logger | |
| 9 | ||
| 10 | @table :snippy_certs | |
| 11 | ||
| 12 | 1 | def table_name, do: @table |
| 13 | ||
| 14 | def start_link(_opts \\ []) do | |
| 15 | 1 | GenServer.start_link(__MODULE__, [], name: __MODULE__) |
| 16 | end | |
| 17 | ||
| 18 | @doc false | |
| 19 | def __test_hide_table__ do | |
| 20 | 5 | GenServer.call(__MODULE__, :__test_hide_table__) |
| 21 | end | |
| 22 | ||
| 23 | @doc false | |
| 24 | def __test_restore_table__ do | |
| 25 | 5 | GenServer.call(__MODULE__, :__test_restore_table__) |
| 26 | end | |
| 27 | ||
| 28 | @impl true | |
| 29 | def init([]) do | |
| 30 | 1 | _ = |
| 31 | :ets.new(@table, [ | |
| 32 | :named_table, | |
| 33 | :public, | |
| 34 | :bag, | |
| 35 | read_concurrency: true | |
| 36 | ]) | |
| 37 | ||
| 38 | 1 | Logger.debug("snippy: created ETS table #{inspect(@table)}") |
| 39 | {:ok, %{table: @table, hidden_tid: nil}} | |
| 40 | end | |
| 41 | ||
| 42 | @impl true | |
| 43 | def handle_call(:__test_hide_table__, _from, state) do | |
| 44 | 5 | tid = :ets.rename(@table, :snippy_certs_hidden) |
| 45 | 5 | {:reply, :ok, %{state | hidden_tid: tid}} |
| 46 | end | |
| 47 | ||
| 48 | @impl true | |
| 49 | def handle_call(:__test_restore_table__, _from, state) do | |
| 50 | 5 | :ets.rename(:snippy_certs_hidden, @table) |
| 51 | 5 | {:reply, :ok, %{state | hidden_tid: nil}} |
| 52 | end | |
| 53 | end |
| Line | Hits | Source |
|---|---|---|
| 0 | defmodule Snippy.Wildcard do | |
| 1 | @moduledoc false | |
| 2 | ||
| 3 | # Hostname normalization and wildcard matching. | |
| 4 | # | |
| 5 | # We use :domainname to parse "real" domain names (which it lowercases and | |
| 6 | # validates). For patterns with a leading "*" label, we strip the "*" and | |
| 7 | # parse the rest, since :domainname rejects "*" as a label character. | |
| 8 | # | |
| 9 | # :domainname does not (yet) handle IDN; for inputs containing non-ASCII | |
| 10 | # bytes we fall back to a simple lowercase + dot-split. | |
| 11 | ||
| 12 | @doc """ | |
| 13 | Parses a host or pattern into a normalized list of labels. | |
| 14 | ||
| 15 | Returns either: | |
| 16 | - `{:exact, [label, ...]}` for a non-wildcard pattern/host | |
| 17 | - `{:wild, [label, ...]}` for a pattern with a leading `*` label | |
| 18 | (the `*` is stripped from the returned labels list) | |
| 19 | """ | |
| 20 | def parse(input) when is_binary(input) do | |
| 21 | 1889 | trimmed = String.trim_trailing(input, ".") |
| 22 | ||
| 23 | 1889 | case trimmed do |
| 24 | 591 | "*." <> rest -> |
| 25 | {:wild, labels(rest)} | |
| 26 | ||
| 27 | 5 | "*" -> |
| 28 | {:wild, []} | |
| 29 | ||
| 30 | 1293 | _ -> |
| 31 | {:exact, labels(trimmed)} | |
| 32 | end | |
| 33 | end | |
| 34 | ||
| 35 | def parse(input) when is_list(input) do | |
| 36 | 2 | input |> List.to_string() |> parse() |
| 37 | end | |
| 38 | ||
| 39 | 2 | defp labels(""), do: [] |
| 40 | ||
| 41 | defp labels(name) do | |
| 42 | 1882 | case DomainName.new(name) do |
| 43 | 1881 | {:ok, d} -> DomainName.labels(d) |
| 44 | 1 | {:error, _} -> name |> String.downcase() |> String.split(".") |
| 45 | end | |
| 46 | end | |
| 47 | ||
| 48 | @doc """ | |
| 49 | Returns the canonical lowercase, dot-trimmed string form of a host. | |
| 50 | """ | |
| 51 | def normalize(input) do | |
| 52 | 466 | case parse(input) do |
| 53 | 311 | {:exact, ls} -> Enum.join(ls, ".") |
| 54 | 155 | {:wild, ls} -> Enum.join(["*" | ls], ".") |
| 55 | end | |
| 56 | end | |
| 57 | ||
| 58 | @doc """ | |
| 59 | Returns true if `pattern` matches `host`. | |
| 60 | ||
| 61 | Pattern may include a single leading `*` label which matches exactly one | |
| 62 | label in the host. No mid-label or multi-label wildcards. | |
| 63 | """ | |
| 64 | def match?(pattern, host) do | |
| 65 | 546 | case parse(host) do |
| 66 | # Wildcards on the client/host side don't really make sense; treat | |
| 67 | # as no match. | |
| 68 | 6 | {:wild, _} -> false |
| 69 | 540 | {:exact, host_labels} -> match_pattern?(parse(pattern), host_labels) |
| 70 | end | |
| 71 | end | |
| 72 | ||
| 73 | 233 | defp match_pattern?({:exact, pat_labels}, host_labels), do: pat_labels == host_labels |
| 74 | 1 | defp match_pattern?({:wild, _pat_labels}, []), do: false |
| 75 | 306 | defp match_pattern?({:wild, pat_labels}, [_first | rest]), do: rest == pat_labels |
| 76 | ||
| 77 | @doc """ | |
| 78 | Returns true if `pattern` is a wildcard pattern. | |
| 79 | """ | |
| 80 | def wildcard?(pattern) do | |
| 81 | 3 | case parse(pattern) do |
| 82 | 2 | {:wild, _} -> true |
| 83 | 1 | _ -> false |
| 84 | end | |
| 85 | end | |
| 86 | ||
| 87 | @doc """ | |
| 88 | Returns the number of labels in a host (or pattern, including the wildcard). | |
| 89 | """ | |
| 90 | def label_count(input) do | |
| 91 | 104 | case parse(input) do |
| 92 | 52 | {:exact, ls} -> length(ls) |
| 93 | 52 | {:wild, ls} -> length(ls) + 1 |
| 94 | end | |
| 95 | end | |
| 96 | ||
| 97 | @doc """ | |
| 98 | Returns the labels-only representation, including the leading `*` for wild. | |
| 99 | """ | |
| 100 | def labels_with_wild(input) do | |
| 101 | 3 | case parse(input) do |
| 102 | 1 | {:exact, ls} -> ls |
| 103 | 2 | {:wild, ls} -> ["*" | ls] |
| 104 | end | |
| 105 | end | |
| 106 | end |