acme-proxy 0.6.1

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
//! `completions <shell>` and `man`: the two commands whose output *is* the
//! command tree.
//!
//! Both render from [`Cli::command()`] — the same builder `clap` parses argv
//! with — rather than from a script or a roff file checked in beside it. That
//! is the whole reason they exist as generators: the CLI is deliberately not
//! frozen before 1.0.0 (ADR 0001, in the book), so a hand-maintained completion
//! script or man page goes stale at the first rename with nothing in CI to say
//! so, while a generated one cannot.
//!
//! **Neither reads the configuration or the database**, which is why
//! `src/main.rs` answers them *before* it calls `Config::load` and
//! `Database::open` — see the note there. Everything below is therefore a
//! plain function over an injectable writer, so the tests assert on the bytes
//! instead of on a process's stdout.

use std::io::Write;

use clap::CommandFactory;
use clap_complete::aot::Shell;

use crate::cli::{Cli, CliError, Command};

/// The book, named in the man page's SEE ALSO. Read from the manifest rather
/// than written out, so a moved documentation site moves this too.
const BOOK_URL: &str = env!("CARGO_PKG_HOMEPAGE");

/// The book's Admin CLI chapter, which holds the per-flag reference this page
/// leaves out. `CARGO_PKG_HOMEPAGE` ends in a `/`.
const CLI_CHAPTER_URL: &str = concat!(env!("CARGO_PKG_HOMEPAGE"), "operations/cli.html");

/// Routes the two generator commands.
///
/// Shared by `src/main.rs` (which answers them before opening anything) and by
/// [`crate::cli::dispatch`] (which stays a total function over [`Command`]), so
/// the match is spelled once rather than in both.
///
/// # Panics
///
/// On any other [`Command`]. The two callers both match first; this is the arm
/// that says so out loud rather than silently generating the wrong thing.
pub fn write(command: &Command, out: &mut impl Write) -> Result<(), CliError> {
    match command {
        Command::Completions { shell } => write_completions(*shell, out),
        Command::Man => write_man(out),
        _ => unreachable!("both callers match the two generator commands first"),
    }
}

/// Writes the completion script for `shell`.
///
/// The name passed to the generator is the binary's, not the crate's: it is
/// what the script registers itself against, so it has to be what an operator
/// types.
pub fn write_completions(shell: Shell, out: &mut impl Write) -> Result<(), CliError> {
    let mut command = Cli::command();
    let name = command.get_name().to_string();
    clap_complete::aot::generate(shell, &mut command, name, out);
    Ok(())
}

/// Writes the roff source of `acme-proxy.1`.
///
/// Rendered section by section rather than through `Man::render`, because
/// five of them are facts `clap` has no way to know: what the exit status
/// means, a few typical invocations, the environment this binary reads, the
/// file it looks for, and where the full documentation is.
/// They are written as roff here rather than hung off `after_long_help`, which
/// would put the same block — `.TP` markup and all — into `--help`.
///
/// One page, for the top-level command. The per-flag detail of every subcommand
/// lives in the book's Admin CLI chapter, which SEE ALSO names: a tree of ~50
/// roff files would document `admin user totp reset` for a binary that installs
/// no man pages at all.
pub fn write_man(out: &mut impl Write) -> Result<(), CliError> {
    let man = clap_mangen::Man::new(Cli::command()).section("1");

    let render = |out: &mut dyn Write| -> std::io::Result<()> {
        man.render_title(out)?;
        man.render_name_section(out)?;
        man.render_synopsis_section(out)?;
        man.render_description_section(out)?;
        man.render_options_section(out)?;
        man.render_subcommands_section(out)?;
        render_exit_status_section(out)?;
        render_examples_section(out)?;
        render_environment_section(out)?;
        render_files_section(out)?;
        render_see_also_section(out)?;
        man.render_version_section(out)
    };

    render(out).map_err(|error| CliError::failed(format!("cannot write the man page: {error}")))
}

