acme-proxy 0.5.0

An ACME (RFC 8555) server that issues from a local CA, relays to an upstream CA, or delegates to a script
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
//! The `ipam` filter: the client's address must own the names it asks for.
//!
//! The other identifier filter, [`identifiers`](super::identifiers), answers
//! "may *anyone here* have this name certified?" from a static list. This one
//! answers the narrower question the list cannot express: may **this address**
//! have **this name** certified? The answer is not configured — it is read from
//! the [`ipam`](crate::ipam) inventory, which in a managed estate already
//! records it.
//!
//! Everything about *how* the answer is obtained lives in that subsystem: which
//! product, which queries, which sources are trusted, and the budget the whole
//! lookup runs under. What is here is the policy built on the answer, and it is
//! the same policy whichever inventory produced it — which is the point of the
//! split, and why the 403 an operator reads names their own product rather than
//! the one this filter was first written for.
//!
//! ## Matching is exact
//!
//! Case-insensitive and ignoring a trailing dot, but otherwise literal: no
//! suffix rule, no wildcard expansion. An entry `example.com` does **not**
//! permit `a.example.com`, and a request for `*.example.com` requires that
//! exact string in the inventory. This is the same choice
//! [`compile_anchored`](super::compile_anchored) makes for the regex-based
//! filters, for the same reason — a rule that quietly covers more than it says
//! is the bypass an allowlist exists to prevent.
//!
//! An `ip` identifier is permitted when it *is* the connecting address (a
//! machine may always certify the address it is talking from) or when it is
//! listed like any other name. A `cn` is skipped — see
//! [`super::SUBJECT_ONLY_TYPES`]. Any other type is refused: an IPAM has
//! nothing to say about an email address or a URI, and a filter whose job is to
//! confirm entitlement must refuse what it cannot confirm.
//!
//! ## Denied versus Internal
//!
//! "The inventory does not associate this name with this address" is a decision
//! about the client and denies the request. "It answered 500", "the token was
//! refused", "the lookup timed out" are not — the server failed to reach a
//! decision, so they become [`Verdict::Undecided`] and surface as a 500 the
//! client can retry. The split is enforced by the types rather than by care
//! here: an [`IpamError`](crate::ipam::IpamError) cannot express a refusal, so
//! every one of them maps to `Internal` and there is no branch in which an
//! outage could fail open.

use std::net::IpAddr;
use std::sync::Arc;

use async_trait::async_trait;
use tracing::debug;

use super::policy::{Check, StageSet, Verdict};
use super::{IdentifierContext, SUBJECT_ONLY_TYPES, canonical};
use crate::ipam::{AddressNames, IpamRegistry, normalize};

/// Requires every requested name to be one the inventory associates with the
/// client's address.
#[derive(Debug)]
pub struct IpamFilter {
    ipam: Arc<IpamRegistry>,
}

impl IpamFilter {
    /// Wraps the profile's configured inventory.
    #[must_use]
    pub fn new(ipam: Arc<IpamRegistry>) -> Self {
        Self { ipam }
    }

    /// The names the inventory holds, or the refusal its answer implies.
    async fn permitted_names(&self, client_ip: IpAddr) -> Result<AddressNames, Verdict> {
        let names = self
            .ipam
            .names_for(client_ip)
            .await
            .map_err(|error| Verdict::Undecided(error.0))?;

        if !names.is_known() {
            return Err(Verdict::Fail(format!(
                "{} holds no record of {client_ip}",
                self.ipam.backend_name()
            )));
        }

        Ok(names)
    }
}

impl IpamFilter {
    async fn decide(&self, ctx: &IdentifierContext<'_>) -> Result<(), Verdict> {
        let client_ip = super::require_client_ip(ctx.client_ip)?;
        let stage = ctx.stage.as_str();
        let backend = self.ipam.backend_name();

        // A CSR carrying nothing but a common name asks the inventory no
        // question, so it is not worth a round trip. Same reasoning as
        // `FilterPolicy::has_rules_at`.
        if ctx.identifiers.iter().all(is_subject_only) {
            return Ok(());
        }

        let permitted = self.permitted_names(client_ip).await?;
        let names = permitted.names();

        for identifier in ctx.identifiers {
            if is_subject_only(identifier) {
                continue;
            }

            let typ = identifier.typ.to_ascii_lowercase();
            let value = normalize(&identifier.value);

            match typ.as_str() {
                "dns" => {
                    if !names.contains(&value) {
                        return Err(Verdict::Fail(format!(
                            "{stage} identifier {} is not among the names {backend} associates \
                             with {client_ip}",
                            identifier.value
                        )));
                    }
                }
                // A machine may always certify the address it is talking from;
                // any other address has to be listed like a name.
                "ip" => {
                    let is_client = value
                        .parse::<IpAddr>()
                        .is_ok_and(|ip| canonical(ip) == client_ip);
                    if !is_client && !names.contains(&value) {
                        return Err(Verdict::Fail(format!(
                            "{stage} identifier {} is neither {client_ip} nor a name {backend} \
                             associates with it",
                            identifier.value
                        )));
                    }
                }
                other => {
                    return Err(Verdict::Fail(format!(
                        "{stage} requests a {other} identifier, which {backend} cannot confirm \
                         for {client_ip}"
                    )));
                }
            }
        }

        debug!(
            event = "filter_ipam_accepted",
            outcome = "success",
            backend,
            client_ip = %client_ip,
            stage,
            identifiers = ctx.identifiers.len(),
        );
        Ok(())
    }
}

