1use 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#[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 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 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#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)]
81pub struct Listed {
82 pub kind: AgentKind,
83 pub value: String,
84 pub source: &'static str,
87 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
103pub enum Resolved {
105 None,
107 Refused,
109 Superadmin(Arc<Superadmin>),
110}
111
112pub struct Gate {
113 store: Arc<AuthStore>,
114 tailnet: Option<Tailnet>,
115 tailnet_listens: Vec<String>,
117 access_agents: Option<(Arc<AccessValidator>, Vec<String>)>,
120 access: Option<(Arc<AccessValidator>, AccessAllowList, Vec<String>)>,
125 #[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 #[cfg(debug_assertions)]
157 pub fn with_dev(mut self, email: Option<String>) -> Gate {
158 self.dev = email;
159 self
160 }
161
162 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 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 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 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 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 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 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 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 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 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 #[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 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 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
532pub 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
544pub 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
553fn 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
560pub 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 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 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#[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
658pub 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
686pub 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
749pub(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;