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