#[async_trait]
impl Check for IpamFilter {
    fn kind(&self) -> &'static str {
        "ipam"
    }

    /// Identifiers only, and not overridable: a connection hook would query the
    /// inventory on every `newNonce`.
    fn stages(&self) -> StageSet {
        StageSet::identifiers_only()
    }

    async fn check_identifiers(&self, context: &IdentifierContext<'_>) -> Verdict {
        self.decide(context).await.err().unwrap_or(Verdict::Pass)
    }
}

/// Whether this identifier is subject metadata this filter leaves alone.
fn is_subject_only(identifier: &crate::sqlite::order::Identifier) -> bool {
    SUBJECT_ONLY_TYPES.contains(&identifier.typ.to_ascii_lowercase().as_str())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::filter::{ConnectionContext, IdentifierStage};
    use crate::ipam::{Ipam, IpamError};
    use crate::sqlite::order::Identifier;
    use crate::testutil::identifiers as ids;
    use axum::http::Method;
    use std::sync::atomic::{AtomicUsize, Ordering};
    use std::time::Duration;

    /// An inventory answering from a canned set, never touching the network.
    struct StubIpam {
        names: Option<Vec<&'static str>>,
        error: Option<&'static str>,
        calls: AtomicUsize,
    }

    impl StubIpam {
        /// A recorded address owning `names`.
        fn owning(names: &[&'static str]) -> Self {
            Self {
                names: Some(names.to_vec()),
                error: None,
                calls: AtomicUsize::new(0),
            }
        }

        /// An address the inventory has never heard of.
        fn unknown() -> Self {
            Self {
                names: None,
                error: None,
                calls: AtomicUsize::new(0),
            }
        }

        fn failing(error: &'static str) -> Self {
            Self {
                names: None,
                error: Some(error),
                calls: AtomicUsize::new(0),
            }
        }
    }

    #[async_trait]
    impl Ipam for StubIpam {
        fn name(&self) -> &'static str {
            "StubIPAM"
        }

        async fn names_for(&self, _ip: IpAddr) -> Result<AddressNames, IpamError> {
            self.calls.fetch_add(1, Ordering::SeqCst);
            if let Some(error) = self.error {
                return Err(IpamError(error.to_string()));
            }
            match &self.names {
                None => Ok(AddressNames::Unknown),
                Some(names) => {
                    let mut answer = AddressNames::known();
                    for name in names {
                        answer.insert(name);
                    }
                    Ok(answer)
                }
            }
        }
    }

    fn filter_over(stub: Arc<StubIpam>) -> IpamFilter {
        IpamFilter::new(Arc::new(IpamRegistry::new(stub, Duration::from_secs(5))))
    }

    fn filter(stub: StubIpam) -> IpamFilter {
        filter_over(Arc::new(stub))
    }

    async fn check_from(
        filter: &IpamFilter,
        ip: Option<&str>,
        identifiers: &[Identifier],
    ) -> Verdict {
        filter
            .check_identifiers(&IdentifierContext {
                client_ip: ip.map(|ip| ip.parse().unwrap()),
                account_id: "acct-1",
                stage: IdentifierStage::NewOrder,
                identifiers,

                eab: None,
            })
            .await
    }

    async fn check(filter: &IpamFilter, identifiers: &[Identifier]) -> Verdict {
        check_from(filter, Some("10.0.0.5"), identifiers).await
    }

    fn assert_denied(verdict: Verdict, needle: &str) {
        match verdict {
            Verdict::Fail(detail) => {
                assert!(detail.contains(needle), "{detail:?} lacks {needle:?}");
            }
            other => panic!("expected Fail, got {other:?}"),
        }
    }

    fn assert_internal(verdict: Verdict, needle: &str) {
        match verdict {
            Verdict::Undecided(detail) => {
                assert!(detail.contains(needle), "{detail:?} lacks {needle:?}");
            }
            other => panic!("expected Undecided, got {other:?}"),
        }
    }

    // ------------------------------------------------------- the happy paths

    #[tokio::test]
    async fn a_listed_name_is_permitted() {
        let filter = filter(StubIpam::owning(&["host.example.com"]));

        assert_eq!(
            check(&filter, &ids(&[("dns", "host.example.com")])).await,
            Verdict::Pass
        );
    }

    #[tokio::test]
    async fn matching_ignores_case_and_a_trailing_dot() {
        let filter = filter(StubIpam::owning(&["host.example.com"]));

        assert_eq!(
            check(&filter, &ids(&[("dns", "HOST.example.com.")])).await,
            Verdict::Pass
        );
    }

    #[tokio::test]
    async fn every_requested_name_must_be_permitted() {
        let filter = filter(StubIpam::owning(&["a.example.com", "b.example.com"]));

        assert_eq!(
            check(
                &filter,
                &ids(&[("dns", "a.example.com"), ("dns", "b.example.com")]),
            )
            .await,
            Verdict::Pass
        );

        let error = check(
            &filter,
            &ids(&[("dns", "a.example.com"), ("dns", "c.example.com")]),
        )
        .await;
        assert_denied(error, "c.example.com");
    }

    #[tokio::test]
    async fn an_ipv4_mapped_client_is_canonicalized_before_the_lookup() {
        let filter = filter(StubIpam::owning(&["host.example.com"]));

        assert_eq!(
            check_from(
                &filter,
                Some("::ffff:10.0.0.5"),
                &ids(&[("dns", "host.example.com")]),
            )
            .await,
            Verdict::Pass
        );
    }

    // ------------------------------------------------------------- refusals

    /// The refusal names the identifier, the stage and the product, so an
    /// operator reading a 403 knows which inventory to go and edit.
    #[tokio::test]
    async fn an_unlisted_name_is_denied_naming_it_the_stage_and_the_backend() {
        let filter = filter(StubIpam::owning(&["host.example.com"]));

        let error = check(&filter, &ids(&[("dns", "evil.example.com")])).await;
        assert_denied(error, "newOrder identifier evil.example.com");

        let error = check(&filter, &ids(&[("dns", "evil.example.com")])).await;
        assert_denied(error, "StubIPAM associates with 10.0.0.5");
    }

    /// An address the inventory has never heard of is worded differently from
    /// one recorded and entitled to nothing — the reason `AddressNames` is an
    /// enum rather than a set that may be empty.
    #[tokio::test]
    async fn an_unrecorded_address_is_denied_saying_so() {
        let filter = filter(StubIpam::unknown());

        let error = check(&filter, &ids(&[("dns", "host.example.com")])).await;
        assert_denied(error, "StubIPAM holds no record of 10.0.0.5");
    }

    #[tokio::test]
    async fn a_recorded_address_owning_nothing_is_denied_per_name() {
        let filter = filter(StubIpam::owning(&[]));

        let error = check(&filter, &ids(&[("dns", "host.example.com")])).await;
        assert_denied(error, "is not among the names");
    }

    #[tokio::test]
    async fn a_missing_client_address_is_denied() {
        let filter = filter(StubIpam::owning(&["host.example.com"]));

        let error = check_from(&filter, None, &ids(&[("dns", "host.example.com")])).await;
        assert_denied(error, "client address unavailable");
    }

    // --------------------------------------------------- Internal, not Denied

    /// The property the whole `IpamError`/`AddressNames` split exists for: an
    /// outage must stop issuance with a retryable 500, never fail open and
    /// never look like a permanent refusal.
    #[tokio::test]
    async fn a_failed_lookup_is_internal_not_a_denial() {
        let filter = filter(StubIpam::failing("HTTP 500"));

        let error = check(&filter, &ids(&[("dns", "host.example.com")])).await;
        assert_internal(error, "HTTP 500");
    }

    /// The registry's budget surfaces here as an ordinary `Internal`.
    #[tokio::test]
    async fn a_wedged_inventory_times_out_rather_than_hanging() {
        struct Hanging;
        #[async_trait]
        impl Ipam for Hanging {
            fn name(&self) -> &'static str {
                "StubIPAM"
            }
            async fn names_for(&self, _ip: IpAddr) -> Result<AddressNames, IpamError> {
                tokio::time::sleep(Duration::from_secs(3600)).await;
                unreachable!("the registry's budget expires first")
            }
        }

        let filter = IpamFilter::new(Arc::new(IpamRegistry::new(
            Arc::new(Hanging),
            Duration::from_millis(10),
        )));

        let error = check(&filter, &ids(&[("dns", "host.example.com")])).await;
        assert_internal(error, "timed out after 10ms");
    }

    // -------------------------------------------------------------- wildcards

    #[tokio::test]
    async fn a_wildcard_needs_the_literal_entry() {
        let filter = filter(StubIpam::owning(&["example.com"]));

        let error = check(&filter, &ids(&[("dns", "*.example.com")])).await;
        assert_denied(error, "*.example.com");
    }

    #[tokio::test]
    async fn a_literal_wildcard_entry_permits_the_wildcard() {
        let filter = filter(StubIpam::owning(&["*.example.com"]));

        assert_eq!(
            check(&filter, &ids(&[("dns", "*.example.com")])).await,
            Verdict::Pass
        );
    }

    #[tokio::test]
    async fn a_wildcard_entry_does_not_expand_to_subdomains() {
        let filter = filter(StubIpam::owning(&["*.example.com"]));

        let error = check(&filter, &ids(&[("dns", "a.example.com")])).await;
        assert_denied(error, "a.example.com");
    }

    // ------------------------------------------------------ identifier types

    #[tokio::test]
    async fn the_connecting_address_may_always_be_certified() {
        let filter = filter(StubIpam::owning(&["host.example.com"]));

        assert_eq!(
            check(&filter, &ids(&[("ip", "10.0.0.5")])).await,
            Verdict::Pass
        );
    }

    #[tokio::test]
    async fn another_address_is_denied_unless_listed() {
        let filter = filter(StubIpam::owning(&["host.example.com"]));

        let error = check(&filter, &ids(&[("ip", "10.0.0.9")])).await;
        assert_denied(error, "10.0.0.9");
    }

    #[tokio::test]
    async fn another_address_may_be_listed_like_any_other_name() {
        let filter = filter(StubIpam::owning(&["10.0.0.9"]));

        assert_eq!(
            check(&filter, &ids(&[("ip", "10.0.0.9")])).await,
            Verdict::Pass
        );
    }

    #[tokio::test]
    async fn a_common_name_is_left_alone() {
        let filter = filter(StubIpam::owning(&["host.example.com"]));

        assert_eq!(
            check(
                &filter,
                &ids(&[
                    ("dns", "host.example.com"),
                    ("cn", "rcgen self signed cert"),
                ]),
            )
            .await,
            Verdict::Pass
        );
    }

    /// No round trip at all for a CSR carrying nothing but a common name.
    #[tokio::test]
    async fn a_request_of_common_names_alone_asks_the_inventory_nothing() {
        let stub = Arc::new(StubIpam::failing("must not be called"));
        let filter = filter_over(stub.clone());

        assert_eq!(
            check(&filter, &ids(&[("cn", "some label")])).await,
            Verdict::Pass
        );
        assert_eq!(stub.calls.load(Ordering::SeqCst), 0);
    }

    #[tokio::test]
    async fn a_type_an_inventory_cannot_speak_to_is_denied() {
        let filter = filter(StubIpam::owning(&["host.example.com"]));

        for typ in ["email", "uri", "other"] {
            let error = check(&filter, &ids(&[(typ, "whatever")])).await;
            assert_denied(error, &format!("requests a {typ} identifier"));
        }
    }

    // ------------------------------------------------------ startup + wiring

    #[test]
    fn reports_its_type_and_stages() {
        let check = filter(StubIpam::unknown());
        assert_eq!(check.kind(), "ipam");
        assert_eq!(check.stages(), StageSet::identifiers_only());
    }

    #[tokio::test]
    async fn does_not_inspect_connections() {
        let stub = Arc::new(StubIpam::failing("must not be called"));
        let filter = filter_over(stub.clone());

        assert_eq!(
            filter
                .check_connection(&ConnectionContext {
                    client_ip: Some("203.0.113.9".parse().unwrap()),
                    method: &Method::POST,
                    path: "/newOrder",
                })
                .await,
            Verdict::Pass
        );
        assert_eq!(stub.calls.load(Ordering::SeqCst), 0);
    }

    #[test]
    fn the_debug_impl_names_the_backend() {
        let rendered = format!("{:?}", filter(StubIpam::unknown()));
        assert!(rendered.contains("StubIPAM"), "{rendered}");
    }
}