/// The contract `CliErrorKind` keeps, as `doc/src/operations/cli.md`'s exit
/// code table states it.
fn render_exit_status_section(out: &mut dyn Write) -> std::io::Result<()> {
    writeln!(out, ".SH EXIT STATUS")?;
    for (code, meaning) in [
        ("0", "Success."),
        (
            "1",
            "The host could not carry out the request: a database that will not \
             open, a signer or CA error, an unreadable file, an unreachable \
             upstream, invalid configuration. Worth retrying once the host is \
             fixed. \\fBserve\\fR exits 1 for any startup failure.",
        ),
        (
            "2",
            "The command line was rejected by the argument parser: an unknown \
             flag, subcommand or \\fB\\-\\-role\\fR, a missing argument.",
        ),
        (
            "3",
            "The request cannot be satisfied as written: no object with that id, \
             an object in the wrong state, an unknown \\fB\\-\\-status\\fR, \
             \\fB\\-\\-event\\fR or \\fB\\-\\-outcome\\fR value, \
             contradictory flags. Re-running the identical command will not help.",
        ),
    ] {
        writeln!(out, ".TP")?;
        writeln!(out, "\\fB{code}\\fR")?;
        writeln!(out, "{meaning}")?;
    }
    Ok(())
}

/// A handful of invocations, one per kind of task, so the page answers "how do
/// I start" without the book. Not a reference: every flag is in `--help`.
fn render_examples_section(out: &mut dyn Write) -> std::io::Result<()> {
    writeln!(out, ".SH EXAMPLES")?;
    for (what, command) in [
        (
            "Prepare a new deployment, then run every role in one process:",
            "acme\\-proxy init\nacme\\-proxy serve",
        ),
        (
            "Create the first web admin operator, reading the password from stdin:",
            "acme\\-proxy admin user create alice",
        ),
        (
            "Find the order behind a certificate serial, and revoke it:",
            "acme\\-proxy order list \\-\\-cert\\-serial 03:a1:5f\n\
             acme\\-proxy order revoke <order\\-id> \\-\\-reason 1",
        ),
        (
            "Page through the audit trail as JSON:",
            "acme\\-proxy audit list \\-\\-since\\-days 7 \\-\\-limit 100 \\-\\-offset 100 \\-\\-json",
        ),
        (
            "Check a configuration's access policy before restarting:",
            "acme\\-proxy filter explain \\-\\-client\\-ip 192.0.2.10 \\-\\-identifier www.example.com",
        ),
    ] {
        writeln!(out, ".PP")?;
        writeln!(out, "{what}")?;
        writeln!(out, ".PP")?;
        writeln!(out, ".nf")?;
        writeln!(out, ".RS 4")?;
        writeln!(out, "{command}")?;
        writeln!(out, ".RE")?;
        writeln!(out, ".fi")?;
    }
    Ok(())
}

/// What `Config::load` and `main.rs` actually read from the environment.
///
/// Deliberately names the variables without restating their defaults: a default
/// spelled here and in `doc/src/configuration/reference.md` is a default that
/// drifts, which is the rule `doc/lint.py` enforces inside the book.
fn render_environment_section(out: &mut dyn Write) -> std::io::Result<()> {
    writeln!(out, ".SH ENVIRONMENT")?;
    writeln!(out, ".TP")?;
    writeln!(out, "\\fBACME_PROXY_CONFIG\\fR")?;
    writeln!(
        out,
        "Path to the configuration file; its \\fB.toml\\fR extension may be \
         omitted. Defaults to \\fBconfig\\fR in the working directory."
    )?;
    writeln!(out, ".TP")?;
    writeln!(out, "\\fBACME_PROXY_*\\fR")?;
    writeln!(
        out,
        "Per-key overrides of the configuration file, section and key separated \
         by a double underscore: \\fBACME_PROXY_SERVER__BIND_ADDRESS\\fR \
         sets \\fBserver.bind_address\\fR. List-valued keys are comma-separated."
    )?;
    writeln!(out, ".TP")?;
    writeln!(out, "\\fBNO_COLOR\\fR")?;
    writeln!(
        out,
        "Set and non-empty, suppresses colour in the human-readable output. \
         \\fB\\-\\-color always\\fR outranks it; \\fB\\-\\-json\\fR output never \
         carries colour at any setting."
    )?;
    writeln!(out, ".TP")?;
    writeln!(out, "\\fBRUST_LOG\\fR")?;
    writeln!(
        out,
        "A log filter in \\fBtracing-subscriber\\fR's \\fBEnvFilter\\fR \
         syntax. For \\fBserve\\fR it overrides \\fB[logging]\\fR, and \
         \\fB\\-\\-log\\-level\\fR overrides it. For every other command, \
         set and non-empty, it turns logging on, on stderr."
    )
}

fn render_files_section(out: &mut dyn Write) -> std::io::Result<()> {
    writeln!(out, ".SH FILES")?;
    writeln!(out, ".TP")?;
    writeln!(out, "\\fBconfig.toml\\fR")?;
    writeln!(
        out,
        "The configuration, read from the working directory unless \
         \\fBACME_PROXY_CONFIG\\fR says otherwise. There is no \
         \\fB\\-\\-config\\fR flag: every subcommand reads the same one the \
         server does, which is what makes the admin commands act on the same \
         database."
    )
}

