Coverage

99.5
640
185191
3

lib/snippy.ex

100.0
35
355
0
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

lib/snippy/application.ex

100.0
4
4
0
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

lib/snippy/decoder.ex

100.0
165
45907
0
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

lib/snippy/discovery.ex

99.4
181
121574
1
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

lib/snippy/lookup.ex

100.0
60
1327
0
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

lib/snippy/otp_check.ex

66.6
3
248
1
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

lib/snippy/store.ex

99.3
154
3523
1
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

lib/snippy/table_owner.ex

100.0
10
34
0
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

lib/snippy/wildcard.ex

100.0
28
12219
0
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