Coverage

100.0
694
236858
0

lib/snippy.ex

100.0
37
638
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 49 Snippy.OTPCheck.check!()
92 49 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 46 groups = resolve_groups(opts)
139 46 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 9 {snippy_opts, transport_opts} = split_snippy_opts(opts)
219 9 adapter = Keyword.get(snippy_opts, :adapter, :cowboy)
220 9 ssl = ssl_opts(snippy_opts)
221
222 9 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 6 Keyword.merge(transport_opts, ssl)
232 end
233 end
234
235 # --------------------------------------------------------- Internals ---
236
237 @snippy_opt_keys [
238 :adapter,
239 :log_level,
240 :prefix,
241 :case_sensitive,
242 :env,
243 :reload_interval_ms,
244 :default_hostname,
245 :expiry_grace_seconds,
246 :public_ca_validation,
247 :only,
248 :keys,
249 :discovered_certs
250 ]
251
252 defp split_snippy_opts(opts) do
253 9 Keyword.split(opts, @snippy_opt_keys)
254 end
255
256 defp resolve_groups(opts) do
257 49 {groups, prefixes} =
258 cond do
259 Keyword.has_key?(opts, :discovered_certs) ->
260 8 %Discovery{groups: groups} = Keyword.fetch!(opts, :discovered_certs)
261 8 hydrated = Lookup.hydrate_groups(groups)
262 8 pfx = hydrated |> Enum.map(& &1.prefix) |> Enum.uniq()
263 {hydrated, pfx}
264
265 41 Keyword.has_key?(opts, :env) ->
266 18 {:ok, %Discovery{groups: groups}} = Store.discover(opts)
267 18 pfx = Discovery.normalize_prefixes!(opts[:prefix])
268 {Lookup.hydrate_groups(groups), pfx}
269
270 23 true ->
271 23 prefixes = Discovery.normalize_prefixes!(opts[:prefix])
272 {Store.lookup_groups(prefixes, scan_and_lookup_opts(opts)), prefixes}
273 end
274
275 49 Snippy.Logging.log_discovery(groups, prefixes, opts)
276 49 groups
277 end
278
279 defp scan_and_lookup_opts(opts) do
280 23 Keyword.take(opts, [
281 :case_sensitive,
282 :env,
283 :reload_interval_ms,
284 :expiry_grace_seconds,
285 :public_ca_validation
286 ])
287 end
288
289 defp lookup_opts(opts) do
290 49 Keyword.take(opts, [:only, :keys, :default_hostname])
291 end
292 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
56118
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 393 case :public_key.pem_decode(pem_string) do
9 7 [] -> {:error, :invalid_pem}
10 386 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 155 with {:ok, entries} <- decode_pem(pem_string) do
25 149 ders = for {:Certificate, der, :not_encrypted} <- entries, do: der
26
27 149 case ders do
28 1 [] -> {:error, :no_certificates_found}
29 148 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 133 with {:ok, entries} <- decode_pem(pem_string) do
50 133 case find_key_entry(entries) do
51 1 nil -> {:error, :no_key_found}
52 118 {_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 139 Enum.find(entries, fn
67 2 {:RSAPrivateKey, _, _} -> true
68 5 {:ECPrivateKey, _, _} -> true
69 1 {:DSAPrivateKey, _, _} -> true
70 128 {:PrivateKeyInfo, _, _} -> true
71 1 {:EncryptedPrivateKeyInfo, _, _} -> true
72 3 _ -> false
73 end)
74 end
75
76 118 defp decode_unencrypted_key({_type, _der, :not_encrypted} = entry) do
77 118 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 117 asn1_type = record_tag(record)
118
119 117 %{
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 126 do: :erlang.element(1, record)
140
141 # ------------------------------------------------- Key type classification --
142
143 238 def key_type(%{record: record}), do: record_to_type(record)
144 2 def key_type(_), do: :other
145
146 @doc false
147 222 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 456 :public_key.pkix_decode_cert(der, :otp)
180 end
181
182 455 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 219 do: validity
189
190 defp subject_of(
191 {:OTPTBSCertificate, _v, _serial, _sig, _issuer, _val, subject, _spki, _iuid, _suid,
192 _exts}
193 ),
194 123 do: subject
195
196 defp spki_of(
197 {:OTPTBSCertificate, _v, _serial, _sig, _issuer, _val, _subj, spki, _iuid, _suid, _exts}
198 ),
199 113 do: spki
200
201 defp extensions_of(
202 {:OTPTBSCertificate, _v, _serial, _sig, _issuer, _val, _subj, _spki, _iuid, _suid, exts}
203 ),
204 123 do: exts
205
206 # ---------------------------------------------------------- Cert validity --
207
208 def cert_validity(der) when is_binary(der) do
209 219 {: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 440 <<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 440 yyyy =
227 case String.to_integer(yy) do
228 1 y when y >= 50 -> 1900 + y
229 439 y -> 2000 + y
230 end
231
232 440 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 441 {: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 441 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 123 tbs = der |> decode_otp_cert() |> tbs_of()
260 123 cn = subject_cn(subject_of(tbs))
261 123 sans = san_dns_names(extensions_of(tbs))
262
263 sans
264 |> Kernel.++(List.wrap(cn))
265 245 |> Enum.map(&to_string/1)
266 123 |> Enum.uniq()
267 end
268
269 @doc false
270 def subject_cn({:rdnSequence, rdn_lists}) do
271 125 Enum.find_value(rdn_lists, fn attrs ->
272 131 Enum.find_value(attrs, fn
273 125 {: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 126 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 125 Enum.find_value(extensions, [], fn
295 {:Extension, {2, 5, 29, 17}, _critical, names} when is_list(names) ->
296 123 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 123 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 109 {:Certificate, tbs, _sig_alg, _sig} = :public_key.pkix_decode_cert(cert_der, :plain)
314 109 spki = elem_plain_spki(tbs)
315 109 der = :public_key.der_encode(:SubjectPublicKeyInfo, spki)
316 109 :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 109 do: spki
323
324 108 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 45 |> 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 114 def cert_key_match?(cert_der, %{} = key) do
342 114 cert_pub = cert_public_key(cert_der)
343 113 {digest, signing_record} = digest_and_signer(key)
344 113 sig = :public_key.sign(@match_message, digest, signing_record)
345 113 :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 115 case key_type(key) do
353 1 :eddsa -> {:none, record}
354 114 _ -> {: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 116 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 114 spki = cert_der |> decode_otp_cert() |> tbs_of() |> spki_of()
374 113 spki_to_public(spki)
375 end
376
377 @doc false
378 112 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 102 if Code.ensure_loaded?(CAStore) and function_exported?(CAStore, :file_path, 0) do
421 102 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 102 with {:ok, pem} <- File.read(CAStore.file_path()),
429 101 {:ok, ca_entries} <- decode_pem(pem) do
430 101 ca_ders = for {:Certificate, der, :not_encrypted} <- ca_entries, do: der
431 101 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 104 chain = intermediates ++ [leaf_der]
440
441 104 Enum.reduce_while(ca_ders, {:error, :no_match}, fn root_der, _acc ->
442 14649 try do
443 14649 case :public_key.pkix_path_validation(root_der, chain, []) do
444 1 {:ok, _} -> {:halt, :ok}
445 14357 {: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

100.0
181
158696
0
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 2953 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 469 env = opts[:env] || System.get_env()
89 469 case_sensitive = Keyword.get(opts, :case_sensitive, true)
90
91 469 Enum.flat_map(env, fn {var, val} ->
92 6824 candidate = if case_sensitive, do: var, else: String.upcase(var)
93
94 6824 case match_suffix_only(candidate) do
95 2703 {:ok, suffix, slot, kind} ->
96 [%{var: var, suffix: suffix, slot: slot, kind: kind, val: val}]
97
98 4121 :no_match ->
99 []
100 end
101 end)
102 end
103
104 defp match_suffix_only(var) do
105 6824 Enum.find_value(@suffixes_sorted, :no_match, fn {suffix, slot, kind} ->
106 101134 if String.ends_with?(var, suffix) and byte_size(var) > byte_size(suffix) do
107 2703 {: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 263 Enum.flat_map(entries, fn entry ->
124 1307 var_search = if case_sensitive, do: entry.var, else: String.upcase(entry.var)
125
126 Enum.find_value(prefixes, [], fn prefix ->
127 1307 peel_prefix(entry, var_search, prefix)
128 end)
129 1307 |> List.wrap()
130 end)
131 end
132
133 defp peel_prefix(entry, var_search, prefix) do
134 1307 suffix = entry.suffix
135
136 1307 cond do
137 prefix == "" ->
138 696 peel_no_prefix(entry, var_search, suffix)
139
140 342 String.starts_with?(var_search, prefix <> "_") and
141 611 String.ends_with?(var_search, suffix) ->
142 269 peel_with_prefix(entry, var_search, prefix, suffix)
143
144 342 true ->
145 nil
146 end
147 end
148
149 defp peel_no_prefix(entry, var_search, suffix) do
150 696 body_len = byte_size(var_search) - byte_size(suffix)
151
152 696 with true <- body_len > 0,
153 696 key = binary_part(var_search, 0, body_len) |> String.trim_leading("_"),
154 696 true <- key != "" do
155 696 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 269 body_start = byte_size(prefix) + 1
163 269 body_len = byte_size(var_search) - body_start - byte_size(suffix)
164
165 269 with true <- body_len > 0,
166 269 key = binary_part(var_search, body_start, body_len),
167 269 true <- key != "" do
168 269 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 269 |> Enum.group_by(fn e -> {e.prefix, e.key} end)
184 113 |> Enum.map(fn {{prefix, key}, group_entries} ->
185 126 build_raw_group(prefix, key, group_entries)
186 end)
187 end
188
189 defp build_raw_group(prefix, key, entries) do
190 126 password = extract_password!(entries, prefix, key)
191 125 cert = Enum.find(entries, &(&1.slot == :cert))
192 125 key_var = Enum.find(entries, &(&1.slot == :key))
193 125 ca = Enum.find(entries, &(&1.slot == :ca))
194
195 125 %{
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 126 pw_entries = Enum.filter(entries, &(&1.slot == :password))
207
208 126 case pw_entries do
209 118 [] ->
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 125 grace = Keyword.get(opts, :expiry_grace_seconds, 0)
240 125 public_ca = Keyword.get(opts, :public_ca_validation, :auto)
241
242 125 if public_ca == :always and not castore_available?() do
243 {:error, :castore_required_for_always_validation}
244 else
245 125 with {:ok, prepared} <- materialize_prepare(raw_group) do
246 120 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 121 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 119 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 101 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 122 case resolve_password(g.password) do
327 {:ok, password_str} ->
328 120 cert = g.cert
329 120 key_var = g.key_var
330
331 120 prepared =
332 Map.merge(g, %{
333 120 cert_kind: cert.kind,
334 120 cert_var: cert.var,
335 120 cert_val: cert.val,
336 120 key_kind: key_var.kind,
337 120 key_var_name: key_var.var,
338 120 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 113 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 120 label = "#{inspect(g.prefix)}/#{g.key}"
364
365 120 with {:ok, cert_ders} <- load_cert_chain(g),
366 113 {:ok, key} <- load_key(g),
367 110 :ok <- check_match(cert_ders, key, label),
368 110 :ok <- check_validity(cert_ders, label, grace),
369 108 {:ok, ca_ders} <- load_ca_chain(g),
370 108 {chain_status, chain_reason} <-
371 validate_chain_or_castore(hd(cert_ders), ca_ders, public_ca, label) do
372 108 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 116 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 103 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 110 if Decoder.cert_key_match?(leaf, key) do
401 :ok
402 else
403 {:error, :cert_key_mismatch}
404 end
405 end
406
407 110 defp check_validity([leaf | _], _label, grace) do
408 110 {not_before, not_after} = Decoder.cert_validity(leaf)
409 109 now = DateTime.utc_now()
410 109 grace_dt_after = DateTime.add(not_after, grace, :second)
411
412 109 cond do
413 1 DateTime.compare(now, not_before) == :lt ->
414 {:error, {:not_yet_valid, not_before}}
415
416 108 DateTime.compare(now, grace_dt_after) == :gt ->
417 {:error, {:expired, not_after}}
418
419 107 true ->
420 :ok
421 end
422 rescue
423 1 ArgumentError -> :ok
424 end
425
426 100 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 100 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 101 cond do
456 mode == :never ->
457 1 log_self_signed(label)
458 {:ok_self, nil}
459
460 100 castore_available?() ->
461 99 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 99 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 97 Logger.warning(
480 97 "snippy: #{label}: public CA validation failed: #{inspect(reason)}; accepting"
481 )
482
483 97 log_self_signed(label)
484 {:ok_self, reason}
485 end
486 end
487
488 defp log_self_signed(label) do
489 99 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 107 [leaf | _] = cert_ders
502 107 {not_before, not_after} = Decoder.cert_validity(leaf)
503 107 hostnames = Decoder.cert_hostnames(leaf)
504 107 payload = build_ssl_payload(cert_ders, key, ca_ders, g)
505
506 107 group =
507 struct!(Group,
508 107 prefix: g.prefix,
509 107 key: g.key,
510 hostnames: hostnames,
511 107 has_password?: g.password != nil,
512 has_ca_chain?: ca_ders != [],
513 107 cert_source: g.cert_kind,
514 107 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 107 full_chain = cert_ders ++ ca_ders
530
531 107 base =
532 107 if g.cert_kind == :file and ca_ders == [] do
533 3 %{certfile: g.cert_val}
534 else
535 104 %{cert: full_chain}
536 end
537
538 107 base =
539 107 if g.key_kind == :file and ca_ders == [] do
540 7 Map.put(base, :keyfile, g.key_val)
541 else
542 100 Map.put(base, :key, ssl_key_form(key))
543 end
544
545 107 case g.password_str do
546 101 nil -> base
547 6 pw -> Map.put(base, :password, pw)
548 end
549 end
550
551 100 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/logging.ex

100.0
48
1688
0
Line Hits Source
0 defmodule Snippy.Logging do
1 @moduledoc false
2
3 require Logger
4
5 alias Snippy.Decoder
6 alias Snippy.Discovery.Group
7
8 @table Snippy.TableOwner.table_name()
9
10 @doc """
11 Resolve the effective log level from opts and application env.
12
13 Returns a Logger level atom or `false` to disable.
14 """
15 def level(opts) do
16 49 case Keyword.get(opts, :log_level) do
17 44 nil -> Application.get_env(:snippy, :log_level, :debug)
18 1 :none -> false
19 3 false -> false
20 1 level -> level
21 end
22 end
23
24 @doc """
25 Log discovery results if the level is enabled and groups have changed.
26 """
27 def log_discovery(groups, prefixes, opts) do
28 49 case level(opts) do
29 4 false -> :ok
30 45 level -> maybe_log(groups, prefixes, level)
31 end
32 end
33
34 defp maybe_log(groups, prefixes, level) do
35 45 fp = fingerprint(groups)
36 45 prefixes_sorted = Enum.sort(prefixes)
37 45 cache_key = {fp, prefixes_sorted}
38
39 45 if suppressed?(cache_key) do
40 :ok
41 else
42 38 emit(groups, prefixes, level)
43 38 store_fingerprint(cache_key)
44 end
45 end
46
47 defp suppressed?(cache_key) do
48 45 case :ets.whereis(@table) do
49 4 :undefined ->
50 false
51
52 _tid ->
53 41 case :ets.lookup(@table, :last_logged_fingerprint) do
54 7 [{_, ^cache_key}] -> true
55 34 _ -> false
56 end
57 end
58 end
59
60 defp store_fingerprint(cache_key) do
61 38 case :ets.whereis(@table) do
62 4 :undefined -> :ok
63 34 _tid -> :ets.insert(@table, {:last_logged_fingerprint, cache_key})
64 end
65 end
66
67 defp emit(groups, prefixes, level) do
68 38 announcement = announcement_line(groups, prefixes)
69 38 Logger.log(level, announcement)
70
71 38 Enum.each(groups, fn g ->
72 43 Logger.log(level, group_line(g))
73 end)
74 end
75
76 defp announcement_line(groups, []) do
77 1 "snippy: building config from supplied discovery -> #{group_count(groups)}"
78 end
79
80 defp announcement_line(groups, prefixes) do
81 37 "snippy: discovering certificates for prefix(es) #{inspect(prefixes)} -> #{group_count(groups)}"
82 end
83
84 3 defp group_count([]), do: "no groups"
85 35 defp group_count(groups), do: "#{length(groups)} group(s)"
86
87 defp group_line(%Group{} = g) do
88 43 hosts = Enum.join(g.hostnames, ",")
89 43 spki = if g.spki_fingerprint, do: Decoder.fingerprint_hex(g.spki_fingerprint), else: "n/a"
90 43 ca = if g.has_ca_chain?, do: "present", else: "absent"
91 43 pwd = if g.has_password?, do: "present", else: "absent"
92
93 43 "snippy: group #{g.prefix}/#{g.key}" <>
94 43 " hosts=[#{hosts}]" <>
95 43 " key_type=#{g.key_type}" <>
96 43 " not_before=#{g.not_before}" <>
97 43 " not_after=#{g.not_after}" <>
98 43 " spki=#{spki}" <>
99 43 " ca_chain=#{ca}" <>
100 43 " password=#{pwd}" <>
101 43 " chain=#{g.chain_validation}" <>
102 43 " cert_source=#{g.cert_source}" <>
103 43 " key_source=#{g.key_source}"
104 end
105
106 @doc """
107 Compute a stable fingerprint over a list of groups for change detection.
108 """
109 def fingerprint(groups) do
110 groups
111 |> Enum.map(fn g ->
112 53 {g.prefix, g.key, g.spki_fingerprint, g.key_fingerprint, Enum.sort(g.hostnames || []),
113 53 g.not_before, g.not_after, g.has_ca_chain?, g.has_password?, g.chain_validation}
114 end)
115 |> Enum.sort()
116 45 |> :erlang.phash2()
117 end
118 end

lib/snippy/lookup.ex

100.0
60
1740
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 29 Enum.flat_map(groups, fn
31 36 %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 49 scope = build_scope(groups, opts)
51 49 fallback = fallback_entries(groups, scope)
52 49 scoped_groups = scoped(groups, scope)
53
54 49 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 46 scope = build_scope(groups, opts)
71 46 fallback = fallback_entries(groups, scope)
72 46 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 95 %{
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 89 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 114 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 6 defp fallback_entries([], _scope), do: []
162
163 defp fallback_entries(groups, %{default_hostname: nil} = scope) do
164 81 case scoped(groups, scope) do
165 2 [] -> [hd(groups)]
166 79 [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 75 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

100.0
3
255
0
Line Hits Source
0 defmodule Snippy.OTPCheck do
1 @moduledoc false
2
3 alias Snippy.OtpInfo
4
5 @min_otp 25
6
7 @otp_release OtpInfo.release()
8
9 if @otp_release < @min_otp do
10 raise CompileError,
11 description: "Snippy requires OTP >= #{@min_otp}, got #{@otp_release}"
12 end
13
14 def check! do
15 127 otp = OtpInfo.release()
16
17 127 if otp < @min_otp do
18 1 raise "Snippy requires OTP >= #{@min_otp}, got #{otp}"
19 end
20
21 :ok
22 end
23 end

lib/snippy/otp_info.ex

100.0
1
126
0
Line Hits Source
0 defmodule Snippy.OtpInfo do
1 @moduledoc false
2
3 # Tiny indirection over :erlang.system_info(:otp_release) so the OTP
4 # version check is unit-testable via `rewire` without touching the
5 # Erlang-level :erlang module.
6
7 @spec release() :: integer()
8 def release do
9 126 :erlang.system_info(:otp_release) |> List.to_integer()
10 end
11 end

lib/snippy/store.ex

100.0
157
5253
0
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 52 case current_scan() do
54 11 {:ok, _meta} ->
55 :ok
56
57 :missing ->
58 41 synchronous_scan(opts)
59 end
60 end
61
62 defp synchronous_scan(opts) do
63 41 case GenServer.call(__MODULE__, {:scan, opts}, scan_call_timeout()) do
64 38 :ok -> :ok
65 2 {:error, reason} -> raise ScanError, reason: reason
66 end
67 end
68
69 defp scan_call_timeout do
70 48 Application.get_env(:snippy, :scan_timeout_ms, @scan_timeout_ms) + 5_000
71 end
72
73 defp infrastructure_available? do
74 46 :ets.whereis(@table) != :undefined
75 end
76
77 defp current_scan do
78 99 case :ets.whereis(@table) do
79 1 :undefined ->
80 :missing
81
82 _tid ->
83 98 case :ets.lookup(@table, :scan_meta) do
84 19 [{:scan_meta, meta}] -> {:ok, meta}
85 79 [] -> :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 38 if infrastructure_available?() do
97 32 shared_lookup_groups(prefixes, opts)
98 else
99 6 local_lookup_groups(prefixes, opts)
100 end
101 end
102
103 defp shared_lookup_groups(prefixes, opts) do
104 32 :ok = ensure_scanned(opts)
105
106 32 case_sensitive = Keyword.get(opts, :case_sensitive, true)
107 32 raw_groups = collect_raw_groups(prefixes, case_sensitive)
108
109 32 Enum.flat_map(raw_groups, fn raw ->
110 33 case fetch_or_materialize(raw, opts) do
111 29 {:ok, group} -> [group]
112 4 {:error, _reason} -> []
113 end
114 end)
115 end
116
117 defp local_lookup_groups(prefixes, opts) do
118 6 case_sensitive = Keyword.get(opts, :case_sensitive, true)
119
120 6 raw_groups =
121 opts
122 |> Discovery.scan_all()
123 |> Discovery.filter_by_prefixes(prefixes, case_sensitive)
124 |> Discovery.group_entries()
125
126 6 Enum.flat_map(raw_groups, fn raw ->
127 7 case Discovery.materialize_group(raw, opts) do
128 6 {:ok, group} -> [group]
129 1 {:error, _reason} -> []
130 end
131 end)
132 end
133
134 defp collect_raw_groups(prefixes, case_sensitive) do
135 39 entries = scan_rows()
136
137 entries
138 |> Discovery.filter_by_prefixes(prefixes, case_sensitive)
139 39 |> Discovery.group_entries()
140 end
141
142 defp scan_rows do
143 :ets.tab2list(@table)
144 39 |> Enum.flat_map(fn
145 84 {{:scan, _seq, _n}, payload} -> [payload]
146 57 _ -> []
147 end)
148 end
149
150 @doc """
151 Re-scan and clear all materialized + index rows.
152 """
153 def reload(opts \\ []) do
154 7 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 123 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 76 Snippy.OTPCheck.check!()
181 76 prefixes = Discovery.normalize_prefixes!(opts[:prefix])
182
183 72 cond do
184 64 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 65 case_sensitive = Keyword.get(opts, :case_sensitive, true)
222 65 entries = Discovery.scan_all(opts)
223
224 65 raw_groups =
225 entries
226 |> Discovery.filter_by_prefixes(prefixes, case_sensitive)
227 |> Discovery.group_entries()
228
229 64 {groups, errors} =
230 Enum.reduce(raw_groups, {[], []}, fn raw, {gs, es} ->
231 75 case Discovery.materialize_group(raw, opts) do
232 67 {: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 64 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 40 key = {:materialized, raw.prefix, raw.key}
273
274 40 case :ets.lookup(@table, key) do
275 [{_, cached}] ->
276 5 cached
277
278 [] ->
279 35 GenServer.call(
280 __MODULE__,
281 {:materialize, raw, opts},
282 materialize_call_timeout()
283 )
284
285 35 [{_, cached}] = :ets.lookup(@table, key)
286 35 cached
287 end
288 end
289
290 defp materialize_call_timeout do
291 35 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 40 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 38 case do_scan(opts) do
315 {:ok, new_seq} ->
316 36 state = %{state | seq: new_seq}
317 36 state = maybe_record_reload_interval(state, opts)
318 36 {: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 7 case do_scan(opts) do
329 {:ok, new_seq} ->
330 5 state = %{state | seq: new_seq}
331 5 state = maybe_record_reload_interval(state, opts)
332 5 {: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 123 if state.reload_timer, do: Process.cancel_timer(state.reload_timer)
343
344 123 try do
345 123 :ets.match_delete(@table, {{:scan, :_, :_}, :_})
346 122 :ets.match_delete(@table, {{:materialized, :_, :_}, :_})
347 122 :ets.match_delete(@table, {{:exact, :_, :_, :_}, :_})
348 122 :ets.match_delete(@table, {{:wild, :_, :_, :_}, :_})
349 122 :ets.delete(@table, :scan_meta)
350 122 :ets.delete(@table, :last_logged_fingerprint)
351 rescue
352 # Table absent (e.g. during shutdown, or hidden by tests). The
353 # purpose of __test_reset__ is to start from a clean slate, so a
354 # missing table is already the desired end-state.
355 1 ArgumentError -> :ok
356 end
357
358 123 {:reply, :ok, %{state | seq: 0, reload_interval_ms: nil, reload_timer: nil}}
359 end
360
361 @impl true
362 def handle_call({:materialize, raw, opts}, _from, state) do
363 37 key = {:materialized, raw.prefix, raw.key}
364
365 37 case :ets.lookup(@table, key) do
366 [_] ->
367 1 {:reply, :ok, state}
368
369 [] ->
370 36 result = run_materialize(raw, opts)
371 36 :ets.insert(@table, {key, result})
372
373 36 case result do
374 31 {:ok, %Group{} = g} -> populate_host_index(g)
375 5 {:error, reason} -> log_materialization_error(raw, reason)
376 end
377
378 36 {:reply, :ok, state}
379 end
380 end
381
382 @impl true
383 def handle_cast({:set_reload_interval, ms}, state) do
384 2 state = %{state | reload_interval_ms: ms}
385 {:noreply, schedule_reload_if_needed(state)}
386 end
387
388 @impl true
389 def handle_info({:scheduled_reload, seq}, state) do
390 7 if seq == state.seq do
391 7 case do_scan(scan_opts_from_state(state)) do
392 4 {:ok, new_seq} ->
393 {:noreply, schedule_reload_if_needed(%{state | seq: new_seq})}
394
395 {:error, reason} ->
396 3 Logger.error("snippy: scheduled reload failed: #{inspect(reason)}")
397 {:noreply, schedule_reload_if_needed(state)}
398 end
399 else
400 {:noreply, state}
401 end
402 end
403
404 @impl true
405 def handle_info({ref, _result}, state) when is_reference(ref) do
406 1 Process.demonitor(ref, [:flush])
407 {:noreply, state}
408 end
409
410 @impl true
411 1 def handle_info({:DOWN, _ref, :process, _pid, _reason}, state) do
412 {:noreply, state}
413 end
414
415 # --------------------------------------------------------- internals ---
416
417 defp do_scan(opts) do
418 52 timeout = Application.get_env(:snippy, :scan_timeout_ms, @scan_timeout_ms)
419 52 scan_fn = Application.get_env(:snippy, :scan_fn, &Discovery.scan_all/1)
420
421 52 task =
422 Task.Supervisor.async_nolink(Snippy.TaskSupervisor, fn ->
423 52 scan_fn.(opts)
424 end)
425
426 52 case Task.yield(task, timeout) || Task.shutdown(task) do
427 {:ok, entries} ->
428 45 seq = System.unique_integer([:positive, :monotonic])
429 45 replace_scan(entries, seq, opts)
430 {:ok, seq}
431
432 1 nil ->
433 {:error, :scan_timeout}
434
435 6 {:exit, reason} ->
436 {:error, {:scan_crashed, reason}}
437 end
438 end
439
440 defp replace_scan(entries, seq, opts) do
441 # Drop everything dependent on the old scan.
442 45 :ets.match_delete(@table, {{:scan, :_, :_}, :_})
443 45 :ets.match_delete(@table, {{:materialized, :_, :_}, :_})
444 45 :ets.match_delete(@table, {{:exact, :_, :_, :_}, :_})
445 45 :ets.match_delete(@table, {{:wild, :_, :_, :_}, :_})
446 45 :ets.delete(@table, :scan_meta)
447
448 45 rows =
449 entries
450 |> Enum.with_index()
451 88 |> Enum.map(fn {entry, n} -> {{:scan, seq, n}, entry} end)
452
453 45 if rows != [] do
454 40 :ets.insert(@table, rows)
455 end
456
457 45 meta = %{
458 seq: seq,
459 scanned_at: System.monotonic_time(:millisecond),
460 scan_opts: Keyword.take(opts, [:case_sensitive, :env])
461 }
462
463 45 :ets.insert(@table, {:scan_meta, meta})
464 :ok
465 end
466
467 36 defp run_materialize(raw, opts) do
468 36 Discovery.materialize_group(raw, opts)
469 rescue
470 1 e ->
471 1 Logger.error("snippy: materialize_group raised: #{Exception.message(e)}")
472 {:error, {:materialize_exception, Exception.message(e)}}
473 end
474
475 defp populate_host_index(%Group{prefix: pfx, key: key, hostnames: hosts}) do
476 31 rows =
477 Enum.map(hosts, fn host ->
478 32 case Snippy.Wildcard.parse(host) do
479 31 {:exact, labels} ->
480 {{:exact, pfx, key, Enum.join(labels, ".")}, :present}
481
482 1 {:wild, labels} ->
483 {{:wild, pfx, key, labels}, :present}
484 end
485 end)
486
487 31 if rows != [] do
488 31 :ets.insert(@table, rows)
489 end
490
491 :ok
492 end
493
494 defp log_materialization_error(raw, reason) do
495 5 Logger.error(
496 5 "snippy: #{inspect(raw.prefix)}/#{raw.key}: #{Discovery.format_error(reason)}; dropping"
497 )
498 end
499
500 defp maybe_record_reload_interval(state, opts) do
501 41 case opts[:reload_interval_ms] do
502 2 ms when is_integer(ms) and ms > 0 -> %{state | reload_interval_ms: ms}
503 39 _ -> state
504 end
505 end
506
507 40 defp schedule_reload_if_needed(%{reload_interval_ms: nil} = state), do: state
508
509 defp schedule_reload_if_needed(%{reload_interval_ms: ms, seq: seq} = state)
510 when is_integer(ms) and ms > 0 do
511 10 if state.reload_timer, do: Process.cancel_timer(state.reload_timer)
512 10 timer = Process.send_after(self(), {:scheduled_reload, seq}, ms)
513 10 %{state | reload_timer: timer}
514 end
515
516 defp scan_opts_from_state(_state) do
517 # Scheduled reloads always re-scan from real env (or the last persisted
518 # opts in scan_meta).
519 7 case current_scan() do
520 6 {:ok, %{scan_opts: opts}} -> opts
521 1 _ -> []
522 end
523 end
524 end

lib/snippy/table_owner.ex

100.0
10
46
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 7 GenServer.call(__MODULE__, :__test_hide_table__)
21 end
22
23 @doc false
24 def __test_restore_table__ do
25 7 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 7 tid = :ets.rename(@table, :snippy_certs_hidden)
45 7 {:reply, :ok, %{state | hidden_tid: tid}}
46 end
47
48 @impl true
49 def handle_call(:__test_restore_table__, _from, state) do
50 7 :ets.rename(:snippy_certs_hidden, @table)
51 7 {:reply, :ok, %{state | hidden_tid: nil}}
52 end
53 end

lib/snippy/wildcard.ex

100.0
28
12294
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 1904 trimmed = String.trim_trailing(input, ".")
22
23 1904 case trimmed do
24 596 "*." <> rest ->
25 {:wild, labels(rest)}
26
27 5 "*" ->
28 {:wild, []}
29
30 1303 _ ->
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 1897 case DomainName.new(name) do
43 1896 {: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 308 {:exact, ls} -> Enum.join(ls, ".")
54 158 {: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 51 {:exact, ls} -> length(ls)
93 53 {: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