Skip to main content

isb_daemon/daemon/
superadmin.rs

1//! Who is a superadmin ([`crate::auth::superadmin`]) on this daemon: a
2//! superadmin token, a tailnet identity on `--superadmin-tailnet`, a
3//! verified Cloudflare Access identity on `--superadmin-access`, either kind
4//! of identity added to `isb.db` with `isb superadmin add` (read per request,
5//! so no restart), or, in a debug build, any credential-less loopback request
6//! under `ISB_DEV_SUPERADMIN` ([`crate::auth::dev`]). Nothing else grants it.
7//! One gate serves the tool endpoints (through the authn hook) and the
8//! identity endpoints (`/api/v1/auth/*`).
9
10use std::sync::Arc;
11
12use serde_json::{Value, json};
13
14use crate::auth::agent_identities::{AgentKind, AgentWays};
15use crate::auth::edge::EdgeIdentity;
16use crate::auth::superadmin::SuperadminIdentity;
17use crate::auth::{AuthStore, Principal, Superadmin, SuperadminSource};
18use crate::error::{Error, Result};
19use crate::server::access::{ASSERTION_HEADER, AccessValidator, Identity};
20use crate::server::http::{Peer, Request};
21use crate::server::tailnet::{Tailnet, host_only};
22use crate::server::{Registry, Tool};
23
24/// `--superadmin-access`: Access emails and service-token client ids.
25#[derive(Debug, Clone, PartialEq, Eq, Default)]
26pub struct AccessAllowList {
27    pub emails: Vec<String>,
28    pub client_ids: Vec<String>,
29}
30
31impl AccessAllowList {
32    /// Comma-separated; an entry with `@` is an email, else a service
33    /// token's client id. Exact matches only: no wildcards or domains.
34    pub fn parse(list: &str) -> Result<AccessAllowList> {
35        let mut a = AccessAllowList::default();
36        for e in list.split(',').map(str::trim).filter(|e| !e.is_empty()) {
37            if e.contains(['*', '?', ' ', '\t']) || e.starts_with('@') || e.ends_with('@') {
38                return Err(Error::invalid(format!(
39                    "--superadmin-access: {e:?}: exact emails or service token client ids only"
40                )));
41            }
42            if e.contains('@') {
43                a.emails.push(e.to_ascii_lowercase());
44            } else {
45                a.client_ids.push(e.to_string());
46            }
47        }
48        if a.emails.is_empty() && a.client_ids.is_empty() {
49            return Err(Error::invalid(
50                "--superadmin-access needs at least one email or service token client id",
51            ));
52        }
53        Ok(a)
54    }
55
56    /// A user by email (case-insensitively); a service token by client id.
57    pub fn admits(&self, id: &Identity) -> bool {
58        match (&id.email, &id.common_name) {
59            (Some(e), _) => self.emails.iter().any(|x| x.eq_ignore_ascii_case(e)),
60            (None, Some(cn)) => self.client_ids.iter().any(|x| x == cn),
61            (None, None) => false,
62        }
63    }
64
65    pub fn entries(&self) -> Vec<String> {
66        self.emails
67            .iter()
68            .chain(&self.client_ids)
69            .cloned()
70            .collect()
71    }
72
73    pub fn is_empty(&self) -> bool {
74        self.emails.is_empty() && self.client_ids.is_empty()
75    }
76}
77
78/// One superadmin identity, as `superadmin_list` and the start-up line
79/// show it.
80#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
81pub struct Listed {
82    pub kind: AgentKind,
83    pub value: String,
84    /// `flag` (`--superadmin-access` / `--superadmin-tailnet`, read at
85    /// start-up) or `state` (`isb.db`, `isb superadmin add`, read per request).
86    pub source: &'static str,
87    /// Whether a request can match it on this daemon at all.
88    pub effective: bool,
89    #[serde(skip_serializing_if = "Option::is_none")]
90    pub note: Option<&'static str>,
91    #[serde(skip_serializing_if = "Option::is_none")]
92    pub id: Option<i64>,
93    #[serde(skip_serializing_if = "Option::is_none")]
94    pub added_at: Option<i64>,
95    #[serde(skip_serializing_if = "Option::is_none")]
96    pub added_by: Option<String>,
97}
98
99const NO_ACCESS: &str = "not effective: an Access superadmin needs Cloudflare Access (CF_ACCESS_TEAM_DOMAIN and CF_ACCESS_AUD) and --public-url";
100const NO_TAILNET: &str =
101    "not effective: no tailnet --listen address, so no tailnet peer reaches this daemon";
102
103/// What the gate makes of a request.
104pub enum Resolved {
105    /// Not a superadmin credential: judge the request as before.
106    None,
107    /// A superadmin token that is not valid.
108    Refused,
109    Superadmin(Arc<Superadmin>),
110}
111
112pub struct Gate {
113    store: Arc<AuthStore>,
114    tailnet: Option<Tailnet>,
115    /// The tailnet `--listen` addresses (what lets a tailnet peer in at all).
116    tailnet_listens: Vec<String>,
117    /// The validator of the listeners Access guards, and the `Host` names an
118    /// Access agent's request may carry (empty: no public URL, not checked).
119    access_agents: Option<(Arc<AccessValidator>, Vec<String>)>,
120    /// The Access validator of the loopback listeners, the
121    /// `--superadmin-access` list (empty without the flag; `isb.db`'s
122    /// entries count too), and the `Host` names an Access superadmin's
123    /// request may carry. None: no Access superadmin is possible here.
124    access: Option<(Arc<AccessValidator>, AccessAllowList, Vec<String>)>,
125    /// `ISB_DEV_SUPERADMIN`: the email a loopback request with no
126    /// credential is signed in as.
127    #[cfg(debug_assertions)]
128    dev: Option<String>,
129}
130
131impl Gate {
132    pub fn new(
133        store: Arc<AuthStore>,
134        tailnet: Option<Tailnet>,
135        access: Option<(Arc<AccessValidator>, AccessAllowList, Vec<String>)>,
136    ) -> Gate {
137        let access = access.map(|(v, a, hosts)| {
138            let mut hosts: Vec<String> = hosts.iter().map(|h| host_only(h)).collect();
139            hosts.sort();
140            hosts.dedup();
141            (v, a, hosts)
142        });
143        Gate {
144            store,
145            tailnet,
146            tailnet_listens: Vec::new(),
147            access_agents: None,
148            access,
149            #[cfg(debug_assertions)]
150            dev: None,
151        }
152    }
153
154    /// Sign every loopback request with no credential in as a superadmin
155    /// acting as `email` ([`crate::auth::dev`]).
156    #[cfg(debug_assertions)]
157    pub fn with_dev(mut self, email: Option<String>) -> Gate {
158        self.dev = email;
159        self
160    }
161
162    /// Let orgs' tailnet and Access agent identities in
163    /// ([`crate::auth::agent_identities`]): the tailnet `--listen`
164    /// addresses, and Access's validator with the `Host` names to allow.
165    pub fn with_agents(
166        mut self,
167        tailnet_listens: Vec<String>,
168        access: Option<(Arc<AccessValidator>, Vec<String>)>,
169    ) -> Gate {
170        self.tailnet_listens = tailnet_listens;
171        self.access_agents = access.map(|(v, hosts)| {
172            let mut hosts: Vec<String> = hosts.iter().map(|h| host_only(h)).collect();
173            hosts.sort();
174            hosts.dedup();
175            (v, hosts)
176        });
177        self
178    }
179
180    /// Which agent identities can reach this server at all.
181    pub fn agent_ways(&self) -> AgentWays {
182        AgentWays {
183            tailnet_listen: if self.tailnet.is_some() {
184                self.tailnet_listens.clone()
185            } else {
186                Vec::new()
187            },
188            access: self.access_agents.is_some(),
189            public_url: None,
190            superadmin_access: self
191                .access_list()
192                .map(AccessAllowList::entries)
193                .unwrap_or_default(),
194            superadmin_tailnet: self
195                .tailnet
196                .as_ref()
197                .map(|t| t.allow().entries())
198                .unwrap_or_default(),
199        }
200    }
201
202    /// The agent identity behind `req`, if an org maps it: a verified
203    /// Access identity (`id`, else the request's own assertion) on a
204    /// loopback listener Access guards, or a tailnet peer, as tailscaled
205    /// says. A bearer token decides on its own, so it is not asked here.
206    /// Superadmin sources are judged first, by [`Gate::resolve`].
207    pub fn agent(&self, req: &Request, id: Option<&Identity>) -> Option<Principal> {
208        if req.header("authorization").is_some() {
209            return None;
210        }
211        let looked = if matches!(&req.peer, Peer::Tcp(a) if a.ip().is_loopback()) {
212            let (v, hosts) = self.access_agents.as_ref()?;
213            let verified;
214            let id = match id {
215                Some(id) => id,
216                None => {
217                    let t = req.header(ASSERTION_HEADER)?.trim();
218                    verified = v.validate(t).ok()?;
219                    &verified
220                }
221            };
222            if !hosts.is_empty() {
223                let host = req.header("host").map(host_only).unwrap_or_default();
224                if !hosts.contains(&host) {
225                    eprintln!(
226                        "isb serve: Access agent {} sent Host {host:?}, not one of this server's names; not an agent",
227                        id.name()
228                    );
229                    return None;
230                }
231            }
232            self.store
233                .principal_for_access_agent(id.email.as_deref(), id.common_name.as_deref())
234        } else {
235            let w = self.tailnet.as_ref()?.identify(req)?;
236            self.store.principal_for_tailnet(&w.login, &w.node, &w.tags)
237        };
238        looked.unwrap_or_else(|e| {
239            eprintln!("isb serve: agent identities: {e}");
240            None
241        })
242    }
243
244    /// The person a front door verified ([`crate::auth::edge`]): a verified
245    /// Access user on a loopback listener Access guards, or an untagged
246    /// tailnet peer. Service tokens and tagged nodes are not people. They
247    /// may claim setup unless this front door's superadmin allow list
248    /// exists and leaves them out.
249    pub fn edge(&self, req: &Request, id: Option<&Identity>) -> Option<EdgeIdentity> {
250        if req.header("authorization").is_some() {
251            return None;
252        }
253        if matches!(&req.peer, Peer::Tcp(a) if a.ip().is_loopback()) {
254            let (v, hosts) = self.access_agents.as_ref()?;
255            let verified;
256            let id = match id {
257                Some(id) => id,
258                None => {
259                    let t = req.header(ASSERTION_HEADER)?.trim();
260                    verified = v.validate(t).ok()?;
261                    &verified
262                }
263            };
264            let email = id.email.as_deref()?.to_ascii_lowercase();
265            if !hosts.is_empty() {
266                let host = req.header("host").map(host_only).unwrap_or_default();
267                if !hosts.contains(&host) {
268                    return None;
269                }
270            }
271            let can_claim = self.access.as_ref().is_none_or(|(_, flag, _)| {
272                let state = self.state(AgentKind::Access);
273                (flag.is_empty() && state.is_empty())
274                    || flag.admits(id)
275                    || state
276                        .iter()
277                        .any(|s| s.admits_access(id.email.as_deref(), id.common_name.as_deref()))
278            });
279            return Some(EdgeIdentity {
280                kind: AgentKind::Access,
281                subject: id.sub.clone(),
282                name: email.clone(),
283                email: Some(email),
284                node: None,
285                can_claim,
286            });
287        }
288        let t = self.tailnet.as_ref()?;
289        let w = t.identify(req)?;
290        if !w.tags.is_empty() {
291            return None;
292        }
293        let state = self.state(AgentKind::Tailnet);
294        let listed = !t.allow().entries().is_empty() || !state.is_empty();
295        let can_claim = !listed || self.tailnet_admits(&w, &state);
296        Some(EdgeIdentity {
297            kind: AgentKind::Tailnet,
298            subject: w.login.clone(),
299            name: w.login.clone(),
300            email: crate::auth::edge::login_email(&w.login),
301            node: Some(w.node.clone()),
302            can_claim,
303        })
304    }
305
306    /// [`Gate::agent`] and [`Gate::edge`] as the identity endpoints ask them.
307    pub fn agent_fn(self: &Arc<Self>) -> crate::auth::http::AgentFn {
308        let g = self.clone();
309        Arc::new(move |r: &Request| g.agent(r, None))
310    }
311
312    pub fn edge_fn(self: &Arc<Self>) -> crate::auth::edge::EdgeFn {
313        let g = self.clone();
314        Arc::new(move |r: &Request| g.edge(r, None))
315    }
316
317    pub fn tailnet(&self) -> Option<&Tailnet> {
318        self.tailnet.as_ref()
319    }
320
321    /// `--superadmin-access`, when given.
322    pub fn access_list(&self) -> Option<&AccessAllowList> {
323        self.access
324            .as_ref()
325            .map(|(_, a, _)| a)
326            .filter(|a| !a.is_empty())
327    }
328
329    /// Every superadmin identity, the flags' then `isb.db`'s, each with
330    /// whether this daemon can match it.
331    pub fn listing(&self) -> Result<Vec<Listed>> {
332        let access_on = self.access.is_some();
333        let tailnet_on = self.tailnet.is_some() && !self.tailnet_listens.is_empty();
334        let flag = |kind, value: String, on: bool, note| Listed {
335            kind,
336            value,
337            source: "flag",
338            effective: on,
339            note: (!on).then_some(note),
340            id: None,
341            added_at: None,
342            added_by: None,
343        };
344        let mut v: Vec<Listed> = Vec::new();
345        for e in self
346            .access_list()
347            .map(AccessAllowList::entries)
348            .unwrap_or_default()
349        {
350            v.push(flag(AgentKind::Access, e, true, NO_ACCESS));
351        }
352        for e in self
353            .tailnet
354            .as_ref()
355            .map(|t| t.allow().entries())
356            .unwrap_or_default()
357        {
358            v.push(flag(AgentKind::Tailnet, e, tailnet_on, NO_TAILNET));
359        }
360        for i in self.store.list_superadmin_identities()? {
361            let (on, note) = match i.kind {
362                AgentKind::Access => (access_on, NO_ACCESS),
363                AgentKind::Tailnet => (tailnet_on, NO_TAILNET),
364            };
365            v.push(Listed {
366                kind: i.kind,
367                value: i.value,
368                source: "state",
369                effective: on,
370                note: (!on).then_some(note),
371                id: Some(i.id),
372                added_at: Some(i.added_at),
373                added_by: Some(i.added_by),
374            });
375        }
376        Ok(v)
377    }
378
379    /// `isb.db`'s superadmin identities of `kind`, read now: the host CLI
380    /// writes the table from another process, so a cached copy would need a
381    /// restart. A failed read admits nobody, and says why.
382    fn state(&self, kind: AgentKind) -> Vec<SuperadminIdentity> {
383        match self.store.list_superadmin_identities() {
384            Ok(v) => v.into_iter().filter(|i| i.kind == kind).collect(),
385            Err(e) => {
386                eprintln!("isb serve: superadmin identities in isb.db: {e}");
387                Vec::new()
388            }
389        }
390    }
391
392    /// On `--superadmin-tailnet` or among `state`.
393    fn tailnet_admits(
394        &self,
395        w: &crate::server::tailnet::Whois,
396        state: &[SuperadminIdentity],
397    ) -> bool {
398        self.tailnet.as_ref().is_some_and(|t| t.allow().admits(w))
399            || state.iter().any(|s| s.admits_tailnet(&w.login, &w.tags))
400    }
401
402    /// `id` is the Access identity the listener already verified, if any.
403    /// A bearer superadmin token decides alone; any other bearer token is
404    /// not this gate's. Then Access, then the tailnet, then (debug builds)
405    /// `ISB_DEV_SUPERADMIN`.
406    pub fn resolve(&self, req: &Request, id: Option<&Identity>) -> Resolved {
407        if let Some(a) = req.header("authorization") {
408            let token = a
409                .trim()
410                .split_once(' ')
411                .filter(|(s, _)| s.eq_ignore_ascii_case("bearer"))
412                .map(|(_, t)| t.trim());
413            return match token {
414                Some(t) if t.starts_with(crate::auth::secret::TokenKind::Superadmin.prefix()) => {
415                    match self.store.authenticate_superadmin_token(t) {
416                        Ok(Some(info)) => Resolved::Superadmin(Arc::new(Superadmin::synthetic(
417                            SuperadminSource::Token {
418                                id: info.id,
419                                name: info.name,
420                            },
421                        ))),
422                        Ok(None) => Resolved::Refused,
423                        Err(e) => {
424                            eprintln!("isb serve: superadmin token: {e}");
425                            Resolved::Refused
426                        }
427                    }
428                }
429                _ => Resolved::None,
430            };
431        }
432        if let Some(s) = self.access_superadmin(req, id) {
433            return Resolved::Superadmin(s);
434        }
435        let tailnet = self.tailnet.as_ref().and_then(|t| t.identify(req));
436        if let Some(w) = tailnet.filter(|w| self.tailnet_admits(w, &self.state(AgentKind::Tailnet)))
437        {
438            let source = SuperadminSource::Tailnet {
439                login: w.login.clone(),
440                node: w.node,
441                tags: w.tags.clone(),
442            };
443            let as_user = if w.tags.is_empty() {
444                Some(w.login.as_str())
445            } else {
446                None
447            };
448            return Resolved::Superadmin(Arc::new(self.acting_as(source, as_user)));
449        }
450        #[cfg(debug_assertions)]
451        if let Some(s) = self.dev_superadmin(req, id) {
452            return Resolved::Superadmin(s);
453        }
454        Resolved::None
455    }
456
457    /// A loopback TCP request with no credential of any kind: no bearer
458    /// token (judged above), session cookie or Access assertion. The unix
459    /// socket is its own caller.
460    #[cfg(debug_assertions)]
461    fn dev_superadmin(&self, req: &Request, id: Option<&Identity>) -> Option<Arc<Superadmin>> {
462        let email = self.dev.as_deref()?;
463        let loopback = matches!(&req.peer, Peer::Tcp(a) if a.ip().is_loopback());
464        let credential = id.is_some()
465            || req.header(ASSERTION_HEADER).is_some()
466            || crate::auth::http::cookie(req, crate::auth::http::COOKIE).is_some();
467        if !loopback || credential {
468            return None;
469        }
470        let source = SuperadminSource::Dev {
471            email: email.to_string(),
472        };
473        Some(Arc::new(self.acting_as(source, Some(email))))
474    }
475
476    /// Only from a verified assertion, on the loopback listeners Access
477    /// guards.
478    fn access_superadmin(&self, req: &Request, id: Option<&Identity>) -> Option<Arc<Superadmin>> {
479        let (v, allow, hosts) = self.access.as_ref()?;
480        let loopback = matches!(&req.peer, Peer::Tcp(a) if a.ip().is_loopback());
481        if !loopback {
482            return None;
483        }
484        let state = self.state(AgentKind::Access);
485        if allow.is_empty() && state.is_empty() {
486            return None;
487        }
488        let verified;
489        let id = match id {
490            Some(id) => id,
491            None => {
492                let t = req.header(ASSERTION_HEADER)?.trim();
493                verified = v.validate(t).ok()?;
494                &verified
495            }
496        };
497        let listed = allow.admits(id)
498            || state
499                .iter()
500                .any(|s| s.admits_access(id.email.as_deref(), id.common_name.as_deref()));
501        if !listed {
502            return None;
503        }
504        let host = req.header("host").map(host_only).unwrap_or_default();
505        if !hosts.contains(&host) {
506            eprintln!(
507                "isb serve: Access superadmin {} sent Host {host:?}, not one of this server's names; not a superadmin",
508                id.name()
509            );
510            return None;
511        }
512        let source = SuperadminSource::Access {
513            name: id.name().to_string(),
514            service_token: id.is_service_token(),
515        };
516        Some(Arc::new(self.acting_as(source, id.email.as_deref())))
517    }
518
519    /// As the enabled isb user with this email, else synthetic.
520    fn acting_as(&self, source: SuperadminSource, email: Option<&str>) -> Superadmin {
521        let user = email
522            .and_then(|e| self.store.user_by_email(e).ok().flatten())
523            .filter(|u| !u.disabled);
524        match user {
525            Some(u) => Superadmin::as_user(source.clone(), u, &self.store)
526                .unwrap_or_else(|_| Superadmin::synthetic(source)),
527            None => Superadmin::synthetic(source),
528        }
529    }
530}
531
532/// Tools for superadmins only (not platform admins): the host itself, and
533/// what reaches further into its kernel (nesting for an org's workspace).
534pub const TOOLS: &[&str] = &[
535    "host_inventory",
536    "host_monitor",
537    "host_policy",
538    "superadmin_token_list",
539    "superadmin_token_revoke",
540    "superadmin_list",
541    "org_nesting",
542];
543
544/// A `--listen` address on the tailnet (it then binds without a tunnel).
545pub fn is_tailnet_listen(addr: &str) -> bool {
546    use std::net::ToSocketAddrs;
547    addr.to_socket_addrs().is_ok_and(|mut a| {
548        a.next()
549            .is_some_and(|a| crate::server::tailnet::is_tailnet_ip(a.ip()))
550    })
551}
552
553/// The public URL's host, for the `Host` checks.
554fn public_host(url: &str) -> Option<String> {
555    let rest = url.trim().split_once("://")?.1;
556    let host = rest.split(['/', '?', '#']).next()?;
557    (!host.is_empty()).then(|| host.to_string())
558}
559
560/// The gate `cfg` describes. Refuses `--superadmin-access` without Access or a
561/// public URL (whose host the Access check needs).
562pub fn gate(
563    cfg: &super::ServeConfig,
564    store: Arc<AuthStore>,
565    access: Option<Arc<AccessValidator>>,
566) -> Result<Gate> {
567    let tailnet_listens: Vec<&String> =
568        cfg.listen.iter().filter(|a| is_tailnet_listen(a)).collect();
569    let public = cfg.public_url.as_deref().and_then(public_host);
570    if cfg.superadmin_tailnet.is_some() && tailnet_listens.is_empty() {
571        eprintln!(
572            "isb serve: WARNING: --superadmin-tailnet without a tailnet --listen address: no tailnet peer can reach this daemon"
573        );
574    }
575    // The tailnet check runs wherever a tailnet address is served: for the
576    // superadmin allow list, and for orgs' agent identities.
577    let tailnet = (cfg.superadmin_tailnet.is_some() || !tailnet_listens.is_empty()).then(|| {
578        let allow = cfg.superadmin_tailnet.clone().unwrap_or_default();
579        let mut hosts: Vec<String> = tailnet_listens.iter().map(|a| a.to_string()).collect();
580        hosts.extend(crate::server::tailnet::self_names());
581        hosts.extend(public.clone());
582        crate::server::tailnet::Tailnet::new(allow, crate::server::tailnet::system_fetcher(), hosts)
583    });
584    let mut access_hosts: Vec<String> = public.iter().cloned().collect();
585    access_hosts.extend(cfg.listen.iter().filter(|a| !is_tailnet_listen(a)).cloned());
586    let agent_access = access.clone().map(|v| {
587        let hosts = if public.is_some() {
588            access_hosts
589        } else {
590            Vec::new()
591        };
592        (v, hosts)
593    });
594    // Access superadmins are possible wherever Access and the public host
595    // are: the flag's list, else only `isb superadmin add`'s.
596    let access = match (&cfg.superadmin_access, access) {
597        (None, Some(v)) => public.clone().map(|host| {
598            let mut hosts = vec![host];
599            hosts.extend(cfg.listen.iter().filter(|a| !is_tailnet_listen(a)).cloned());
600            (v, AccessAllowList::default(), hosts)
601        }),
602        (None, None) => None,
603        (Some(_), None) => {
604            return Err(Error::invalid(
605                "--superadmin-access needs Cloudflare Access (CF_ACCESS_TEAM_DOMAIN and CF_ACCESS_AUD): it trusts only a verified assertion",
606            ));
607        }
608        (Some(list), Some(v)) => {
609            let Some(host) = public.clone() else {
610                return Err(Error::invalid(
611                    "--superadmin-access needs --public-url: an Access superadmin's request must name this server's host",
612                ));
613            };
614            let mut hosts = vec![host];
615            hosts.extend(cfg.listen.iter().filter(|a| !is_tailnet_listen(a)).cloned());
616            Some((v, list.clone(), hosts))
617        }
618    };
619    let listens = tailnet_listens.iter().map(|a| a.to_string()).collect();
620    let gate = Gate::new(store, tailnet, access).with_agents(listens, agent_access);
621    #[cfg(debug_assertions)]
622    let gate = gate.with_dev(dev_superadmin(cfg)?);
623    #[cfg(not(debug_assertions))]
624    if cfg.dev_superadmin.is_some() {
625        return Err(Error::invalid(format!(
626            "{} works only in debug builds: unset it",
627            crate::auth::dev::SUPERADMIN_ENV
628        )));
629    }
630    Ok(gate)
631}
632
633/// `ISB_DEV_SUPERADMIN`, refused unless every `--listen` address is
634/// loopback: it signs in anyone who can connect.
635#[cfg(debug_assertions)]
636fn dev_superadmin(cfg: &super::ServeConfig) -> Result<Option<String>> {
637    use std::net::ToSocketAddrs;
638    let Some(email) = cfg.dev_superadmin.clone() else {
639        return Ok(None);
640    };
641    let env = crate::auth::dev::SUPERADMIN_ENV;
642    let loopback = |a: &String| {
643        a.to_socket_addrs()
644            .is_ok_and(|mut it| it.all(|s| s.ip().is_loopback()))
645    };
646    if cfg.listen.is_empty() || !cfg.listen.iter().all(loopback) {
647        return Err(Error::invalid(format!(
648            "{env} signs every request without a credential in as a superadmin: --listen must be loopback only (it is {:?})",
649            cfg.listen
650        )));
651    }
652    eprintln!(
653        "isb serve: WARNING: {env}={email}: every HTTP request without a credential is superadmin dev:{email}; for developing isb only"
654    );
655    Ok(Some(email))
656}
657
658/// What `host_policy` reports of the configuration (never a secret).
659pub fn host_summary(cfg: &super::ServeConfig, gate: &Gate) -> Value {
660    json!({
661        "isb": env!("CARGO_PKG_VERSION"),
662        "listen": cfg.listen,
663        "socket": cfg.socket,
664        "state_dir": cfg.state_dir,
665        "public_url": cfg.public_url,
666        "access": cfg.access.as_ref().map(|(team, aud)| json!({"team_domain": team, "aud": aud})),
667        "allow_unauthenticated": cfg.allow_unauthenticated,
668        "tools": {"allow": cfg.remote_tools.allow, "deny": cfg.remote_tools.deny},
669        "policy": {
670            "allow_privileged": cfg.policy.allow_privileged,
671            "allow_raw": cfg.policy.allow_raw,
672            "bind_roots": cfg.policy.bind_roots,
673            "publish_addresses": cfg.policy.publish_addresses,
674            "any_instance": cfg.policy.any_instance,
675        },
676        "superadmin": {
677            "socket": cfg.socket,
678            "tokens": true,
679            "tailnet": cfg.superadmin_tailnet.as_ref().and(gate.tailnet()).map(|t| t.allow().entries()),
680            "tailnet_hosts": gate.tailnet().map(|t| t.hosts().to_vec()),
681            "access": gate.access_list().map(AccessAllowList::entries),
682        },
683    })
684}
685
686/// Say at start-up which sources grant superadmin.
687pub fn announce(cfg: &super::ServeConfig, gate: &Gate, store: &AuthStore) {
688    let mut v = vec![format!("the unix socket {}", cfg.socket.display())];
689    if !cfg.listen.is_empty() {
690        let n = store.list_superadmin_tokens().map(|t| t.len()).unwrap_or(0);
691        v.push(format!(
692            "superadmin tokens ({n}; minted on this host with `isb token create NAME --superadmin`)"
693        ));
694    }
695    if let Some(t) = cfg.superadmin_tailnet.as_ref().and(gate.tailnet()) {
696        v.push(format!(
697            "tailnet identities {} (--superadmin-tailnet; Host: {})",
698            t.allow().entries().join(", "),
699            t.hosts().join(", ")
700        ));
701    }
702    if let Some(a) = gate.access_list() {
703        v.push(format!(
704            "Cloudflare Access identities {} (--superadmin-access)",
705            a.entries().join(", ")
706        ));
707    }
708    let state: Vec<Listed> = match gate.listing() {
709        Ok(l) => l.into_iter().filter(|l| l.source == "state").collect(),
710        Err(e) => {
711            eprintln!("isb serve: superadmin identities in isb.db: {e}");
712            Vec::new()
713        }
714    };
715    for (kind, what) in [
716        (AgentKind::Tailnet, "tailnet identities"),
717        (AgentKind::Access, "Cloudflare Access identities"),
718    ] {
719        let on: Vec<&str> = state
720            .iter()
721            .filter(|l| l.kind == kind && l.effective)
722            .map(|l| l.value.as_str())
723            .collect();
724        if !on.is_empty() {
725            v.push(format!(
726                "{what} {} (isb.db: isb superadmin add)",
727                on.join(", ")
728            ));
729        }
730    }
731    #[cfg(debug_assertions)]
732    if let Some(e) = &gate.dev {
733        v.push(format!(
734            "every loopback request without a credential, as {e} ({})",
735            crate::auth::dev::SUPERADMIN_ENV
736        ));
737    }
738    eprintln!("isb serve: superadmins: {}", v.join("; "));
739    for l in state.iter().filter(|l| !l.effective) {
740        eprintln!(
741            "isb serve: WARNING: superadmin {} {} (isb.db) is {}",
742            l.kind.as_str(),
743            l.value,
744            l.note.unwrap_or("not effective")
745        );
746    }
747}
748
749/// The superadmin-only tools.
750pub(super) fn register(r: &mut Registry, d: Arc<super::Daemon>) -> Result<()> {
751    let ro = json!({"readOnlyHint": true, "openWorldHint": false});
752    let destructive = json!({"destructiveHint": true, "openWorldHint": false});
753    let empty = || json!({"type": "object", "properties": {"org": {"type": "string"}}, "additionalProperties": false});
754    let dd = d.clone();
755    r.register(
756        Tool::new(
757            "host_inventory",
758            "Every incus project and instance on the host, isb's or not: projects with the org each one is (if any); instances with project, type, status, addresses, and isb's labels (stack, owner). Superadmins only.",
759            empty(),
760            move |_a, _c| inventory(&dd),
761        )
762        .title("Host inventory")
763        .annotations(ro.clone()),
764    )?;
765    let dd = d.clone();
766    r.register(
767        Tool::new(
768            "host_policy",
769            "How this daemon serves: listen addresses, Cloudflare Access, the tools remote callers see, what a remote caller's specs may ask for (bind roots, publish addresses, privileged, raw, any instance), and every superadmin source with its allow lists. Superadmins only.",
770            empty(),
771            move |_a, _c| {
772                let mut v = dd.host.clone();
773                v["superadmin"]["token_count"] =
774                    json!(dd.users.list_superadmin_tokens().map(|t| t.len()).unwrap_or(0));
775                v["superadmin"]["identities"] = json!(dd.gate.listing()?);
776                Ok(v)
777            },
778        )
779        .title("Host policy")
780        .annotations(ro.clone()),
781    )?;
782    let dd = d.clone();
783    r.register(
784        Tool::new(
785            "superadmin_token_list",
786            "Superadmin tokens: id, name, created, last used, expiry (never the token). They are minted only on the host: isb token create NAME --superadmin. Superadmins only.",
787            empty(),
788            move |_a, _c| Ok(json!({"tokens": dd.users.list_superadmin_tokens()?})),
789        )
790        .title("Superadmin tokens")
791        .annotations(ro.clone()),
792    )?;
793    let dd = d.clone();
794    r.register(
795        Tool::new(
796            "superadmin_list",
797            "Superadmin identities: tailnet logins and tags, Cloudflare Access emails and service token client ids, each with its source (`flag`: --superadmin-tailnet / --superadmin-access, read at start-up; `state`: isb.db, added with `isb superadmin add` and read per request) and whether this daemon can match it (`effective`, with a `note` when not). Read only: identities are added and removed only on the host (isb superadmin add / rm), never over HTTP. Superadmins only.",
798            empty(),
799            move |_a, _c| Ok(json!({"identities": dd.gate.listing()?})),
800        )
801        .title("Superadmin identities")
802        .annotations(ro),
803    )?;
804    let dd = d;
805    r.register(
806        Tool::new(
807            "superadmin_token_revoke",
808            "Revoke a superadmin token by id; it stops working at once. Superadmins only.",
809            json!({"type": "object", "properties": {"id": {"type": "integer"}, "org": {"type": "string"}}, "required": ["id"], "additionalProperties": false}),
810            move |a, _c| {
811                let id = a
812                    .get("id")
813                    .and_then(Value::as_i64)
814                    .ok_or_else(|| Error::invalid("id: an integer"))?;
815                let t = dd.users.superadmin_token(id)?;
816                dd.users.revoke_superadmin_token(id)?;
817                Ok(json!({"revoked": t}))
818            },
819        )
820        .title("Revoke a superadmin token")
821        .annotations(destructive),
822    )?;
823    Ok(())
824}
825
826fn inventory(d: &super::Daemon) -> Result<Value> {
827    let projects = d.client.get("/1.0/projects?recursion=1")?;
828    let projects: Vec<Value> = projects
829        .as_array()
830        .map(|a| {
831            a.iter()
832                .map(|p| {
833                    let name = p["name"].as_str().unwrap_or("");
834                    json!({
835                        "name": name,
836                        "description": p["description"],
837                        "org": crate::org::OrgId::from_incus_project(name).map(|o| o.to_string()),
838                        "instances": p["used_by"].as_array().map(|u| u.iter().filter(|x| x.as_str().is_some_and(|s| s.starts_with("/1.0/instances/"))).count()).unwrap_or(0),
839                    })
840                })
841                .collect()
842        })
843        .unwrap_or_default();
844    let instances = d
845        .client
846        .get("/1.0/instances?recursion=2&all-projects=true")?;
847    let instances: Vec<Value> = instances
848        .as_array()
849        .map(|a| {
850            a.iter()
851                .map(|i| {
852                    let project = i["project"].as_str().unwrap_or("default");
853                    let cfg = &i["config"];
854                    let label = |k: &str| cfg.get(format!("user.{k}")).cloned().unwrap_or(Value::Null);
855                    let addresses: Vec<String> = i["state"]["network"]
856                        .as_object()
857                        .map(|n| {
858                            n.iter()
859                                .filter(|(k, _)| k.as_str() != "lo")
860                                .flat_map(|(_, v)| v["addresses"].as_array().cloned().unwrap_or_default())
861                                .filter(|a| a["scope"] == "global")
862                                .filter_map(|a| a["address"].as_str().map(String::from))
863                                .collect()
864                        })
865                        .unwrap_or_default();
866                    let stack = label("isb.stack");
867                    let owner = label(super::LABEL_OWNER);
868                    json!({
869                        "name": i["name"],
870                        "project": project,
871                        "org": crate::org::OrgId::from_incus_project(project).map(|o| o.to_string()),
872                        "type": i["type"],
873                        "status": i["status"],
874                        "created_at": i["created_at"],
875                        "image": cfg.get("image.description").cloned().unwrap_or(Value::Null),
876                        "addresses": addresses,
877                        "stack": stack,
878                        "owner": owner,
879                        "managed": !stack.is_null() || !owner.is_null(),
880                    })
881                })
882                .collect()
883        })
884        .unwrap_or_default();
885    Ok(json!({"projects": projects, "instances": instances}))
886}
887
888#[cfg(test)]
889mod tests;