fn render_see_also_section(out: &mut dyn Write) -> std::io::Result<()> {
    writeln!(out, ".SH SEE ALSO")?;
    writeln!(
        out,
        "The per-flag reference for every subcommand above is the Admin CLI \
         chapter of the book:"
    )?;
    writeln!(out, ".UR {CLI_CHAPTER_URL}")?;
    writeln!(out, ".UE")?;
    writeln!(
        out,
        "The whole book, with the configuration reference and the operator \
         guides:"
    )?;
    writeln!(out, ".UR {BOOK_URL}")?;
    writeln!(out, ".UE")
}

#[cfg(test)]
mod tests {
    use super::*;
    use clap::ValueEnum;

    fn completions(shell: Shell) -> String {
        let mut out = Vec::new();
        write_completions(shell, &mut out).expect("a completion script must render");
        String::from_utf8(out).expect("clap generates UTF-8")
    }

    fn man() -> String {
        let mut out = Vec::new();
        write_man(&mut out).expect("the man page must render");
        String::from_utf8(out).expect("roff is written as UTF-8 here")
    }

    /// `Shell::value_variants()` rather than a list written out: a shell `clap`
    /// adds later is covered without an edit here, which is the point of taking
    /// its enum instead of declaring one.
    #[test]
    fn every_shell_generates_a_script_naming_the_binary() {
        for shell in Shell::value_variants() {
            let script = completions(*shell);
            assert!(!script.is_empty(), "{shell} generated nothing");
            assert!(
                script.contains("acme-proxy"),
                "{shell}'s script does not name the binary"
            );
        }
    }

    /// The whole tree, not just the top level: `recovery-codes` is four levels
    /// down (`admin user totp recovery-codes`), so a script carrying it walked
    /// every subcommand rather than stopping at the first rank.
    ///
    /// `Fish` is deliberately absent, and that is a fact about the generator
    /// rather than about this tree: `clap_complete`'s fish output guards each
    /// candidate with `__fish_seen_subcommand_from`, which cannot express a
    /// fourth rank, so it stops at `admin user totp`. Asserting it here would
    /// pin a limitation of that backend as though it were our contract.
    #[test]
    fn the_scripts_reach_the_deepest_subcommand() {
        for shell in [Shell::Bash, Shell::Zsh, Shell::Elvish, Shell::PowerShell] {
            assert!(
                completions(shell).contains("recovery-codes"),
                "{shell}'s script stops short of the deepest subcommand"
            );
        }
    }

    /// The generated half: a title line, and the subcommand list clap builds.
    #[test]
    fn the_man_page_carries_a_title_and_the_subcommands() {
        let page = man();
        // Not `starts_with`: `render_title` emits roff's `\*(Aq` quote
        // definition ahead of the `.TH` line.
        assert!(
            page.contains(".TH acme-proxy 1"),
            "no roff title line: {page:.120}"
        );
        assert!(
            page.contains("acme\\-proxy"),
            "the page does not name itself"
        );
        for subcommand in [
            "serve", "account", "order", "audit", "nonce", "profile", "eab", "admin",
        ] {
            assert!(
                page.contains(subcommand),
                "the SUBCOMMANDS section omits `{subcommand}`"
            );
        }
    }

    /// The hand-written half — the only part of the page that can be wrong
    /// without a compile error.
    #[test]
    fn the_man_page_carries_the_hand_written_sections() {
        let page = man();
        for section in [
            ".SH ENVIRONMENT",
            "ACME_PROXY_CONFIG",
            "NO_COLOR",
            "RUST_LOG",
            ".SH FILES",
            "config.toml",
            ".SH SEE ALSO",
            BOOK_URL,
            CLI_CHAPTER_URL,
            ".SH EXIT STATUS",
            ".SH EXAMPLES",
        ] {
            assert!(page.contains(section), "the page omits `{section}`");
        }
    }

    /// They land between the generated sections rather than after the last one:
    /// a SEE ALSO under VERSION reads as a footnote to the version.
    #[test]
    fn the_hand_written_sections_precede_the_version() {
        let page = man();
        let environment = page
            .find(".SH ENVIRONMENT")
            .expect("ENVIRONMENT is rendered");
        let see_also = page.find(".SH SEE ALSO").expect("SEE ALSO is rendered");
        let version = page.find(".SH VERSION").expect("VERSION is rendered");
        assert!(environment < see_also, "SEE ALSO comes before ENVIRONMENT");
        assert!(see_also < version, "VERSION comes before SEE ALSO");
    }

