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