Skip to main content

acme_proxy_server/
sockets.rs

1//! The three listeners' sockets: binding them at startup, planning a rebind on
2//! a reload, and announcing one that has come up.
3
4use std::sync::Arc;
5use std::time::Duration;
6
7use tokio::net::TcpListener;
8use tracing::{error, info, warn};
9
10use acme_proxy_core::config::Config;
11use acme_proxy_store::db::Database;
12
13use super::supervisor::Cells;
14
15/// Validates the admin configuration and binds its socket when it is enabled.
16///
17/// Shared by [`run`](super::run) and [`serve_on`](super::serve_on) rather than
18/// living in one of them: both need it, and the validation must happen **before
19/// anything binds**, so a misconfigured panel cannot take the ACME listener
20/// down with it halfway through startup.
21pub(super) async fn bind_admin(config: &Arc<Config>) -> anyhow::Result<Option<TcpListener>> {
22    acme_proxy_admin::webadmin::check_config(config).inspect_err(|error| {
23        error!(event = "admin_config_invalid", outcome = "failure", error = %error);
24    })?;
25
26    match config.admin.enabled {
27        false => Ok(None),
28        true => Ok(Some(
29            TcpListener::bind(&config.admin.bind_address)
30                .await
31                .inspect_err(|error| {
32                    error!(event = "admin_socket_bind_failed",
33                           outcome = "failure",
34                           bind_address = %config.admin.bind_address,
35                           error = %error);
36                })?,
37        )),
38    }
39}
40
41/// Refuses a `[metrics]` bind address that collides with another listener's.
42///
43/// Pure, so a reload runs the same check before rebinding anything — the twin of
44/// [`acme_proxy_admin::webadmin::check_config`], and beside it in
45/// [`prepare_reload`](crate::generation::prepare_reload) for the same reason: a listener configuration that would not start must not be one a
46/// running server can be reloaded into.
47///
48/// Three listeners, so the check is pairwise. `webadmin` already refuses
49/// admin-versus-server; these are the two pairs it cannot see. Checked even when
50/// `[admin]` is off, since enabling the panel later must not be what surfaces a
51/// latent conflict.
52///
53/// # Errors
54///
55/// Names the other key when the two addresses are equal.
56pub fn check_metrics_config(config: &Config) -> anyhow::Result<()> {
57    if !config.metrics.enabled {
58        return Ok(());
59    }
60
61    let bind = &config.metrics.bind_address;
62    for (name, other) in [
63        ("server.bind_address", &config.server.bind_address),
64        ("admin.bind_address", &config.admin.bind_address),
65    ] {
66        if bind == other && (name != "admin.bind_address" || config.admin.enabled) {
67            error!(event = "metrics_config_invalid",
68                   outcome = "failure",
69                   bind_address = %bind);
70            anyhow::bail!(
71                "metrics.bind_address and {name} are both `{bind}`: the metrics endpoint is a \
72                 separate listener and cannot share a socket (give it its own port)"
73            );
74        }
75    }
76    Ok(())
77}
78
79/// Binds the metrics socket when `[metrics]` is on, refusing a collision first.
80///
81/// The twin of [`bind_admin`], and the same shape for the same reason: a socket
82/// that cannot be bound must stop startup rather than leave the process running
83/// with one of its three listeners silently missing.
84///
85/// Deliberately **no loopback check**. `webadmin::check_config` refuses a
86/// non-loopback admin bind without TLS because that listener's cookie is always
87/// `Secure`, which a browser will not store over plain HTTP, so the failure
88/// would be invisible. Nothing here has a cookie: a metrics port reachable from
89/// a Prometheus host on another machine is the intended deployment, and the
90/// firewall is what bounds it.
91pub(super) async fn bind_metrics(config: &Arc<Config>) -> anyhow::Result<Option<TcpListener>> {
92    check_metrics_config(config)?;
93    if !config.metrics.enabled {
94        return Ok(None);
95    }
96
97    let bind = &config.metrics.bind_address;
98    let listener = TcpListener::bind(bind).await.inspect_err(|error| {
99        error!(event = "metrics_socket_bind_failed",
100               outcome = "failure",
101               bind_address = %bind,
102               error = %error);
103    })?;
104    Ok(Some(listener))
105}
106
107/// The three sockets a process may be handed, already bound.
108///
109/// A struct rather than three positional `Option<TcpListener>` parameters, the
110/// `ProfileParts` and `tests/reload.rs::Sockets` convention: they are the same
111/// shape, so a caller swapping two would still compile, in a function whose
112/// whole subject is which socket answers what.
113///
114/// Every field is optional because a role this process does not run holds no
115/// socket — and `admin` and `metrics` were already optional for their own
116/// `enabled` keys.
117#[derive(Default)]
118pub struct Sockets {
119    pub acme: Option<TcpListener>,
120    pub admin: Option<TcpListener>,
121    pub metrics: Option<TcpListener>,
122}
123
124/// One of the three sockets this process may hold.
125///
126/// An enum rather than the `&'static str` the log field wants, so the reload
127/// path's per-role handling is exhaustive: a fourth listener would be a compile
128/// error at every point that has to decide something about one, which is exactly
129/// how the third arrived with `bind_metrics` and `check_metrics_config` in
130/// place and nothing else remembering it existed.
131#[derive(Clone, Copy, PartialEq, Eq)]
132pub(super) enum Role {
133    Acme,
134    Admin,
135    Metrics,
136}
137
138impl Role {
139    /// The `listener` field every log line about this socket carries.
140    pub(super) fn label(self) -> &'static str {
141        match self {
142            Self::Acme => "acme",
143            Self::Admin => "admin",
144            Self::Metrics => "metrics",
145        }
146    }
147
148    /// The key an operator edits to move this socket, for a refusal to name.
149    fn bind_key(self) -> &'static str {
150        match self {
151            Self::Acme => "server.bind_address",
152            Self::Admin => "admin.bind_address",
153            Self::Metrics => "metrics.bind_address",
154        }
155    }
156}
157
158/// What a reload does to one role's socket.
159///
160/// Built while a failure can still refuse the whole reload, applied once nothing
161/// can fail — the same build-then-publish split every other part of a generation
162/// makes, applied to the one resource that cannot simply be constructed twice.
163pub(super) enum SocketPlan {
164    /// The role's address and enablement are both unchanged. Note this is
165    /// decided from the *configuration*, never from the address actually bound:
166    /// a caller supplying its own socket (every test that binds `127.0.0.1:0`)
167    /// is entitled to one that does not match the file, and rebinding it out
168    /// from under them would be this feature breaking its own callers.
169    Keep,
170    /// Serve this newly bound socket: the role was switched on, or its address
171    /// moved. Connections already established are untouched — hyper owns those,
172    /// and only the socket beneath them changes.
173    Serve(TcpListener),
174    /// Release the socket: the role was switched off.
175    Close,
176}
177
178/// The three roles' socket plans, and what to say about them afterwards.
179pub(super) struct SocketPlans {
180    acme: SocketPlan,
181    admin: SocketPlan,
182    metrics: SocketPlan,
183    /// The resolved address of each freshly bound socket, so the announcement
184    /// after the swap names where the listener actually landed.
185    pub(super) bound: Vec<(Role, String)>,
186}
187
188impl SocketPlans {
189    /// The roles whose socket this reload moved, for [`ReloadReport`] — which is
190    /// what a test waits on and what an operator greps.
191    ///
192    /// [`ReloadReport`]: crate::reload::ReloadReport
193    pub(super) fn rebound(&self) -> Vec<&'static str> {
194        self.bound.iter().map(|(role, _)| role.label()).collect()
195    }
196
197    /// Hands each socket to its accept loop. Synchronous and infallible, so it
198    /// sits inside the publishing run beside the routers.
199    pub(super) fn publish(self, cells: &Cells) {
200        for (role, plan, handle) in [
201            (Role::Acme, self.acme, &cells.acme),
202            (Role::Admin, self.admin, &cells.admin),
203            (Role::Metrics, self.metrics, &cells.metrics),
204        ] {
205            match plan {
206                SocketPlan::Keep => {}
207                SocketPlan::Serve(listener) => handle.serve(listener),
208                SocketPlan::Close => {
209                    handle.close();
210                    info!(
211                        event = "server_listener_stopped",
212                        outcome = "success",
213                        listener = role.label(),
214                        "switched off by a configuration reload: the socket is released \
215                         and nothing new is accepted on it"
216                    );
217                }
218            }
219        }
220    }
221}
222
223/// Decides, and performs, every bind this reload needs.
224///
225/// The ordering rule the whole reload path rests on, applied to sockets: bind
226/// first, so a bad address refuses the reload rather than having already
227/// dropped the live one. Two addresses that differ as strings but collide in
228/// the kernel — `[::]:3000` against `0.0.0.0:3000` — make that bind fail with
229/// `EADDRINUSE`, which is the safe direction: the running socket is still
230/// serving and the refusal names the key.
231///
232/// A `tls.enabled` flip does not appear here at all. The mode is read per
233/// connection (see [`acme_proxy_net::listener`]), so turning TLS on or off keeps the
234/// socket exactly where it is — which is what makes the one case a bind-first
235/// scheme could not serve, an unchanged address, not a case.
236pub(super) fn plan_sockets(
237    roles: super::RoleSet,
238    applied: &Config,
239    proposed: &Config,
240) -> Result<SocketPlans, crate::reload::ReloadError> {
241    let mut bound = Vec::new();
242    let mut plan = |role: Role,
243                    was: Option<&str>,
244                    now: Option<&str>|
245     -> Result<SocketPlan, crate::reload::ReloadError> {
246        match (was, now) {
247            (None, None) => Ok(SocketPlan::Keep),
248            (Some(_), None) => Ok(SocketPlan::Close),
249            (Some(was), Some(now)) if was == now => Ok(SocketPlan::Keep),
250            (_, Some(now)) => {
251                let listener = acme_proxy_net::listener::bind_blocking(now).map_err(|error| {
252                    error!(event = "server_socket_bind_failed",
253                           outcome = "failure",
254                           listener = role.label(),
255                           bind_address = %now,
256                           error = %error);
257                    crate::reload::ReloadError::Build(format!(
258                        "`{}` is `{now}`, which cannot be bound: {error}",
259                        role.bind_key()
260                    ))
261                })?;
262                bound.push((role, bound_address(Some(&listener), now)));
263                Ok(SocketPlan::Serve(listener))
264            }
265        }
266    };
267
268    // A role this process does not run holds no socket, in either generation,
269    // so it plans `Keep` and nothing is bound for it. `--role` is a command-line
270    // flag and cannot move under a `SIGHUP`, which is what makes comparing the
271    // same set on both sides correct rather than a simplification.
272    let acme_enabled = roles.has(super::ProcessRole::Acme);
273    let admin_enabled =
274        |config: &Config| roles.has(super::ProcessRole::Admin) && config.admin.enabled;
275
276    // Within the roles this process runs, the ACME listener is still never
277    // switched off by a reload — there is no `server.enabled`, and a CA serving
278    // no ACME would be a process with nothing to do.
279    let acme = plan(
280        Role::Acme,
281        acme_enabled.then_some(applied.server.bind_address.as_str()),
282        acme_enabled.then_some(proposed.server.bind_address.as_str()),
283    )?;
284    let admin = plan(
285        Role::Admin,
286        admin_enabled(applied).then_some(applied.admin.bind_address.as_str()),
287        admin_enabled(proposed).then_some(proposed.admin.bind_address.as_str()),
288    )?;
289    let metrics = plan(
290        Role::Metrics,
291        applied
292            .metrics
293            .enabled
294            .then_some(applied.metrics.bind_address.as_str()),
295        proposed
296            .metrics
297            .enabled
298            .then_some(proposed.metrics.bind_address.as_str()),
299    )?;
300
301    Ok(SocketPlans {
302        acme,
303        admin,
304        metrics,
305        bound,
306    })
307}
308
309/// Says the panel is up, and warns about the two states that make it useless.
310///
311/// Run whenever the admin listener **opens** — at startup, and again on a
312/// reload that turns `admin.enabled` on. Separate from the serving path since
313/// that no longer starts or stops per role: the socket is what comes and goes,
314/// and this is what an operator needs told when it does.
315pub(super) async fn announce_admin_listener(
316    config: &Arc<Config>,
317    database: &Arc<Database>,
318    bound: &str,
319) {
320    // A listener nobody holds an account for is a running service with no way
321    // in; say so once, naming the command that fixes it.
322    if acme_proxy_store::admin_user::AdminUser::list_all(database)
323        .await
324        .is_ok_and(|users| users.is_empty())
325    {
326        warn!(
327            event = "admin_no_users",
328            outcome = "advisory",
329            "the web admin is enabled but has no operators: create one with \
330               `acme-proxy admin user create <username>`"
331        );
332    }
333
334    // Repeated on every start while it holds, the `challenge_validation_bypassed`
335    // treatment: these operators can still sign in, they are simply made to
336    // enrol before their session becomes usable, and that stays worth seeing
337    // for exactly as long as it is true.
338    if config.admin.require_mfa
339        && let Ok(count) =
340            acme_proxy_admin::admin::mfa::operators_without_a_factor(database.clone()).await
341        && count > 0
342    {
343        warn!(
344            event = "admin_mfa_enrolment_pending",
345            outcome = "advisory",
346            count = count,
347            "admin.require_mfa is on and some operators have no second factor: \
348               their next sign-in will require enrolment before the session is usable"
349        );
350    }
351
352    // Same treatment for a configured `[admin.notify]` whose messages have
353    // nowhere to go. An operator's security notifications are addressed to
354    // their own `contact_email`, which is settable only from this host
355    // (`admin user contact`) — so a panel-first deployment can have the whole
356    // section configured, believe it is covered by ASVS V6.3.5/V6.3.7, and be
357    // silently falling back to `notify.email.to` or to nothing.
358    if config.admin.notify.enabled.is_empty() {
359        // Nothing configured: no notifications were promised, so an operator
360        // without an address is not a gap.
361    } else if let Ok(count) =
362        acme_proxy_admin::admin::users::operators_without_a_contact(database.clone()).await
363        && count > 0
364    {
365        warn!(
366            event = "admin_notify_contact_missing",
367            outcome = "advisory",
368            count = count,
369            "[admin.notify] is configured but some operators have no contact address: their \
370               security notifications fall back to notify.email.to, or are dropped if that is \
371               empty too — set one with `acme-proxy admin user contact <username> --contact <address>`"
372        );
373    }
374
375    // Swept once now, then on an interval: sessions outlive a restart, so a
376    // startup-only sweep would leak every one an operator never signed out of.
377    let idle = Duration::from_secs(config.admin.session_idle_timeout_seconds);
378    if let Err(error) = acme_proxy_store::admin_session::AdminSession::cleanup(idle, database).await
379    {
380        error!(event = "admin_session_cleanup_failed", outcome = "failure", error = %error);
381    }
382
383    // The **resolved** address, so a `:0` bind is discoverable.
384    info!(
385        event = "admin_listening",
386        outcome = "success",
387        bind_address = %bound,
388        protocol = if config.admin.tls.enabled { "https" } else { "http" },
389        base_url = %config.admin.base_url
390    );
391}
392
393/// The metrics listener's own one-line announcement, and its standing warning.
394pub(super) fn announce_metrics_listener(bound: &str) {
395    info!(
396        event = "metrics_listening",
397        outcome = "success",
398        bind_address = %bound,
399        "unauthenticated by design: the port is the boundary, so firewall it"
400    );
401}
402
403/// The address a socket ended up on, falling back to what was configured.
404///
405/// The two differ for `:0` and for a caller that supplied its own listener; the
406/// resolved one is what an operator needs, and the configured one is all there
407/// is to say when the socket cannot answer.
408pub(super) fn bound_address(listener: Option<&TcpListener>, configured: &str) -> String {
409    listener
410        .and_then(|listener| listener.local_addr().ok())
411        .map_or_else(|| configured.to_string(), |address| address.to_string())
412}