1use std::sync::Arc;
9
10use serde_json::{Value, json};
11
12use crate::auth::agent_identities::{AgentKind, AgentWays};
13use crate::auth::edge::EdgeIdentity;
14use crate::auth::{AuthStore, Principal, Superadmin, SuperadminSource};
15use crate::error::{Error, Result};
16use crate::server::access::{ASSERTION_HEADER, AccessValidator, Identity};
17use crate::server::http::{Peer, Request};
18use crate::server::tailnet::{Tailnet, host_only};
19use crate::server::{Registry, Tool};
20
21#[derive(Debug, Clone, PartialEq, Eq, Default)]
23pub struct AccessAllowList {
24 pub emails: Vec<String>,
25 pub client_ids: Vec<String>,
26}
27
28impl AccessAllowList {
29 pub fn parse(list: &str) -> Result<AccessAllowList> {
32 let mut a = AccessAllowList::default();
33 for e in list.split(',').map(str::trim).filter(|e| !e.is_empty()) {
34 if e.contains(['*', '?', ' ', '\t']) || e.starts_with('@') || e.ends_with('@') {
35 return Err(Error::invalid(format!(
36 "--superadmin-access: {e:?}: exact emails or service token client ids only"
37 )));
38 }
39 if e.contains('@') {
40 a.emails.push(e.to_ascii_lowercase());
41 } else {
42 a.client_ids.push(e.to_string());
43 }
44 }
45 if a.emails.is_empty() && a.client_ids.is_empty() {
46 return Err(Error::invalid(
47 "--superadmin-access needs at least one email or service token client id",
48 ));
49 }
50 Ok(a)
51 }
52
53 pub fn admits(&self, id: &Identity) -> bool {
55 match (&id.email, &id.common_name) {
56 (Some(e), _) => self.emails.iter().any(|x| x.eq_ignore_ascii_case(e)),
57 (None, Some(cn)) => self.client_ids.iter().any(|x| x == cn),
58 (None, None) => false,
59 }
60 }
61
62 pub fn entries(&self) -> Vec<String> {
63 self.emails
64 .iter()
65 .chain(&self.client_ids)
66 .cloned()
67 .collect()
68 }
69}
70
71pub enum Resolved {
73 None,
75 Refused,
77 Superadmin(Arc<Superadmin>),
78}
79
80pub struct Gate {
81 store: Arc<AuthStore>,
82 tailnet: Option<Tailnet>,
83 tailnet_listens: Vec<String>,
85 access_agents: Option<(Arc<AccessValidator>, Vec<String>)>,
88 access: Option<(Arc<AccessValidator>, AccessAllowList, Vec<String>)>,
91 #[cfg(debug_assertions)]
94 dev: Option<String>,
95}
96
97impl Gate {
98 pub fn new(
99 store: Arc<AuthStore>,
100 tailnet: Option<Tailnet>,
101 access: Option<(Arc<AccessValidator>, AccessAllowList, Vec<String>)>,
102 ) -> Gate {
103 let access = access.map(|(v, a, hosts)| {
104 let mut hosts: Vec<String> = hosts.iter().map(|h| host_only(h)).collect();
105 hosts.sort();
106 hosts.dedup();
107 (v, a, hosts)
108 });
109 Gate {
110 store,
111 tailnet,
112 tailnet_listens: Vec::new(),
113 access_agents: None,
114 access,
115 #[cfg(debug_assertions)]
116 dev: None,
117 }
118 }
119
120 #[cfg(debug_assertions)]
123 pub fn with_dev(mut self, email: Option<String>) -> Gate {
124 self.dev = email;
125 self
126 }
127
128 pub fn with_agents(
132 mut self,
133 tailnet_listens: Vec<String>,
134 access: Option<(Arc<AccessValidator>, Vec<String>)>,
135 ) -> Gate {
136 self.tailnet_listens = tailnet_listens;
137 self.access_agents = access.map(|(v, hosts)| {
138 let mut hosts: Vec<String> = hosts.iter().map(|h| host_only(h)).collect();
139 hosts.sort();
140 hosts.dedup();
141 (v, hosts)
142 });
143 self
144 }
145
146 pub fn agent_ways(&self) -> AgentWays {
148 AgentWays {
149 tailnet_listen: if self.tailnet.is_some() {
150 self.tailnet_listens.clone()
151 } else {
152 Vec::new()
153 },
154 access: self.access_agents.is_some(),
155 public_url: None,
156 superadmin_access: self
157 .access_list()
158 .map(AccessAllowList::entries)
159 .unwrap_or_default(),
160 superadmin_tailnet: self
161 .tailnet
162 .as_ref()
163 .map(|t| t.allow().entries())
164 .unwrap_or_default(),
165 }
166 }
167
168 pub fn agent(&self, req: &Request, id: Option<&Identity>) -> Option<Principal> {
174 if req.header("authorization").is_some() {
175 return None;
176 }
177 let looked = if matches!(&req.peer, Peer::Tcp(a) if a.ip().is_loopback()) {
178 let (v, hosts) = self.access_agents.as_ref()?;
179 let verified;
180 let id = match id {
181 Some(id) => id,
182 None => {
183 let t = req.header(ASSERTION_HEADER)?.trim();
184 verified = v.validate(t).ok()?;
185 &verified
186 }
187 };
188 if !hosts.is_empty() {
189 let host = req.header("host").map(host_only).unwrap_or_default();
190 if !hosts.contains(&host) {
191 eprintln!(
192 "isb serve: Access agent {} sent Host {host:?}, not one of this server's names; not an agent",
193 id.name()
194 );
195 return None;
196 }
197 }
198 self.store
199 .principal_for_access_agent(id.email.as_deref(), id.common_name.as_deref())
200 } else {
201 let w = self.tailnet.as_ref()?.identify(req)?;
202 self.store.principal_for_tailnet(&w.login, &w.node, &w.tags)
203 };
204 looked.unwrap_or_else(|e| {
205 eprintln!("isb serve: agent identities: {e}");
206 None
207 })
208 }
209
210 pub fn edge(&self, req: &Request, id: Option<&Identity>) -> Option<EdgeIdentity> {
216 if req.header("authorization").is_some() {
217 return None;
218 }
219 if matches!(&req.peer, Peer::Tcp(a) if a.ip().is_loopback()) {
220 let (v, hosts) = self.access_agents.as_ref()?;
221 let verified;
222 let id = match id {
223 Some(id) => id,
224 None => {
225 let t = req.header(ASSERTION_HEADER)?.trim();
226 verified = v.validate(t).ok()?;
227 &verified
228 }
229 };
230 let email = id.email.as_deref()?.to_ascii_lowercase();
231 if !hosts.is_empty() {
232 let host = req.header("host").map(host_only).unwrap_or_default();
233 if !hosts.contains(&host) {
234 return None;
235 }
236 }
237 let can_claim = self.access_list().is_none_or(|a| a.admits(id));
238 return Some(EdgeIdentity {
239 kind: AgentKind::Access,
240 subject: id.sub.clone(),
241 name: email.clone(),
242 email: Some(email),
243 node: None,
244 can_claim,
245 });
246 }
247 let t = self.tailnet.as_ref()?;
248 let w = t.identify(req)?;
249 if !w.tags.is_empty() {
250 return None;
251 }
252 let listed = !t.allow().entries().is_empty();
253 Some(EdgeIdentity {
254 kind: AgentKind::Tailnet,
255 subject: w.login.clone(),
256 name: w.login.clone(),
257 email: crate::auth::edge::login_email(&w.login),
258 node: Some(w.node.clone()),
259 can_claim: !listed || t.allow().admits(&w),
260 })
261 }
262
263 pub fn agent_fn(self: &Arc<Self>) -> crate::auth::http::AgentFn {
265 let g = self.clone();
266 Arc::new(move |r: &Request| g.agent(r, None))
267 }
268
269 pub fn edge_fn(self: &Arc<Self>) -> crate::auth::edge::EdgeFn {
270 let g = self.clone();
271 Arc::new(move |r: &Request| g.edge(r, None))
272 }
273
274 pub fn tailnet(&self) -> Option<&Tailnet> {
275 self.tailnet.as_ref()
276 }
277
278 pub fn access_list(&self) -> Option<&AccessAllowList> {
279 self.access.as_ref().map(|(_, a, _)| a)
280 }
281
282 pub fn resolve(&self, req: &Request, id: Option<&Identity>) -> Resolved {
287 if let Some(a) = req.header("authorization") {
288 let token = a
289 .trim()
290 .split_once(' ')
291 .filter(|(s, _)| s.eq_ignore_ascii_case("bearer"))
292 .map(|(_, t)| t.trim());
293 return match token {
294 Some(t) if t.starts_with(crate::auth::secret::TokenKind::Superadmin.prefix()) => {
295 match self.store.authenticate_superadmin_token(t) {
296 Ok(Some(info)) => Resolved::Superadmin(Arc::new(Superadmin::synthetic(
297 SuperadminSource::Token {
298 id: info.id,
299 name: info.name,
300 },
301 ))),
302 Ok(None) => Resolved::Refused,
303 Err(e) => {
304 eprintln!("isb serve: superadmin token: {e}");
305 Resolved::Refused
306 }
307 }
308 }
309 _ => Resolved::None,
310 };
311 }
312 if let Some(s) = self.access_superadmin(req, id) {
313 return Resolved::Superadmin(s);
314 }
315 if let Some(w) = self.tailnet.as_ref().and_then(|t| t.superadmin(req)) {
316 let source = SuperadminSource::Tailnet {
317 login: w.login.clone(),
318 node: w.node,
319 tags: w.tags.clone(),
320 };
321 let as_user = if w.tags.is_empty() {
322 Some(w.login.as_str())
323 } else {
324 None
325 };
326 return Resolved::Superadmin(Arc::new(self.acting_as(source, as_user)));
327 }
328 #[cfg(debug_assertions)]
329 if let Some(s) = self.dev_superadmin(req, id) {
330 return Resolved::Superadmin(s);
331 }
332 Resolved::None
333 }
334
335 #[cfg(debug_assertions)]
339 fn dev_superadmin(&self, req: &Request, id: Option<&Identity>) -> Option<Arc<Superadmin>> {
340 let email = self.dev.as_deref()?;
341 let loopback = matches!(&req.peer, Peer::Tcp(a) if a.ip().is_loopback());
342 let credential = id.is_some()
343 || req.header(ASSERTION_HEADER).is_some()
344 || crate::auth::http::cookie(req, crate::auth::http::COOKIE).is_some();
345 if !loopback || credential {
346 return None;
347 }
348 let source = SuperadminSource::Dev {
349 email: email.to_string(),
350 };
351 Some(Arc::new(self.acting_as(source, Some(email))))
352 }
353
354 fn access_superadmin(&self, req: &Request, id: Option<&Identity>) -> Option<Arc<Superadmin>> {
357 let (v, allow, hosts) = self.access.as_ref()?;
358 let loopback = matches!(&req.peer, Peer::Tcp(a) if a.ip().is_loopback());
359 if !loopback {
360 return None;
361 }
362 let verified;
363 let id = match id {
364 Some(id) => id,
365 None => {
366 let t = req.header(ASSERTION_HEADER)?.trim();
367 verified = v.validate(t).ok()?;
368 &verified
369 }
370 };
371 if !allow.admits(id) {
372 return None;
373 }
374 let host = req.header("host").map(host_only).unwrap_or_default();
375 if !hosts.contains(&host) {
376 eprintln!(
377 "isb serve: Access superadmin {} sent Host {host:?}, not one of this server's names; not a superadmin",
378 id.name()
379 );
380 return None;
381 }
382 let source = SuperadminSource::Access {
383 name: id.name().to_string(),
384 service_token: id.is_service_token(),
385 };
386 Some(Arc::new(self.acting_as(source, id.email.as_deref())))
387 }
388
389 fn acting_as(&self, source: SuperadminSource, email: Option<&str>) -> Superadmin {
391 let user = email
392 .and_then(|e| self.store.user_by_email(e).ok().flatten())
393 .filter(|u| !u.disabled);
394 match user {
395 Some(u) => Superadmin::as_user(source.clone(), u, &self.store)
396 .unwrap_or_else(|_| Superadmin::synthetic(source)),
397 None => Superadmin::synthetic(source),
398 }
399 }
400}
401
402pub const TOOLS: &[&str] = &[
405 "host_inventory",
406 "host_policy",
407 "superadmin_token_list",
408 "superadmin_token_revoke",
409 "org_nesting",
410];
411
412pub fn is_tailnet_listen(addr: &str) -> bool {
414 use std::net::ToSocketAddrs;
415 addr.to_socket_addrs().is_ok_and(|mut a| {
416 a.next()
417 .is_some_and(|a| crate::server::tailnet::is_tailnet_ip(a.ip()))
418 })
419}
420
421fn public_host(url: &str) -> Option<String> {
423 let rest = url.trim().split_once("://")?.1;
424 let host = rest.split(['/', '?', '#']).next()?;
425 (!host.is_empty()).then(|| host.to_string())
426}
427
428pub fn gate(
431 cfg: &super::ServeConfig,
432 store: Arc<AuthStore>,
433 access: Option<Arc<AccessValidator>>,
434) -> Result<Gate> {
435 let tailnet_listens: Vec<&String> =
436 cfg.listen.iter().filter(|a| is_tailnet_listen(a)).collect();
437 let public = cfg.public_url.as_deref().and_then(public_host);
438 if cfg.superadmin_tailnet.is_some() && tailnet_listens.is_empty() {
439 eprintln!(
440 "isb serve: WARNING: --superadmin-tailnet without a tailnet --listen address: no tailnet peer can reach this daemon"
441 );
442 }
443 let tailnet = (cfg.superadmin_tailnet.is_some() || !tailnet_listens.is_empty()).then(|| {
446 let allow = cfg.superadmin_tailnet.clone().unwrap_or_default();
447 let mut hosts: Vec<String> = tailnet_listens.iter().map(|a| a.to_string()).collect();
448 hosts.extend(crate::server::tailnet::self_names());
449 hosts.extend(public.clone());
450 crate::server::tailnet::Tailnet::new(allow, crate::server::tailnet::system_fetcher(), hosts)
451 });
452 let mut access_hosts: Vec<String> = public.iter().cloned().collect();
453 access_hosts.extend(cfg.listen.iter().filter(|a| !is_tailnet_listen(a)).cloned());
454 let agent_access = access.clone().map(|v| {
455 let hosts = if public.is_some() {
456 access_hosts
457 } else {
458 Vec::new()
459 };
460 (v, hosts)
461 });
462 let access = match (&cfg.superadmin_access, access) {
463 (None, _) => None,
464 (Some(_), None) => {
465 return Err(Error::invalid(
466 "--superadmin-access needs Cloudflare Access (CF_ACCESS_TEAM_DOMAIN and CF_ACCESS_AUD): it trusts only a verified assertion",
467 ));
468 }
469 (Some(list), Some(v)) => {
470 let Some(host) = public.clone() else {
471 return Err(Error::invalid(
472 "--superadmin-access needs --public-url: an Access superadmin's request must name this server's host",
473 ));
474 };
475 let mut hosts = vec![host];
476 hosts.extend(cfg.listen.iter().filter(|a| !is_tailnet_listen(a)).cloned());
477 Some((v, list.clone(), hosts))
478 }
479 };
480 let listens = tailnet_listens.iter().map(|a| a.to_string()).collect();
481 let gate = Gate::new(store, tailnet, access).with_agents(listens, agent_access);
482 #[cfg(debug_assertions)]
483 let gate = gate.with_dev(dev_superadmin(cfg)?);
484 #[cfg(not(debug_assertions))]
485 if cfg.dev_superadmin.is_some() {
486 return Err(Error::invalid(format!(
487 "{} works only in debug builds: unset it",
488 crate::auth::dev::SUPERADMIN_ENV
489 )));
490 }
491 Ok(gate)
492}
493
494#[cfg(debug_assertions)]
497fn dev_superadmin(cfg: &super::ServeConfig) -> Result<Option<String>> {
498 use std::net::ToSocketAddrs;
499 let Some(email) = cfg.dev_superadmin.clone() else {
500 return Ok(None);
501 };
502 let env = crate::auth::dev::SUPERADMIN_ENV;
503 let loopback = |a: &String| {
504 a.to_socket_addrs()
505 .is_ok_and(|mut it| it.all(|s| s.ip().is_loopback()))
506 };
507 if cfg.listen.is_empty() || !cfg.listen.iter().all(loopback) {
508 return Err(Error::invalid(format!(
509 "{env} signs every request without a credential in as a superadmin: --listen must be loopback only (it is {:?})",
510 cfg.listen
511 )));
512 }
513 eprintln!(
514 "isb serve: WARNING: {env}={email}: every HTTP request without a credential is superadmin dev:{email}; for developing isb only"
515 );
516 Ok(Some(email))
517}
518
519pub fn host_summary(cfg: &super::ServeConfig, gate: &Gate) -> Value {
521 json!({
522 "isb": env!("CARGO_PKG_VERSION"),
523 "listen": cfg.listen,
524 "socket": cfg.socket,
525 "state_dir": cfg.state_dir,
526 "public_url": cfg.public_url,
527 "access": cfg.access.as_ref().map(|(team, aud)| json!({"team_domain": team, "aud": aud})),
528 "allow_unauthenticated": cfg.allow_unauthenticated,
529 "tools": {"allow": cfg.remote_tools.allow, "deny": cfg.remote_tools.deny},
530 "policy": {
531 "allow_privileged": cfg.policy.allow_privileged,
532 "allow_raw": cfg.policy.allow_raw,
533 "bind_roots": cfg.policy.bind_roots,
534 "publish_addresses": cfg.policy.publish_addresses,
535 "any_instance": cfg.policy.any_instance,
536 },
537 "superadmin": {
538 "socket": cfg.socket,
539 "tokens": true,
540 "tailnet": cfg.superadmin_tailnet.as_ref().and(gate.tailnet()).map(|t| t.allow().entries()),
541 "tailnet_hosts": gate.tailnet().map(|t| t.hosts().to_vec()),
542 "access": gate.access_list().map(AccessAllowList::entries),
543 },
544 })
545}
546
547pub fn announce(cfg: &super::ServeConfig, gate: &Gate, store: &AuthStore) {
549 let mut v = vec![format!("the unix socket {}", cfg.socket.display())];
550 if !cfg.listen.is_empty() {
551 let n = store.list_superadmin_tokens().map(|t| t.len()).unwrap_or(0);
552 v.push(format!(
553 "superadmin tokens ({n}; minted on this host with `isb token create NAME --superadmin`)"
554 ));
555 }
556 if let Some(t) = cfg.superadmin_tailnet.as_ref().and(gate.tailnet()) {
557 v.push(format!(
558 "tailnet identities {} (Host: {})",
559 t.allow().entries().join(", "),
560 t.hosts().join(", ")
561 ));
562 }
563 if let Some(a) = gate.access_list() {
564 v.push(format!(
565 "Cloudflare Access identities {}",
566 a.entries().join(", ")
567 ));
568 }
569 #[cfg(debug_assertions)]
570 if let Some(e) = &gate.dev {
571 v.push(format!(
572 "every loopback request without a credential, as {e} ({})",
573 crate::auth::dev::SUPERADMIN_ENV
574 ));
575 }
576 eprintln!("isb serve: superadmins: {}", v.join("; "));
577}
578
579pub(super) fn register(r: &mut Registry, d: Arc<super::Daemon>) -> Result<()> {
581 let ro = json!({"readOnlyHint": true, "openWorldHint": false});
582 let destructive = json!({"destructiveHint": true, "openWorldHint": false});
583 let empty = || json!({"type": "object", "properties": {"org": {"type": "string"}}, "additionalProperties": false});
584 let dd = d.clone();
585 r.register(
586 Tool::new(
587 "host_inventory",
588 "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.",
589 empty(),
590 move |_a, _c| inventory(&dd),
591 )
592 .title("Host inventory")
593 .annotations(ro.clone()),
594 )?;
595 let dd = d.clone();
596 r.register(
597 Tool::new(
598 "host_policy",
599 "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.",
600 empty(),
601 move |_a, _c| {
602 let mut v = dd.host.clone();
603 v["superadmin"]["token_count"] =
604 json!(dd.users.list_superadmin_tokens().map(|t| t.len()).unwrap_or(0));
605 Ok(v)
606 },
607 )
608 .title("Host policy")
609 .annotations(ro.clone()),
610 )?;
611 let dd = d.clone();
612 r.register(
613 Tool::new(
614 "superadmin_token_list",
615 "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.",
616 empty(),
617 move |_a, _c| Ok(json!({"tokens": dd.users.list_superadmin_tokens()?})),
618 )
619 .title("Superadmin tokens")
620 .annotations(ro),
621 )?;
622 let dd = d;
623 r.register(
624 Tool::new(
625 "superadmin_token_revoke",
626 "Revoke a superadmin token by id; it stops working at once. Superadmins only.",
627 json!({"type": "object", "properties": {"id": {"type": "integer"}, "org": {"type": "string"}}, "required": ["id"], "additionalProperties": false}),
628 move |a, _c| {
629 let id = a
630 .get("id")
631 .and_then(Value::as_i64)
632 .ok_or_else(|| Error::invalid("id: an integer"))?;
633 let t = dd.users.superadmin_token(id)?;
634 dd.users.revoke_superadmin_token(id)?;
635 Ok(json!({"revoked": t}))
636 },
637 )
638 .title("Revoke a superadmin token")
639 .annotations(destructive),
640 )?;
641 Ok(())
642}
643
644fn inventory(d: &super::Daemon) -> Result<Value> {
645 let projects = d.client.get("/1.0/projects?recursion=1")?;
646 let projects: Vec<Value> = projects
647 .as_array()
648 .map(|a| {
649 a.iter()
650 .map(|p| {
651 let name = p["name"].as_str().unwrap_or("");
652 json!({
653 "name": name,
654 "description": p["description"],
655 "org": crate::org::OrgId::from_incus_project(name).map(|o| o.to_string()),
656 "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),
657 })
658 })
659 .collect()
660 })
661 .unwrap_or_default();
662 let instances = d
663 .client
664 .get("/1.0/instances?recursion=2&all-projects=true")?;
665 let instances: Vec<Value> = instances
666 .as_array()
667 .map(|a| {
668 a.iter()
669 .map(|i| {
670 let project = i["project"].as_str().unwrap_or("default");
671 let cfg = &i["config"];
672 let label = |k: &str| cfg.get(format!("user.{k}")).cloned().unwrap_or(Value::Null);
673 let addresses: Vec<String> = i["state"]["network"]
674 .as_object()
675 .map(|n| {
676 n.iter()
677 .filter(|(k, _)| k.as_str() != "lo")
678 .flat_map(|(_, v)| v["addresses"].as_array().cloned().unwrap_or_default())
679 .filter(|a| a["scope"] == "global")
680 .filter_map(|a| a["address"].as_str().map(String::from))
681 .collect()
682 })
683 .unwrap_or_default();
684 let stack = label("isb.stack");
685 let owner = label(super::LABEL_OWNER);
686 json!({
687 "name": i["name"],
688 "project": project,
689 "org": crate::org::OrgId::from_incus_project(project).map(|o| o.to_string()),
690 "type": i["type"],
691 "status": i["status"],
692 "created_at": i["created_at"],
693 "image": cfg.get("image.description").cloned().unwrap_or(Value::Null),
694 "addresses": addresses,
695 "stack": stack,
696 "owner": owner,
697 "managed": !stack.is_null() || !owner.is_null(),
698 })
699 })
700 .collect()
701 })
702 .unwrap_or_default();
703 Ok(json!({"projects": projects, "instances": instances}))
704}
705
706#[cfg(test)]
707mod tests;