1macro_rules! tool {
18 ($r:expr, $ctx:expr, $name:expr, $title:expr, $desc:expr, $schema:expr, $ann:expr, $f:expr) => {{
19 let ctx = $ctx.clone();
20 let f = $f;
21 $r.register(
22 Tool::new($name, $desc, $schema, move |a, c| f(&ctx, a, c))
23 .title($title)
24 .annotations($ann.clone()),
25 )?;
26 }};
27}
28
29mod accounts;
30pub mod apps;
31pub mod audit;
32mod authorize;
33pub mod builds;
34pub mod data;
35mod default_org;
36mod dns;
37mod egress;
38mod kube;
39mod monitors;
40mod notify;
41mod orgs;
42pub mod policy;
43pub mod previews;
44mod secret_hooks;
45pub mod secrets;
46mod servers;
47mod ssh;
48mod stack_deploy;
49pub mod superadmin;
50pub mod templates;
51mod terminal;
52mod tools;
53pub mod volumes;
54pub mod workspaces;
55
56use std::collections::BTreeMap;
57use std::path::PathBuf;
58use std::sync::Arc;
59use std::time::{Duration, Instant};
60
61use serde::Deserialize;
62use serde::de::DeserializeOwned;
63use serde_json::{Value, json};
64
65use crate::auth::AuthConfig;
66use crate::auth::AuthStore;
67use crate::auth::http::{ApiConfig, AuthApi};
68use crate::client::Client;
69use crate::error::{Error, Result};
70use crate::exec::{ExecOptions, Stdin};
71use crate::sandbox::{EnsureOptions, Sandbox, SandboxInfo};
72use crate::server::{AccessValidator, Caller, Listener, Registry, Tool, ToolPolicy};
73use crate::spec::SandboxSpec;
74use crate::stack::{Controller, Store, now_secs};
75use authorize::{CROSS_ORG_READS, PLATFORM_TOOLS, arg_org, authorize_class, tool_listed};
76use policy::RemotePolicy;
77use stack_deploy::{DeployArgs, How, deploy, stack_deploy};
78#[cfg(test)]
79use stack_deploy::{reused_note, stack_owner};
80use tools::Ann;
81
82pub const LABEL_OWNER: &str = "isb.owner";
84
85#[derive(Debug, Clone)]
87pub struct ServeConfig {
88 pub listen: Vec<String>,
92 pub socket: PathBuf,
93 pub access: Option<(String, String)>,
95 pub allow_unauthenticated: bool,
97 pub remote_tools: ToolPolicy,
99 pub policy: RemotePolicy,
100 pub state_dir: PathBuf,
101 pub interval: Duration,
102 pub keys: crate::secrets::KeySources,
104 pub secrets_config: PathBuf,
106 pub auth: AuthConfig,
108 pub public_url: Option<String>,
111 pub oauth: crate::auth::oauth::OAuthSettings,
113 pub open_signup: bool,
115 pub ingress: Option<crate::ingress::IngressConfig>,
117 pub workspace_mcp_port: u16,
120 pub workspace_pool: Option<String>,
123 pub workspace_home_root: Option<PathBuf>,
126 pub preview_domain: Option<workspaces::PreviewBase>,
128 pub audit_retention: Duration,
130 pub audit_all: bool,
132 pub history_retention: Duration,
134 pub history_max_rows: i64,
135 pub agent: Option<AgentConfig>,
138 pub superadmin_tailnet: Option<crate::server::tailnet::AllowList>,
141 pub superadmin_access: Option<superadmin::AccessAllowList>,
144 pub dev_superadmin: Option<String>,
147 pub heartbeat: Option<crate::monitor::heartbeat::Heartbeat>,
149 pub egress_pins: Vec<String>,
152 pub egress_ca: Vec<PathBuf>,
154}
155
156#[derive(Debug, Clone)]
158pub struct AgentConfig {
159 pub listen: String,
161 pub tls_dir: PathBuf,
163}
164
165fn auth_routes(
169 cfg: &ServeConfig,
170 store: Arc<AuthStore>,
171 secrets: &Arc<crate::secrets::Secrets>,
172 log: &Arc<crate::audit::AuditLog>,
173 gate: Arc<superadmin::Gate>,
174) -> Result<crate::server::Routes> {
175 use crate::auth::oauth::SecretFn;
176 let default_org = crate::org::OrgId::default_org();
177 let lookup = |name: &str| -> Option<SecretFn> {
178 secrets.inspect(&default_org, name).ok()?;
179 let (s, org, name) = (secrets.clone(), default_org.clone(), name.to_string());
180 Some(Arc::new(move || {
181 let (v, _) = s.get(&org, &name).map_err(|e| e.to_string())?;
182 String::from_utf8(v)
183 .map(|v| v.trim().to_string())
184 .map_err(|_| "the secret is not UTF-8".to_string())
185 }))
186 };
187 let (providers, notes) = cfg.oauth.providers(&lookup);
188 for n in notes {
189 eprintln!("isb serve: {n}");
190 }
191 let path = crate::auth::db_path(&cfg.state_dir);
192 let agent_ways = gate.agent_ways().with_public_url(cfg.public_url.clone());
193 let api = AuthApi::new(
194 store.clone(),
195 ApiConfig {
196 agent: Some(gate.agent_fn()),
197 agent_ways,
198 public_url: cfg.public_url.clone(),
199 notifier: None,
200 setup_token_file: Some(cfg.state_dir.join("setup-token")),
201 edge: Some(gate.edge_fn()),
202 providers,
203 open_signup: cfg.open_signup,
204 audit: Some(log.clone()),
205 superadmin: Some(Arc::new(move |r: &crate::server::http::Request| match gate
206 .resolve(r, None)
207 {
208 superadmin::Resolved::Superadmin(s) => Some(s),
209 _ => None,
210 })),
211 },
212 )?;
213 eprintln!("isb serve: identity store {}", path.display());
214 if !crate::web::BUILT {
215 eprintln!(
216 "isb serve: this binary was built without the web UI (a placeholder page is served)"
217 );
218 }
219 let (auth, web) = (Arc::new(api).router(), crate::web::routes());
222 let tail = audit::stream_route(log.clone(), store);
223 Ok(Arc::new(move |r| {
224 auth(r).or_else(|| tail(r)).or_else(|| web(r))
225 }))
226}
227
228struct Daemon {
229 client: Client,
230 ctl: Controller,
231 policy: RemotePolicy,
232 state_dir: PathBuf,
233 secrets: Arc<crate::secrets::Secrets>,
234 apps: crate::app::Apps,
235 ingress: Option<Arc<crate::ingress::Manager>>,
236 users: Arc<AuthStore>,
238 notifier: crate::notify::Notifier,
239 monitors: crate::monitor::Monitors,
240 history: crate::metrics_history::History,
241 data: data::Ctx,
243 volumes: crate::volume_backup::VolumeBackups,
245 audit: Arc<crate::audit::AuditLog>,
246 servers: Option<Arc<crate::servers::Servers>>,
249 gate: Arc<superadmin::Gate>,
251 host: Value,
252 catalogs: Arc<crate::template::catalog::Catalogs>,
254 workspaces: Arc<workspaces::Workspaces>,
257 public_url: Option<String>,
259 egress: Arc<isb_egress::Manager>,
261 meta: crate::stack::deployments::StackMeta,
263}
264
265#[expect(
267 clippy::too_many_lines,
268 reason = "predates the lint ratchet; split it when next changed"
269)]
270pub fn serve(client: Client, cfg: ServeConfig) -> Result<()> {
271 client
272 .server_info()
273 .map_err(|e| Error::invalid(format!("isb serve needs incusd: {e}")))?;
274 default_org::warn_old_incus(&client);
275 let store = Store::open(&cfg.state_dir)?;
276 dns::open_dns_path(&cfg.state_dir);
277 if cfg.agent.is_none() {
280 default_org::ensure(&client, &store);
281 }
282 let secrets_config = crate::secrets::SecretsConfig::load(&cfg.secrets_config)?;
283 let opened = crate::secrets::Secrets::open(&cfg.state_dir, &cfg.keys, &secrets_config)?;
284 for n in &opened.notes {
285 eprintln!("isb serve: {n}");
286 }
287 let secrets = Arc::new(with_external_drivers(opened.secrets, &cfg.state_dir)?);
288 for r in crate::stack::migrate::run(&store, &secrets, Some(&client)) {
291 match r {
292 Ok(m) => eprintln!("isb serve: {m}"),
293 Err(e) => eprintln!("isb serve: WARNING: {e}"),
294 }
295 }
296 let db = crate::auth::db_path(&cfg.state_dir);
299 let users = Arc::new(
300 AuthStore::open_with(&db, cfg.auth.clone())
301 .map_err(|e| Error::invalid(format!("open {}: {e}", db.display())))?,
302 );
303 let audit_db = crate::audit::db_path(&cfg.state_dir);
304 let audit_log = Arc::new(
305 crate::audit::AuditLog::open(&audit_db, cfg.audit_retention)?
306 .with_history_limits(cfg.history_retention, cfg.history_max_rows),
307 );
308 let recorder = crate::history::Recorder::start(audit_log.clone());
311 let stop_history = Arc::new(std::sync::atomic::AtomicBool::new(false));
312 history_start(&audit_log, &recorder, &client, &stop_history);
313 eprintln!(
314 "isb serve: audit log {} (kept {} days{})",
315 audit_db.display(),
316 cfg.audit_retention.as_secs() / 86400,
317 if cfg.audit_all {
318 ", reads included"
319 } else {
320 ""
321 }
322 );
323 if cfg.agent.is_some() && !cfg.listen.is_empty() {
324 return Err(Error::invalid(
325 "--agent serves its control plane only: drop --listen (users reach the control plane)",
326 ));
327 }
328 let access = match &cfg.access {
329 Some((team, aud)) => Some(Arc::new(AccessValidator::new(team, aud)?)),
330 None => None,
331 };
332 let gate = Arc::new(superadmin::gate(&cfg, users.clone(), access.clone())?);
333 let auth = if cfg.listen.is_empty() {
334 None
335 } else {
336 Some(auth_routes(
337 &cfg,
338 users.clone(),
339 &secrets,
340 &audit_log,
341 gate.clone(),
342 )?)
343 };
344 let servers = match &cfg.agent {
345 None => Some(crate::servers::Servers::open(&cfg.state_dir)?),
346 Some(_) => None,
347 };
348 match crate::registry::Registry::open(&client, Some(&cfg.state_dir)) {
351 Ok(Some(r)) => {
352 eprintln!("isb serve: local registry {}", r.info().url());
353 crate::registry::install(Arc::new(r));
354 }
355 Ok(None) => {}
356 Err(e) => eprintln!("isb serve: WARNING: local registry: {e}"),
357 }
358 let ingress = match &cfg.ingress {
361 Some(ic) => Some(crate::ingress::Manager::new(
362 ic.clone(),
363 client.clone(),
364 secrets.clone(),
365 &cfg.state_dir,
366 )?),
367 None => None,
368 };
369 let observer = ingress
370 .clone()
371 .map(|m| m as Arc<dyn crate::stack::controller::Observer>);
372 let ctl = Controller::start_with(
373 client.clone(),
374 store,
375 cfg.interval,
376 secrets.clone(),
377 observer,
378 )?;
379 if let Some(m) = &ingress {
380 m.start(ctl.clone())?;
381 }
382 let apps = crate::app::Apps::new(&cfg.state_dir, client.clone(), ctl.clone(), secrets.clone());
383 let stacks: Vec<(crate::org::OrgId, String)> = ctl
387 .definitions()
388 .iter()
389 .map(|d| (d.org.clone(), d.name.clone()))
390 .collect();
391 for r in apps.adopt_compose_stacks(&stacks) {
392 match r {
393 Ok(m) => eprintln!("isb serve: {m}"),
394 Err(e) => eprintln!("isb serve: WARNING: {e}"),
395 }
396 }
397 ctl.set_dns_scope(apps.scope_fn());
399 let ra = apps.clone();
401 let resolve: crate::notify::Resolve = Arc::new(move |org, stack, service| {
402 let a = ra.get(org, service).ok()?;
403 let own = a.spec.stack().ok()? == stack;
405 let preview = stack.starts_with(&format!("{}-", a.spec.project))
406 && crate::app::preview::is_pr_suffix(stack);
407 (own || preview).then_some(a.spec.project)
408 });
409 let notifier = crate::notify::Notifier::new(&cfg.state_dir, secrets.clone(), resolve)?;
410 notifier.start(ctl.clone());
411 let monitors = monitors::start(&cfg, &apps, &secrets, ¬ifier);
412 ctl.set_event_sink(recorder.controller_sink());
414 let history = crate::metrics_history::History::new(&cfg.state_dir);
416 ctl.set_metrics_sink(history.start());
417 apps.start_preview_upkeep();
419 let jobs = crate::jobs::Jobs::new(&cfg.state_dir, apps.clone());
421 let backups = crate::backup::Backups::new(&cfg.state_dir, apps.clone());
422 let volumes =
423 crate::volume_backup::VolumeBackups::new(&cfg.state_dir, apps.clone(), backups.clone());
424 let scheduler = crate::jobs::Scheduler::start(vec![
425 Arc::new(jobs.clone()) as Arc<dyn crate::jobs::Scheduled>,
426 Arc::new(backups.clone()),
427 Arc::new(volumes.clone()),
428 ]);
429 jobs.set_scheduler(scheduler.clone());
430 backups.set_scheduler(scheduler.clone());
431 volumes.set_scheduler(scheduler.clone());
432 if let Some(ic) = &cfg.ingress {
433 if ic.tunnel_port == cfg.workspace_mcp_port {
434 return Err(Error::invalid(format!(
435 "--workspace-mcp-port {} is the ingress's tunnel port; pick another",
436 cfg.workspace_mcp_port
437 )));
438 }
439 }
440 let workspaces = workspaces::Workspaces::new(
441 &cfg.state_dir,
442 client.clone(),
443 secrets.clone(),
444 recorder.clone(),
445 cfg.workspace_mcp_port,
446 cfg.workspace_pool.clone(),
447 cfg.workspace_home_root.clone(),
448 );
449 workspaces.previews.set_base(cfg.preview_domain.clone());
450 let (egress, stop_egress) = egress::start(&cfg, &client, &secrets)?;
451 let meta = crate::stack::deployments::StackMeta::new(&cfg.state_dir);
452 meta.recover();
453 let d = Arc::new(Daemon {
454 client,
455 ctl: ctl.clone(),
456 policy: cfg.policy.clone(),
457 state_dir: cfg.state_dir.clone(),
458 secrets,
459 apps: apps.clone(),
460 ingress: ingress.clone(),
461 users: users.clone(),
462 notifier: notifier.clone(),
463 monitors: monitors.clone(),
464 history,
465 data: data::Ctx {
466 apps: apps.clone(),
467 jobs,
468 backups,
469 },
470 volumes,
471 audit: audit_log.clone(),
472 servers: servers.clone(),
473 gate: gate.clone(),
474 host: superadmin::host_summary(&cfg, &gate),
475 catalogs: Arc::new(crate::template::catalog::Catalogs::new(&cfg.state_dir)),
476 workspaces: workspaces.clone(),
477 public_url: cfg.public_url.clone(),
478 egress,
479 meta,
480 });
481 if let Some(s) = &servers {
482 s.start(ctl.clone());
483 }
484 let registry = registry(d.clone())?;
485 let mut hooks = hooks(d.clone(), users.clone(), cfg.allow_unauthenticated);
486 superadmin::announce(&cfg, &gate, &users);
487 hooks.audit = Some(audit::hook(audit_log.clone(), cfg.audit_all));
488 if servers.is_some() {
489 hooks.route = Some(servers::route(d.clone()));
490 }
491 let webhooks = {
494 let w = apps::webhook_routes(apps.clone());
495 let w = match &servers {
496 Some(s) => servers::forward_webhooks(w, s.clone()),
497 None => w,
498 };
499 audit::audited_webhooks(w, audit_log.clone())
500 };
501 let auth = auth.map(|a| -> crate::server::Routes {
503 let logo = templates::logo::route(
504 d.catalogs.clone(),
505 Arc::new(templates::logo::Logos::new(&cfg.state_dir)),
506 templates::logo::admit(
507 hooks.authn.clone().expect("the daemon authenticates"),
508 access.clone(),
509 cfg.allow_unauthenticated,
510 ),
511 );
512 Arc::new(move |r| logo(r).or_else(|| a(r)))
513 });
514 let mut listeners = vec![Listener::unix(&cfg.socket).hooks(hooks.clone())];
515 if let Some(ac) = &cfg.agent {
516 listeners.push(servers::agent_listener(
517 d.clone(),
518 &hooks,
519 ac,
520 webhooks.clone(),
521 )?);
522 }
523 for addr in &cfg.listen {
524 let tailnet = superadmin::is_tailnet_listen(addr);
525 let mut l = Listener::tcp(addr.clone())
526 .policy(cfg.remote_tools.clone())
527 .hooks(hooks.clone())
528 .public_routes(webhooks.clone())
529 .preview(workspaces::preview_route(d.clone()))
530 .tailnet(tailnet);
531 if let Some(r) = &auth {
532 l = l.routes(r.clone());
533 }
534 listeners.push(match &access {
535 Some(v) if !tailnet => l.access_shared(v.clone()),
537 _ => l.allow_unauthenticated(true),
541 });
542 }
543 let hd = d.clone();
544 let healthz: crate::server::Healthz = Arc::new(move || {
545 let stacks: Vec<Value> = hd
546 .ctl
547 .list()
548 .into_iter()
549 .map(|s| json!({"name": s.name, "converged": s.converged}))
550 .collect();
551 (
552 true,
553 json!({"ok": true, "isb": env!("CARGO_PKG_VERSION"), "stacks": stacks}),
554 )
555 });
556 let registry = Arc::new(registry);
560 workspaces.set_serving(
561 Listener::tcp("org-bridge")
562 .policy(cfg.remote_tools.clone())
563 .hooks(hooks.clone())
564 .allow_unauthenticated(true),
565 registry.clone(),
566 healthz.clone(),
567 );
568 let local: workspaces::LocalOrg = {
569 let d = d.clone();
570 Arc::new(move |o: &crate::org::OrgId| d.remote(o).is_none())
571 };
572 workspaces.start(ctl.clone(), local);
573 workspaces::start_ports(d.clone());
574 let r = crate::server::serve_shared(listeners, registry, healthz);
575 workspaces.shutdown();
576 stop_egress.store(true, std::sync::atomic::Ordering::Relaxed);
577 stop_history.store(true, std::sync::atomic::Ordering::Relaxed);
578 recorder.record(crate::history::marker(
579 "serve.stopped",
580 "isb serve stopped: incus events from now on are not observed".into(),
581 json!({"version": env!("CARGO_PKG_VERSION")}),
582 ));
583 recorder.shutdown();
584 if let Some(s) = &servers {
585 s.shutdown();
586 }
587 notifier.shutdown();
588 monitors.shutdown();
589 scheduler.shutdown();
590 ctl.shutdown();
591 if let Some(m) = &ingress {
592 m.shutdown();
593 }
594 r
595}
596
597fn history_start(
600 log: &Arc<crate::audit::AuditLog>,
601 rec: &Arc<crate::history::Recorder>,
602 client: &Client,
603 stop: &Arc<std::sync::atomic::AtomicBool>,
604) {
605 use crate::history::{HistoryQuery, marker};
606 let now = crate::audit::now_ms();
607 let last = log
608 .history_list(
609 &HistoryQuery::default(),
610 &crate::audit::Visibility::All,
611 None,
612 None,
613 1,
614 )
615 .ok()
616 .and_then(|v| v.into_iter().next());
617 if let Some(l) = last {
618 let clean = l.kind == "serve.stopped";
619 let reason = if clean {
620 "isb serve was not running"
621 } else {
622 "isb serve was not running (it did not stop cleanly)"
623 };
624 rec.record(marker(
625 "incus.gap",
626 format!(
627 "incus events between {} and {} were not observed: {reason}",
628 crate::history::fmt_ms(l.time),
629 crate::history::fmt_ms(now),
630 ),
631 json!({"from": l.time, "to": now, "reason": reason}),
632 ));
633 }
634 rec.record(marker(
635 "serve.started",
636 format!("isb serve {} started", env!("CARGO_PKG_VERSION")),
637 json!({"version": env!("CARGO_PKG_VERSION"), "pid": std::process::id()}),
638 ));
639 let (c, r, s) = (client.clone(), rec.clone(), stop.clone());
640 let _ = std::thread::Builder::new()
641 .name("isb-incus-events".into())
642 .spawn(move || crate::history::watch_incus(c, r, s));
643}
644
645fn with_external_drivers(
647 secrets: crate::secrets::Secrets,
648 state_dir: &std::path::Path,
649) -> Result<crate::secrets::Secrets> {
650 use crate::secrets::{Driver, local::LocalDriver, onepassword};
651 let local = Arc::new(LocalDriver::new(state_dir, secrets.keyring().clone()));
652 let token: onepassword::TokenSource =
653 Arc::new(move |org| match local.get(org, onepassword::TOKEN_SECRET) {
654 Ok((v, _)) => Ok(Some(
655 String::from_utf8(v)
656 .map_err(|_| Error::invalid("the 1Password token is not text"))?
657 .trim()
658 .to_string(),
659 )),
660 Err(e) if e.is_not_found() => Ok(None),
661 Err(e) => Err(e),
662 });
663 secrets.with_driver(Arc::new(onepassword::OnePasswordDriver::new(token)))
664}
665
666fn visible_orgs(c: &Caller) -> Option<Vec<crate::org::OrgId>> {
668 match c.principal() {
669 Some(p) if !p.platform_admin => Some(p.orgs.iter().map(|(o, _)| o.clone()).collect()),
670 _ => None,
671 }
672}
673
674fn hooks(d: Arc<Daemon>, users: Arc<AuthStore>, allow_anonymous: bool) -> crate::server::Hooks {
676 use crate::server::Authenticated;
677 let term = terminal::terminal(d.clone());
678 let ssh = ssh::ssh(d.clone(), users.clone());
679 let u = users.clone();
680 let gate = d.gate.clone();
681 let wsa = d.workspaces.clone();
682 let authn: crate::server::mcp::Authn = Arc::new(move |req, id| {
683 match gate.resolve(req, id) {
684 superadmin::Resolved::Superadmin(s) => return Authenticated::Superadmin(s),
685 superadmin::Resolved::Refused => return Authenticated::Refused,
686 superadmin::Resolved::None => {}
687 }
688 if let Some(t) = bearer(req) {
690 if t.starts_with(crate::auth::secret::TokenKind::Workspace.prefix()) {
691 return match wsa.authenticate(t) {
692 Some(p) => Authenticated::User(Arc::new(p)),
693 None => Authenticated::Refused,
694 };
695 }
696 }
697 if req.header("authorization").is_some()
698 || req
699 .header("cookie")
700 .is_some_and(|c| c.contains("isb_session="))
701 {
702 return match u.principal_from_request(req) {
703 Some(p) => Authenticated::User(Arc::new(p)),
704 None => Authenticated::Refused,
705 };
706 }
707 if let Some(email) = id.and_then(|i| i.email.as_deref()) {
709 if let Ok(Some(p)) = u.principal_for_email(email) {
710 return Authenticated::User(Arc::new(p));
711 }
712 }
713 if let Some(p) = gate.agent(req, id) {
715 return Authenticated::User(Arc::new(p));
716 }
717 Authenticated::None
718 });
719 let authorize: crate::server::mcp::Authorize = Arc::new(move |c, tool, args, scope| {
720 let args = crate::server::aliases::alias_args(tool, args);
722 authorize_class(
723 c,
724 &tool.name,
725 audit::class_for(tool, &args),
726 args,
727 scope,
728 allow_anonymous,
729 )
730 });
731 let events: crate::server::mcp::Events = Arc::new(move |c, since| {
732 if let (Caller::Unauthenticated { .. }, false) = (c, allow_anonymous) {
733 return Err(Error::Forbidden("sign in to follow events".into()));
734 }
735 if let Caller::Access(id) = c {
736 return Err(Error::Forbidden(format!(
737 "{} has no isb account",
738 id.name()
739 )));
740 }
741 let orgs = visible_orgs(c);
742 let ctl = d.ctl.clone();
743 Ok(Box::new(move |w: &mut dyn std::io::Write| {
744 let mut since = since;
745 loop {
746 let (seq, evs) = ctl.wait_events(since, 200, Duration::from_secs(15));
747 let mut wrote = false;
748 for e in evs {
749 if !event_visible(&orgs, &e.stack) {
750 continue;
751 }
752 let data = serde_json::to_string(&e).unwrap_or_default();
753 write!(w, "id: {}\nevent: {}\ndata: {data}\n\n", e.seq, e.level)?;
754 wrote = true;
755 }
756 if !wrote {
757 w.write_all(b": keepalive\n\n")?;
759 }
760 w.flush()?;
761 since = seq.max(since);
762 }
763 }))
764 });
765 crate::server::Hooks {
766 authn: Some(authn),
767 authorize: Some(authorize),
768 events: Some(events),
769 terminal: Some(term),
770 ssh: Some(ssh),
771 audit: None,
772 route: None,
773 listed: Some(Arc::new(tool_listed)),
774 refuse_anonymous: !allow_anonymous,
775 }
776}
777
778fn bearer(req: &crate::server::http::Request) -> Option<&str> {
780 let (scheme, token) = req.header("authorization")?.trim().split_once(' ')?;
781 scheme.eq_ignore_ascii_case("bearer").then(|| token.trim())
782}
783
784fn event_visible(orgs: &Option<Vec<crate::org::OrgId>>, stack: &str) -> bool {
786 let Some(orgs) = orgs else { return true };
787 let org = stack
788 .split_once('/')
789 .map(|(o, _)| o)
790 .unwrap_or(crate::org::DEFAULT_ORG);
791 orgs.iter().any(|o| o.as_str() == org)
792}
793
794fn args<T: DeserializeOwned>(v: Value) -> Result<T> {
795 serde_json::from_value(v).map_err(|e| Error::invalid(format!("bad arguments: {e}")))
796}
797
798fn obj(mut props: Value, required: &[&str]) -> Value {
799 props["org"] =
801 json!({"type": "string", "description": "The org to act in (default: default)."});
802 json!({"type": "object", "properties": props, "required": required, "additionalProperties": false})
803}
804
805fn qname(org: &Option<String>, name: &str) -> Result<String> {
807 let org = match org {
808 Some(o) => crate::org::OrgId::new(o.clone())?,
809 None => crate::org::OrgId::default_org(),
810 };
811 Ok(crate::stack::qualified(&org, name))
812}
813
814fn caller_name(c: &Caller) -> String {
815 c.to_string()
816}
817
818fn registry(d: Arc<Daemon>) -> Result<Registry> {
820 let mut r = Registry::new().instructions(INSTRUCTIONS);
821 superadmin::register(&mut r, d.clone())?;
822 let ann = Ann {
823 ro: json!({"readOnlyHint": true, "openWorldHint": false}),
824 destructive: json!({"destructiveHint": true, "openWorldHint": false}),
825 write: json!({"destructiveHint": false, "openWorldHint": false}),
826 };
827
828 tools::stack_deploy_tool(&mut r, &d, &ann)?;
829 tools::overview_tool(&mut r, &d, &ann)?;
830 tools::events_tool(&mut r, &d, &ann)?;
831 tools::ingress_status_tool(&mut r, &d, &ann)?;
832 tools::stack_list_tool(&mut r, &d, &ann)?;
833 tools::stack_status_tool(&mut r, &d, &ann)?;
834 tools::stack_config_tool(&mut r, &d, &ann)?;
835 tools::stack_logs_tool(&mut r, &d, &ann)?;
836 tools::stack_scale_tool(&mut r, &d, &ann)?;
837 tools::stack_edit_tools(&mut r, &d, &ann)?;
838 tools::sandbox_create_tool(&mut r, &d, &ann)?;
839 secret_hooks::register(&mut r, &d)?;
840 builds::register(
841 &mut r,
842 builds::Ctx {
843 client: d.client.clone(),
844 policy: d.policy.clone(),
845 ctl: d.ctl.clone(),
846 },
847 )?;
848 tools::sandbox_tools(&mut r, &d, &ann)?;
849 apps::register(&mut r, d.apps.clone(), d.ingress.is_some())?;
850 previews::register(&mut r, d.apps.clone())?;
851 let mut t = templates::Templates::new(
852 &d.state_dir,
853 d.apps.clone(),
854 d.secrets.clone(),
855 d.ingress.as_ref().and_then(|m| m.public_ip()),
856 );
857 t.catalogs = d.catalogs.clone();
858 templates::register(&mut r, t)?;
859 data::register(&mut r, d.data.clone())?;
860 volumes::register(&mut r, d.volumes.clone())?;
861 tools::server_status_tool(&mut r, &d, &ann)?;
862 orgs::register(&mut r, d.clone())?;
863 notify::register(
864 &mut r,
865 d.notifier.clone(),
866 d.history.clone(),
867 d.apps.clone(),
868 )?;
869 monitors::register(&mut r, d.monitors.clone())?;
870 audit::register(&mut r, d.audit.clone())?;
871 audit::register_history(&mut r, d.audit.clone())?;
872 servers::register(&mut r, d.clone())?;
873 ssh::register(&mut r, d.clone())?;
874 workspaces::register(&mut r, d.clone())?;
875 accounts::register(&mut r, d.clone())?;
876 Ok(r)
877}
878
879const INSTRUCTIONS: &str = "isb runs incus containers and VMs on this host. Two uses: \
880stacks (long-running services from a docker-compose-style file, with replicas, health checks, \
881rolling updates and a load balancer: stack_deploy, then stack_status) and sandboxes \
882(an isolated machine to run code in: sandbox_create, sandbox_exec, sandbox_remove). \
883Images: local incus aliases (dev-base), images:debian/12, OCI images (docker:nginx:1.27, ghcr:org/app:tag), \
884or the org's own builds in the local registry (registry:APP:TAG; build_run makes them, registry_list lists them). \
885Deploys return immediately; poll stack_status, or pass wait=true. \
886Each org also has a secret store (secret_create, secret_set, secret_list; values are base64). \
887Apps (Dokploy-style): project_create, then app_create (an image, or a repository with a builder), \
888app_env_set, app_deploy (or app_apply: a YAML definition that creates or updates, dry_run to diff first); each project environment runs as one stack <project>-<env>. \
889One-click apps: template_list, template_get, then template_deploy (dry_run first shows the plan). \
890Databases are apps too (database_create; connection details via database_get), backed up to S3-compatible \
891destinations on a cron schedule (backup_destination_create, backup_create, backup_run, backup_restore). \
892Scheduled jobs run commands against an app on a cron schedule (job_create, job_runs, job_run_log).";
893
894impl Daemon {
895 fn reachable(&self, c: &Caller, i: &SandboxInfo) -> bool {
898 c.is_trusted()
901 || c.principal().is_some()
902 || self.policy.any_instance
903 || i.config.contains_key("user.isb.stack")
904 || i.config.contains_key(&format!("user.{LABEL_OWNER}"))
905 }
906
907 fn oc(&self, org: &Option<String>) -> Result<Client> {
910 let org = crate::org::OrgId::new(org.as_deref().unwrap_or(crate::org::DEFAULT_ORG))?;
911 crate::org::check_exists(&self.client, &org)?;
912 Ok(crate::org::client(&self.client, &org))
913 }
914
915 fn reach(&self, c: &Caller, oc: &Client, name: &str) -> Result<SandboxInfo> {
916 let info = Sandbox::get(oc, name)?.info()?;
917 if !self.reachable(c, &info) {
918 return Err(Error::NotFound(format!("sandbox {name}")));
921 }
922 Ok(info)
923 }
924
925 fn workspaces_def(
928 &self,
929 org: &crate::org::OrgId,
930 name: &str,
931 ) -> Result<crate::workspace::Workspace> {
932 crate::workspace::Store::new(&self.state_dir)
933 .get(org, name)?
934 .ok_or_else(|| Error::NotFound(format!("org {org} has no workspace {name}")))
935 }
936
937 fn files_dir(&self, stack: &str) -> Result<PathBuf> {
938 let p = self.state_dir.join("files").join(stack);
939 std::fs::create_dir_all(&p)?;
940 Ok(p)
941 }
942}
943
944pub fn wait_settled(
947 ctl: &Controller,
948 name: &str,
949 timeout: Duration,
950) -> Result<crate::stack::controller::StackStatus> {
951 let started = Instant::now();
952 let def = ctl.definition(name)?;
953 loop {
954 let st = ctl.status(name)?;
955 let settled = st.services.iter().all(|s| {
958 let current = def.revision(&s.service).is_ok_and(|r| r == s.rev)
959 && def
960 .service(&s.service)
961 .is_ok_and(|d| d.replicas() == s.replicas);
962 current && matches!(s.state.as_str(), "converged" | "paused" | "failing")
963 });
964 if settled || started.elapsed() >= timeout {
965 return Ok(st);
966 }
967 std::thread::sleep(Duration::from_secs(1));
968 }
969}
970
971#[derive(Deserialize)]
972#[serde(untagged)]
973enum SpecArg {
974 Text(String),
975 Object(Box<SandboxSpec>),
976}
977
978#[expect(
979 clippy::too_many_lines,
980 reason = "predates the lint ratchet; split it when next changed"
981)]
982fn sandbox_create(d: &Daemon, a: Value, c: &Caller) -> Result<Value> {
983 #[derive(Deserialize)]
984 struct A {
985 spec: Value,
986 #[serde(default)]
987 wait_ready: Option<bool>,
988 #[serde(default)]
989 expires: Option<String>,
990 #[serde(default)]
991 idle_timeout: Option<String>,
992 #[serde(default)]
993 org: Option<String>,
994 }
995 let org = arg_org(&a)?;
996 let a: A = args(a)?;
997 let mut spec = match serde_json::from_value::<SpecArg>(a.spec)
998 .map_err(|e| Error::invalid(format!("spec: {e}")))?
999 {
1000 SpecArg::Text(t) => serde_yaml_ng::from_str::<SandboxSpec>(&t)
1001 .map_err(|e| Error::invalid(format!("spec: {e}")))?,
1002 SpecArg::Object(s) => *s,
1003 };
1004 let name = spec
1005 .name
1006 .clone()
1007 .ok_or_else(|| Error::invalid("spec needs container_name"))?;
1008 egress::check_secrets(&d.secrets, &org, &spec)?;
1009 let base = if c.is_local() {
1010 std::env::current_dir()?
1011 } else {
1012 d.files_dir("_sandboxes")?
1013 };
1014 if let Caller::Superadmin(s) = c {
1015 spec.labels.insert(LABEL_OWNER.into(), s.label());
1017 }
1018 if !c.is_trusted() {
1019 d.policy.check_spec(&spec, &base)?;
1020 if let Ok(sb) = Sandbox::get(&d.oc(&a.org)?, &name) {
1021 if !d.reachable(c, &sb.info()?) {
1023 return Err(Error::AlreadyExists(name));
1024 }
1025 }
1026 spec.labels.insert(LABEL_OWNER.into(), owner_label(c));
1027 }
1028 if let Ok(sb) = Sandbox::get(&d.oc(&a.org)?, &name) {
1029 let info = sb.info()?;
1030 if info.config.contains_key(crate::workspace::KEY_WORKSPACE) {
1032 return Err(Error::invalid(format!(
1033 "{name} is the org's workspace; pick another name"
1034 )));
1035 }
1036 }
1037 if !spec
1040 .raw_devices
1041 .get("root")
1042 .is_some_and(|r| r.contains_key("size"))
1043 && workspaces::project_has_disk_limit(&d.client, &org)
1044 {
1045 spec.raw_devices
1046 .entry("root".into())
1047 .or_default()
1048 .insert("size".into(), workspaces::SANDBOX_ROOT_SIZE.into());
1049 }
1050 let settings = d.workspaces.settings(&org)?;
1052 let (expires_at, idle) = crate::workspace::sandbox_deadlines(
1053 &settings,
1054 a.expires.as_deref(),
1055 a.idle_timeout.as_deref(),
1056 now_secs(),
1057 )?;
1058 spec.labels
1059 .insert("isb.expires_at".into(), expires_at.to_string());
1060 match idle {
1061 Some(s) => {
1062 spec.labels.insert("isb.idle_timeout".into(), s.to_string());
1063 }
1064 None => {
1065 spec.labels.insert("isb.idle_timeout".into(), "0".into());
1066 }
1067 }
1068 let opts = EnsureOptions {
1069 wait_ready: a.wait_ready.unwrap_or(true),
1070 ..Default::default()
1071 };
1072 let mut log: Vec<String> = Vec::new();
1073 let (sb, report) = Sandbox::connect_or_create_with_base(
1074 &d.oc(&a.org)?,
1075 &spec,
1076 &Default::default(),
1077 &base,
1078 opts,
1079 &mut |m| log.push(m.to_string()),
1080 )?;
1081 d.workspaces.mark_active(&org.incus_project(), &name);
1082 d.egress.kick();
1083 Ok(json!({
1084 "info": sb.info()?,
1085 "report": report,
1086 "log": log,
1087 "expires_at": expires_at,
1088 "idle_timeout": idle,
1089 "message": format!(
1090 "{name} expires {} from now{}; sandbox_extend pushes it out.",
1091 crate::workspace::human(expires_at.saturating_sub(now_secs())),
1092 match idle {
1093 Some(s) => format!(" and is deleted after {} idle", crate::workspace::human(s)),
1094 None => String::new(),
1095 }
1096 ),
1097 }))
1098}
1099
1100fn owner_label(c: &Caller) -> String {
1102 match c {
1103 Caller::Superadmin(s) => s.label(),
1104 Caller::User { principal } if principal.is_workspace() => {
1105 crate::auth::WORKSPACE_ACTOR.to_string()
1106 }
1107 _ => format!("mcp:{}", caller_name(c)),
1108 }
1109}
1110
1111fn sandbox_extend(d: &Daemon, a: Value, c: &Caller) -> Result<Value> {
1112 #[derive(Deserialize)]
1113 #[serde(deny_unknown_fields)]
1114 struct A {
1115 name: String,
1116 #[serde(default)]
1117 by: Option<String>,
1118 #[serde(default)]
1119 idle_timeout: Option<String>,
1120 #[serde(default)]
1121 org: Option<String>,
1122 }
1123 let org = arg_org(&a)?;
1124 let a: A = args(a)?;
1125 let oc = d.oc(&a.org)?;
1126 let info = d.reach(c, &oc, &a.name)?;
1127 let labels: BTreeMap<String, String> = info
1128 .config
1129 .iter()
1130 .filter_map(|(k, v)| k.strip_prefix("user.").map(|k| (k.to_string(), v.clone())))
1131 .collect();
1132 if crate::workspace::kind_of(&labels) != "sandbox" {
1133 return Err(Error::invalid(format!(
1134 "{} is a {}, not a sandbox: it does not expire",
1135 a.name,
1136 crate::workspace::kind_of(&labels)
1137 )));
1138 }
1139 let mine = labels
1141 .get("isb.owner")
1142 .is_some_and(|o| *o == owner_label(c));
1143 let admin = match c {
1144 Caller::Local { .. } | Caller::Superadmin(_) => true,
1145 Caller::User { principal } => {
1146 principal.platform_admin
1147 || principal
1148 .role_in(&org)
1149 .is_some_and(|r| r >= crate::auth::Role::Admin)
1150 }
1151 _ => false,
1152 };
1153 if !mine && !admin {
1154 return Err(Error::Forbidden(format!(
1155 "{} was created by {}; its creator or the org's admins extend it",
1156 a.name,
1157 labels
1158 .get("isb.owner")
1159 .map(String::as_str)
1160 .unwrap_or("someone else")
1161 )));
1162 }
1163 let now = now_secs();
1164 let mut patch = serde_json::Map::new();
1165 let current = labels
1166 .get("isb.expires_at")
1167 .and_then(|v| v.parse::<u64>().ok());
1168 let by = match &a.by {
1169 Some(b) => crate::flex::parse_duration(b).map_err(Error::invalid)?,
1170 None if a.idle_timeout.is_some() => Duration::ZERO,
1171 None => Duration::from_secs(86400),
1172 };
1173 let mut expires_at = current;
1174 if !by.is_zero() {
1175 let e = crate::workspace::extended(current, by, now)?;
1176 patch.insert(
1177 crate::workspace::KEY_EXPIRES_AT.into(),
1178 json!(e.to_string()),
1179 );
1180 expires_at = Some(e);
1181 }
1182 let mut idle = labels
1183 .get("isb.idle_timeout")
1184 .and_then(|v| v.parse::<u64>().ok())
1185 .filter(|s| *s > 0);
1186 if let Some(t) = &a.idle_timeout {
1187 idle = crate::workspace::idle(t)?.map(|d| d.as_secs());
1188 patch.insert(
1189 crate::workspace::KEY_IDLE_TIMEOUT.into(),
1190 json!(idle.unwrap_or(0).to_string()),
1191 );
1192 }
1193 oc.mutate(
1194 "PATCH",
1195 &format!("/1.0/instances/{}", crate::client::encode_segment(&a.name)),
1196 Some(&json!({"config": patch})),
1197 &format!("extend sandbox {}", a.name),
1198 oc.timeouts.other,
1199 )?;
1200 d.workspaces.mark_active(&org.incus_project(), &a.name);
1201 Ok(json!({
1202 "name": a.name,
1203 "expires_at": expires_at,
1204 "idle_timeout": idle,
1205 "message": format!(
1206 "{} now expires {} from now.",
1207 a.name,
1208 crate::workspace::human(expires_at.unwrap_or(now).saturating_sub(now))
1209 ),
1210 }))
1211}
1212
1213const OUTPUT_CAP: usize = 256 * 1024;
1214
1215fn cap(b: &[u8]) -> (String, bool) {
1216 if b.len() <= OUTPUT_CAP {
1217 return (String::from_utf8_lossy(b).into_owned(), false);
1218 }
1219 (
1220 String::from_utf8_lossy(&b[b.len() - OUTPUT_CAP..]).into_owned(),
1221 true,
1222 )
1223}
1224
1225fn sandbox_exec(d: &Daemon, a: Value, c: &Caller) -> Result<Value> {
1226 #[derive(Deserialize)]
1227 struct A {
1228 name: String,
1229 #[serde(default)]
1230 org: Option<String>,
1231 argv: Vec<String>,
1232 cwd: Option<String>,
1233 user: Option<String>,
1234 #[serde(default)]
1235 env: BTreeMap<String, String>,
1236 stdin: Option<String>,
1237 timeout: Option<String>,
1238 }
1239 let org = arg_org(&a)?;
1240 let a: A = args(a)?;
1241 let oc = d.oc(&a.org)?;
1242 d.reach(c, &oc, &a.name)?;
1243 d.workspaces.mark_active(&org.incus_project(), &a.name);
1244 let timeout = match &a.timeout {
1245 Some(t) => crate::flex::parse_duration(t).map_err(Error::invalid)?,
1246 None => Duration::from_secs(600),
1247 };
1248 let mut opts = ExecOptions::default().timeout(timeout);
1249 opts.cwd = a.cwd;
1250 opts.user = a.user;
1251 opts.env = a.env;
1252 if let Some(s) = a.stdin {
1253 opts.stdin = Stdin::Bytes(s.into_bytes());
1254 }
1255 let sb = Sandbox::get(&oc, &a.name)?;
1256 let out = match sb.exec_with(a.argv, opts) {
1257 Err(Error::ExecTimeout { .. }) => {
1258 return Err(Error::invalid(format!(
1259 "timed out after {timeout:?} and was killed"
1260 )));
1261 }
1262 r => r?,
1263 };
1264 let (stdout, t1) = cap(&out.stdout);
1265 let (stderr, t2) = cap(&out.stderr);
1266 Ok(
1267 json!({"exit_code": out.exit_code, "stdout": stdout, "stderr": stderr, "truncated": t1 || t2}),
1268 )
1269}
1270
1271pub use crate::stack::local_deploy_args;
1272
1273pub fn default_state_dir() -> PathBuf {
1275 Store::default_dir()
1276}
1277
1278#[cfg(test)]
1279#[path = "agent_tests.rs"]
1280mod agent_tests;
1281
1282#[cfg(test)]
1283mod tests;
1284
1285#[cfg(test)]
1286mod downscope_tests;