    /// [`write`] routes both, which is what `main.rs` and `dispatch` share.
    #[test]
    fn write_routes_both_generator_commands() {
        let mut script = Vec::new();
        write(&Command::Completions { shell: Shell::Fish }, &mut script)
            .expect("completions must render");
        assert!(String::from_utf8_lossy(&script).contains("acme-proxy"));

        let mut page = Vec::new();
        write(&Command::Man, &mut page).expect("the man page must render");
        assert!(String::from_utf8_lossy(&page).contains(".TH acme-proxy 1"));
    }

    /// A write that fails is reported rather than panicking or being dropped:
    /// `acme-proxy man | head` closes the pipe under us, and an operator paging
    /// the output should not see a panic for it.
    #[test]
    fn a_broken_writer_becomes_a_cli_error() {
        struct Broken;
        impl Write for Broken {
            fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
                Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe))
            }
            fn flush(&mut self) -> std::io::Result<()> {
                Ok(())
            }
        }

        let error = write_man(&mut Broken).expect_err("a broken pipe must be reported");
        assert!(
            error.to_string().starts_with("cannot write the man page: "),
            "{error}"
        );
    }

    /// The same, but for a pipe that closes *partway* through — which is what
    /// `acme-proxy man | head` actually does. Every section's `?` is a distinct
    /// early return, and a writer that only ever fails on its first call leaves
    /// all but the first of them unexercised.
    #[test]
    fn a_pipe_closing_partway_is_reported_from_every_section() {
        struct FailsAfter(usize);
        impl Write for FailsAfter {
            fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
                if self.0 == 0 {
                    return Err(std::io::Error::from(std::io::ErrorKind::BrokenPipe));
                }
                self.0 -= 1;
                Ok(buf.len())
            }
            fn flush(&mut self) -> std::io::Result<()> {
                Ok(())
            }
        }

        // Every write the page takes, not a handful of samples: each section's
        // `?` is its own early return, and a sample misses most of them.
        let mut counting = FailsAfter(usize::MAX);
        write_man(&mut counting).expect("a writer that never fails must succeed");
        let writes = usize::MAX - counting.0;
        assert!(
            writes > 40,
            "the page should take many writes, took {writes}"
        );

        for stop in 0..writes {
            let error = write_man(&mut FailsAfter(stop))
                .expect_err("a pipe closing mid-page must still be reported");
            assert!(
                error.to_string().starts_with("cannot write the man page: "),
                "closing after {stop} of {writes} writes: {error}"
            );
        }
    }

    /// The bash script matches its own ids.
    ///
    /// It works in two halves: a loop that walks the typed words and builds an
    /// id (`cmd="acme__proxy__subcmd__admin"`), then a `case` over that id
    /// whose labels carry the candidates. The two are generated separately, so
    /// they can disagree — and in `clap_complete` 4.6 they do, for any binary
    /// whose *name* holds a `-`: the loop escapes it to `__` and the labels to
    /// `__subcmd__`, so `acme-proxy admin <TAB>` matches no label and offers
    /// nothing. Every shell but bash is unaffected, and the script is still
    /// valid bash, so nothing else here would have caught it.
    ///
    /// This is why `Cargo.toml` holds `clap_complete` at `~4.5`. A release that
    /// fixes it passes this test and the pin can go.
    #[test]
    fn the_bash_script_is_internally_consistent() {
        let script = completions(Shell::Bash);

        let assigned: Vec<&str> = script
            .lines()
            .filter_map(|line| line.trim().strip_prefix("cmd=\""))
            .filter_map(|rest| rest.strip_suffix('"'))
            .filter(|id| !id.is_empty())
            .collect();
        assert!(
            assigned.len() > 50,
            "expected the whole tree, found {} ids",
            assigned.len()
        );

        let labels: std::collections::HashSet<&str> = script
            .lines()
            .map(str::trim)
            .filter_map(|line| line.strip_suffix(')'))
            .collect();

        for id in assigned {
            assert!(
                labels.contains(id),
                "the word loop builds `{id}`, which no `case` label matches: \
                 bash completion is dead below that point"
            );
        }
    }

    /// `clap`'s own validity check over the whole tree — duplicate short flags,
    /// a `value_parser` that cannot parse its default, an argument named twice.
    /// It panics rather than returning, and only in debug builds, which is
    /// exactly what a test is.
    #[test]
    fn the_command_tree_is_well_formed() {
        Cli::command().debug_assert();
    }
}