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_policy",
537    "superadmin_token_list",
538    "superadmin_token_revoke",
539    "superadmin_list",
540    "org_nesting",
541];
542
543/// A `--listen` address on the tailnet (it then binds without a tunnel).
544pub fn is_tailnet_listen(addr: &str) -> bool {
545    use std::net::ToSocketAddrs;
546    addr.to_socket_addrs().is_ok_and(|mut a| {
547        a.next()
548            .is_some_and(|a| crate::server::tailnet::is_tailnet_ip(a.ip()))
549    })
550}
551
552/// The public URL's host, for the `Host` checks.
553fn public_host(url: &str) -> Option<String> {
554    let rest = url.trim().split_once("://")?.1;
555    let host = rest.split(['/', '?', '#']).next()?;
556    (!host.is_empty()).then(|| host.to_string())
557}
558
559/// The gate `cfg` describes. Refuses `--superadmin-access` without Access or a
560/// public URL (whose host the Access check needs).
561pub fn gate(
562    cfg: &super::ServeConfig,
563    store: Arc<AuthStore>,
564    access: Option<Arc<AccessValidator>>,
565) -> Result<Gate> {
566    let tailnet_listens: Vec<&String> =
567        cfg.listen.iter().filter(|a| is_tailnet_listen(a)).collect();
568    let public = cfg.public_url.as_deref().and_then(public_host);
569    if cfg.superadmin_tailnet.is_some() && tailnet_listens.is_empty() {
570        eprintln!(
571            "isb serve: WARNING: --superadmin-tailnet without a tailnet --listen address: no tailnet peer can reach this daemon"
572        );
573    }
574    // The tailnet check runs wherever a tailnet address is served: for the
575    // superadmin allow list, and for orgs' agent identities.
576    let tailnet = (cfg.superadmin_tailnet.is_some() || !tailnet_listens.is_empty()).then(|| {
577        let allow = cfg.superadmin_tailnet.clone().unwrap_or_default();
578        let mut hosts: Vec<String> = tailnet_listens.iter().map(|a| a.to_string()).collect();
579        hosts.extend(crate::server::tailnet::self_names());
580        hosts.extend(public.clone());
581        crate::server::tailnet::Tailnet::new(allow, crate::server::tailnet::system_fetcher(), hosts)
582    });
583    let mut access_hosts: Vec<String> = public.iter().cloned().collect();
584    access_hosts.extend(cfg.listen.iter().filter(|a| !is_tailnet_listen(a)).cloned());
585    let agent_access = access.clone().map(|v| {
586        let hosts = if public.is_some() {
587            access_hosts
588        } else {
589            Vec::new()
590        };
591        (v, hosts)
592    });
593    // Access superadmins are possible wherever Access and the public host
594    // are: the flag's list, else only `isb superadmin add`'s.
595    let access = match (&cfg.superadmin_access, access) {
596        (None, Some(v)) => public.clone().map(|host| {
597            let mut hosts = vec![host];
598            hosts.extend(cfg.listen.iter().filter(|a| !is_tailnet_listen(a)).cloned());
599            (v, AccessAllowList::default(), hosts)
600        }),
601        (None, None) => None,
602        (Some(_), None) => {
603            return Err(Error::invalid(
604                "--superadmin-access needs Cloudflare Access (CF_ACCESS_TEAM_DOMAIN and CF_ACCESS_AUD): it trusts only a verified assertion",
605            ));
606        }
607        (Some(list), Some(v)) => {
608            let Some(host) = public.clone() else {
609                return Err(Error::invalid(
610                    "--superadmin-access needs --public-url: an Access superadmin's request must name this server's host",
611                ));
612            };
613            let mut hosts = vec![host];
614            hosts.extend(cfg.listen.iter().filter(|a| !is_tailnet_listen(a)).cloned());
615            Some((v, list.clone(), hosts))
616        }
617    };
618    let listens = tailnet_listens.iter().map(|a| a.to_string()).collect();
619    let gate = Gate::new(store, tailnet, access).with_agents(listens, agent_access);
620    #[cfg(debug_assertions)]
621    let gate = gate.with_dev(dev_superadmin(cfg)?);
622    #[cfg(not(debug_assertions))]
623    if cfg.dev_superadmin.is_some() {
624        return Err(Error::invalid(format!(
625            "{} works only in debug builds: unset it",
626            crate::auth::dev::SUPERADMIN_ENV
627        )));
628    }
629    Ok(gate)
630}
631
632/// `ISB_DEV_SUPERADMIN`, refused unless every `--listen` address is
633/// loopback: it signs in anyone who can connect.
634#[cfg(debug_assertions)]
635fn dev_superadmin(cfg: &super::ServeConfig) -> Result<Option<String>> {
636    use std::net::ToSocketAddrs;
637    let Some(email) = cfg.dev_superadmin.clone() else {
638        return Ok(None);
639    };
640    let env = crate::auth::dev::SUPERADMIN_ENV;
641    let loopback = |a: &String| {
642        a.to_socket_addrs()
643            .is_ok_and(|mut it| it.all(|s| s.ip().is_loopback()))
644    };
645    if cfg.listen.is_empty() || !cfg.listen.iter().all(loopback) {
646        return Err(Error::invalid(format!(
647            "{env} signs every request without a credential in as a superadmin: --listen must be loopback only (it is {:?})",
648            cfg.listen
649        )));
650    }
651    eprintln!(
652        "isb serve: WARNING: {env}={email}: every HTTP request without a credential is superadmin dev:{email}; for developing isb only"
653    );
654    Ok(Some(email))
655}
656
657/// What `host_policy` reports of the configuration (never a secret).
658pub fn host_summary(cfg: &super::ServeConfig, gate: &Gate) -> Value {
659    json!({
660        "isb": env!("CARGO_PKG_VERSION"),
661        "listen": cfg.listen,
662        "socket": cfg.socket,
663        "state_dir": cfg.state_dir,
664        "public_url": cfg.public_url,
665        "access": cfg.access.as_ref().map(|(team, aud)| json!({"team_domain": team, "aud": aud})),
666        "allow_unauthenticated": cfg.allow_unauthenticated,
667        "tools": {"allow": cfg.remote_tools.allow, "deny": cfg.remote_tools.deny},
668        "policy": {
669            "allow_privileged": cfg.policy.allow_privileged,
670            "allow_raw": cfg.policy.allow_raw,
671            "bind_roots": cfg.policy.bind_roots,
672            "publish_addresses": cfg.policy.publish_addresses,
673            "any_instance": cfg.policy.any_instance,
674        },
675        "superadmin": {
676            "socket": cfg.socket,
677            "tokens": true,
678            "tailnet": cfg.superadmin_tailnet.as_ref().and(gate.tailnet()).map(|t| t.allow().entries()),
679            "tailnet_hosts": gate.tailnet().map(|t| t.hosts().to_vec()),
680            "access": gate.access_list().map(AccessAllowList::entries),
681        },
682    })
683}
684
685/// Say at start-up which sources grant superadmin.
686pub fn announce(cfg: &super::ServeConfig, gate: &Gate, store: &AuthStore) {
687    let mut v = vec![format!("the unix socket {}", cfg.socket.display())];
688    if !cfg.listen.is_empty() {
689        let n = store.list_superadmin_tokens().map(|t| t.len()).unwrap_or(0);
690        v.push(format!(
691            "superadmin tokens ({n}; minted on this host with `isb token create NAME --superadmin`)"
692        ));
693    }
694    if let Some(t) = cfg.superadmin_tailnet.as_ref().and(gate.tailnet()) {
695        v.push(format!(
696            "tailnet identities {} (--superadmin-tailnet; Host: {})",
697            t.allow().entries().join(", "),
698            t.hosts().join(", ")
699        ));
700    }
701    if let Some(a) = gate.access_list() {
702        v.push(format!(
703            "Cloudflare Access identities {} (--superadmin-access)",
704            a.entries().join(", ")
705        ));
706    }
707    let state: Vec<Listed> = match gate.listing() {
708        Ok(l) => l.into_iter().filter(|l| l.source == "state").collect(),
709        Err(e) => {
710            eprintln!("isb serve: superadmin identities in isb.db: {e}");
711            Vec::new()
712        }
713    };
714    for (kind, what) in [
715        (AgentKind::Tailnet, "tailnet identities"),
716        (AgentKind::Access, "Cloudflare Access identities"),
717    ] {
718        let on: Vec<&str> = state
719            .iter()
720            .filter(|l| l.kind == kind && l.effective)
721            .map(|l| l.value.as_str())
722            .collect();
723        if !on.is_empty() {
724            v.push(format!(
725                "{what} {} (isb.db: isb superadmin add)",
726                on.join(", ")
727            ));
728        }
729    }
730    #[cfg(debug_assertions)]
731    if let Some(e) = &gate.dev {
732        v.push(format!(
733            "every loopback request without a credential, as {e} ({})",
734            crate::auth::dev::SUPERADMIN_ENV
735        ));
736    }
737    eprintln!("isb serve: superadmins: {}", v.join("; "));
738    for l in state.iter().filter(|l| !l.effective) {
739        eprintln!(
740            "isb serve: WARNING: superadmin {} {} (isb.db) is {}",
741            l.kind.as_str(),
742            l.value,
743            l.note.unwrap_or("not effective")
744        );
745    }
746}
747
748/// The superadmin-only tools.
749pub(super) fn register(r: &mut Registry, d: Arc<super::Daemon>) -> Result<()> {
750    let ro = json!({"readOnlyHint": true, "openWorldHint": false});
751    let destructive = json!({"destructiveHint": true, "openWorldHint": false});
752    let empty = || json!({"type": "object", "properties": {"org": {"type": "string"}}, "additionalProperties": false});
753    let dd = d.clone();
754    r.register(
755        Tool::new(
756            "host_inventory",
757            "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.",
758            empty(),
759            move |_a, _c| inventory(&dd),
760        )
761        .title("Host inventory")
762        .annotations(ro.clone()),
763    )?;
764    let dd = d.clone();
765    r.register(
766        Tool::new(
767            "host_policy",
768            "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.",
769            empty(),
770            move |_a, _c| {
771                let mut v = dd.host.clone();
772                v["superadmin"]["token_count"] =
773                    json!(dd.users.list_superadmin_tokens().map(|t| t.len()).unwrap_or(0));
774                v["superadmin"]["identities"] = json!(dd.gate.listing()?);
775                Ok(v)
776            },
777        )
778        .title("Host policy")
779        .annotations(ro.clone()),
780    )?;
781    let dd = d.clone();
782    r.register(
783        Tool::new(
784            "superadmin_token_list",
785            "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.",
786            empty(),
787            move |_a, _c| Ok(json!({"tokens": dd.users.list_superadmin_tokens()?})),
788        )
789        .title("Superadmin tokens")
790        .annotations(ro.clone()),
791    )?;
792    let dd = d.clone();
793    r.register(
794        Tool::new(
795            "superadmin_list",
796            "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.",
797            empty(),
798            move |_a, _c| Ok(json!({"identities": dd.gate.listing()?})),
799        )
800        .title("Superadmin identities")
801        .annotations(ro),
802    )?;
803    let dd = d;
804    r.register(
805        Tool::new(
806            "superadmin_token_revoke",
807            "Revoke a superadmin token by id; it stops working at once. Superadmins only.",
808            json!({"type": "object", "properties": {"id": {"type": "integer"}, "org": {"type": "string"}}, "required": ["id"], "additionalProperties": false}),
809            move |a, _c| {
810                let id = a
811                    .get("id")
812                    .and_then(Value::as_i64)
813                    .ok_or_else(|| Error::invalid("id: an integer"))?;
814                let t = dd.users.superadmin_token(id)?;
815                dd.users.revoke_superadmin_token(id)?;
816                Ok(json!({"revoked": t}))
817            },
818        )
819        .title("Revoke a superadmin token")
820        .annotations(destructive),
821    )?;
822    Ok(())
823}
824
825fn inventory(d: &super::Daemon) -> Result<Value> {
826    let projects = d.client.get("/1.0/projects?recursion=1")?;
827    let projects: Vec<Value> = projects
828        .as_array()
829        .map(|a| {
830            a.iter()
831                .map(|p| {
832                    let name = p["name"].as_str().unwrap_or("");
833                    json!({
834                        "name": name,
835                        "description": p["description"],
836                        "org": crate::org::OrgId::from_incus_project(name).map(|o| o.to_string()),
837                        "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),
838                    })
839                })
840                .collect()
841        })
842        .unwrap_or_default();
843    let instances = d
844        .client
845        .get("/1.0/instances?recursion=2&all-projects=true")?;
846    let instances: Vec<Value> = instances
847        .as_array()
848        .map(|a| {
849            a.iter()
850                .map(|i| {
851                    let project = i["project"].as_str().unwrap_or("default");
852                    let cfg = &i["config"];
853                    let label = |k: &str| cfg.get(format!("user.{k}")).cloned().unwrap_or(Value::Null);
854                    let addresses: Vec<String> = i["state"]["network"]
855                        .as_object()
856                        .map(|n| {
857                            n.iter()
858                                .filter(|(k, _)| k.as_str() != "lo")
859                                .flat_map(|(_, v)| v["addresses"].as_array().cloned().unwrap_or_default())
860                                .filter(|a| a["scope"] == "global")
861                                .filter_map(|a| a["address"].as_str().map(String::from))
862                                .collect()
863                        })
864                        .unwrap_or_default();
865                    let stack = label("isb.stack");
866                    let owner = label(super::LABEL_OWNER);
867                    json!({
868                        "name": i["name"],
869                        "project": project,
870                        "org": crate::org::OrgId::from_incus_project(project).map(|o| o.to_string()),
871                        "type": i["type"],
872                        "status": i["status"],
873                        "created_at": i["created_at"],
874                        "image": cfg.get("image.description").cloned().unwrap_or(Value::Null),
875                        "addresses": addresses,
876                        "stack": stack,
877                        "owner": owner,
878                        "managed": !stack.is_null() || !owner.is_null(),
879                    })
880                })
881                .collect()
882        })
883        .unwrap_or_default();
884    Ok(json!({"projects": projects, "instances": instances}))
885}
886
887#[cfg(test)]
888mod tests;