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