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