From 67e49f30a1f1e7a8eb923a3a263e65ba8ce01bbd Mon Sep 17 00:00:00 2001 From: Aiden McClelland Date: Mon, 2 Feb 2026 15:18:45 -0700 Subject: [PATCH] cli improvements fix group handling --- build/dpkg-deps/depends | 1 + core/Cargo.toml | 1 + core/locales/i18n.yaml | 41 +++- core/src/context/cli.rs | 4 + core/src/context/config.rs | 4 + core/src/context/rpc.rs | 14 +- core/src/error.rs | 57 +++-- core/src/install/mod.rs | 3 + core/src/lib.rs | 5 +- core/src/registry/package/add.rs | 42 ++-- core/src/s9pk/rpc.rs | 68 +++++- core/src/s9pk/v2/pack.rs | 3 +- core/src/service/effects/subcontainer/mod.rs | 22 +- core/src/service/effects/subcontainer/sync.rs | 228 +++++++++++------- core/src/service/mod.rs | 170 ++++++------- core/src/update/mod.rs | 2 + 16 files changed, 417 insertions(+), 248 deletions(-) diff --git a/build/dpkg-deps/depends b/build/dpkg-deps/depends index 9491a1d48..b50e5168b 100644 --- a/build/dpkg-deps/depends +++ b/build/dpkg-deps/depends @@ -46,6 +46,7 @@ openssh-server podman psmisc qemu-guest-agent +qemu-user-static rfkill rsync samba-common-bin diff --git a/core/Cargo.toml b/core/Cargo.toml index 506fc86c8..cdf3fe40a 100644 --- a/core/Cargo.toml +++ b/core/Cargo.toml @@ -176,6 +176,7 @@ mio = "1" new_mime_guess = "4" nix = { version = "0.30.1", features = [ "fs", + "hostname", "mount", "net", "process", diff --git a/core/locales/i18n.yaml b/core/locales/i18n.yaml index f0ac17ca5..7f6cd150e 100644 --- a/core/locales/i18n.yaml +++ b/core/locales/i18n.yaml @@ -1843,18 +1843,18 @@ service.mod.failed-to-parse-package-data-entry: pl_PL: "Nie udało się przeanalizować PackageDataEntry, znaleziono: %{error}" service.mod.no-matching-subcontainers: - en_US: "no matching subcontainers are running for %{id}; some possible choices are:\n%{subcontainers}" - de_DE: "keine passenden Subcontainer laufen für %{id}; einige mögliche Optionen sind:\n%{subcontainers}" - es_ES: "no hay subcontenedores coincidentes ejecutándose para %{id}; algunas opciones posibles son:\n%{subcontainers}" - fr_FR: "aucun sous-conteneur correspondant n'est en cours d'exécution pour %{id} ; voici quelques choix possibles :\n%{subcontainers}" - pl_PL: "nie działają pasujące podkontenery dla %{id}; niektóre możliwe wybory to:\n%{subcontainers}" + en_US: "no matching subcontainers are running for %{id}; some possible choices are:" + de_DE: "keine passenden Subcontainer laufen für %{id}; einige mögliche Optionen sind:" + es_ES: "no hay subcontenedores coincidentes ejecutándose para %{id}; algunas opciones posibles son:" + fr_FR: "aucun sous-conteneur correspondant n'est en cours d'exécution pour %{id} ; voici quelques choix possibles :" + pl_PL: "nie działają pasujące podkontenery dla %{id}; niektóre możliwe wybory to:" service.mod.multiple-subcontainers-found: - en_US: "multiple subcontainers found for %{id}: \n%{subcontainer_ids}" - de_DE: "mehrere Subcontainer für %{id} gefunden: \n%{subcontainer_ids}" - es_ES: "se encontraron múltiples subcontenedores para %{id}: \n%{subcontainer_ids}" - fr_FR: "plusieurs sous-conteneurs trouvés pour %{id} : \n%{subcontainer_ids}" - pl_PL: "znaleziono wiele podkontenerów dla %{id}: \n%{subcontainer_ids}" + en_US: "multiple subcontainers found for %{id}" + de_DE: "mehrere Subcontainer für %{id} gefunden" + es_ES: "se encontraron múltiples subcontenedores para %{id}" + fr_FR: "plusieurs sous-conteneurs trouvés pour %{id}" + pl_PL: "znaleziono wiele podkontenerów dla %{id}" service.mod.invalid-byte-length-for-signal: en_US: "invalid byte length for signal: %{length}" @@ -3703,6 +3703,20 @@ help.arg.wireguard-config: fr_FR: "Configuration WireGuard" pl_PL: "Konfiguracja WireGuard" +help.s9pk-s3base: + en_US: "Base URL for publishing s9pks" + de_DE: "Basis-URL für die Veröffentlichung von s9pks" + es_ES: "URL base para publicar s9pks" + fr_FR: "URL de base pour publier les s9pks" + pl_PL: "Bazowy URL do publikowania s9pks" + +help.s9pk-s3bucket: + en_US: "S3 bucket to publish s9pks to (should correspond to s3base)" + de_DE: "S3-Bucket zum Veröffentlichen von s9pks (sollte mit s3base übereinstimmen)" + es_ES: "Bucket S3 para publicar s9pks (debe corresponder con s3base)" + fr_FR: "Bucket S3 pour publier les s9pks (doit correspondre à s3base)" + pl_PL: "Bucket S3 do publikowania s9pks (powinien odpowiadać s3base)" + # CLI command descriptions (about.*) about.add-address-to-host: en_US: "Add an address to this host" @@ -4866,6 +4880,13 @@ about.persist-new-notification: fr_FR: "Persister une nouvelle notification" pl_PL: "Utrwal nowe powiadomienie" +about.publish-s9pk: + en_US: "Publish s9pk to S3 bucket and index on registry" + de_DE: "S9pk in S3-Bucket veröffentlichen und in Registry indizieren" + es_ES: "Publicar s9pk en bucket S3 e indexar en el registro" + fr_FR: "Publier s9pk dans le bucket S3 et indexer dans le registre" + pl_PL: "Opublikuj s9pk do bucketu S3 i zindeksuj w rejestrze" + about.rebuild-service-container: en_US: "Rebuild service container" de_DE: "Dienst-Container neu erstellen" diff --git a/core/src/context/cli.rs b/core/src/context/cli.rs index c1231c9ad..5e9e535aa 100644 --- a/core/src/context/cli.rs +++ b/core/src/context/cli.rs @@ -38,6 +38,8 @@ pub struct CliContextSeed { pub registry_url: Option, pub registry_hostname: Vec, pub registry_listen: Option, + pub s9pk_s3base: Option, + pub s9pk_s3bucket: Option, pub tunnel_addr: Option, pub tunnel_listen: Option, pub client: Client, @@ -129,6 +131,8 @@ impl CliContext { .transpose()?, registry_hostname: config.registry_hostname.unwrap_or_default(), registry_listen: config.registry_listen, + s9pk_s3base: config.s9pk_s3base, + s9pk_s3bucket: config.s9pk_s3bucket, tunnel_addr: config.tunnel, tunnel_listen: config.tunnel_listen, client: { diff --git a/core/src/context/config.rs b/core/src/context/config.rs index 755837c42..f47ee8110 100644 --- a/core/src/context/config.rs +++ b/core/src/context/config.rs @@ -68,6 +68,10 @@ pub struct ClientConfig { pub registry_hostname: Option>, #[arg(skip)] pub registry_listen: Option, + #[arg(long, help = "help.s9pk-s3base")] + pub s9pk_s3base: Option, + #[arg(long, help = "help.s9pk-s3bucket")] + pub s9pk_s3bucket: Option, #[arg(short = 't', long, help = "help.arg.tunnel-address")] pub tunnel: Option, #[arg(skip)] diff --git a/core/src/context/rpc.rs b/core/src/context/rpc.rs index db7082e37..a59d60236 100644 --- a/core/src/context/rpc.rs +++ b/core/src/context/rpc.rs @@ -579,6 +579,7 @@ impl RpcContext { pub async fn call_remote( &self, method: &str, + metadata: OrdMap<&'static str, Value>, params: Value, ) -> Result where @@ -587,7 +588,7 @@ impl RpcContext { >::call_remote( &self, method, - OrdMap::new(), + metadata, params, Empty {}, ) @@ -596,20 +597,15 @@ impl RpcContext { pub async fn call_remote_with( &self, method: &str, + metadata: OrdMap<&'static str, Value>, params: Value, extra: T, ) -> Result where Self: CallRemote, { - >::call_remote( - &self, - method, - OrdMap::new(), - params, - extra, - ) - .await + >::call_remote(&self, method, metadata, params, extra) + .await } } impl AsRef for RpcContext { diff --git a/core/src/error.rs b/core/src/error.rs index 09999f5e2..cd43e2476 100644 --- a/core/src/error.rs +++ b/core/src/error.rs @@ -4,7 +4,7 @@ use axum::http::StatusCode; use axum::http::uri::InvalidUri; use color_eyre::eyre::eyre; use num_enum::TryFromPrimitive; -use patch_db::Revision; +use patch_db::Value; use rpc_toolkit::reqwest; use rpc_toolkit::yajrc::{ INVALID_PARAMS_ERROR, INVALID_REQUEST_ERROR, METHOD_NOT_FOUND_ERROR, PARSE_ERROR, RpcError, @@ -16,6 +16,7 @@ use tokio_rustls::rustls; use ts_rs::TS; use crate::InvalidId; +use crate::prelude::to_value; #[derive(Debug, Clone, Copy, PartialEq, Eq, TryFromPrimitive)] #[repr(i32)] @@ -197,7 +198,7 @@ pub struct Error { pub source: color_eyre::eyre::Error, pub debug: Option, pub kind: ErrorKind, - pub revision: Option, + pub info: Value, pub task: Option>, } @@ -228,7 +229,7 @@ impl Error { source: source.into(), debug, kind, - revision: None, + info: Value::Null, task: None, } } @@ -237,7 +238,7 @@ impl Error { source: eyre!("{}", self.source), debug: self.debug.as_ref().map(|e| eyre!("{e}")), kind: self.kind, - revision: self.revision.clone(), + info: self.info.clone(), task: None, } } @@ -245,6 +246,10 @@ impl Error { self.task = Some(task); self } + pub fn with_info(mut self, info: Value) -> Self { + self.info = info; + self + } pub async fn wait(mut self) -> Self { if let Some(task) = &mut self.task { task.await.log_err(); @@ -423,6 +428,8 @@ impl From for Error { pub struct ErrorData { pub details: String, pub debug: String, + #[serde(default)] + pub info: Value, } impl Display for ErrorData { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { @@ -440,6 +447,7 @@ impl From for ErrorData { Self { details: value.to_string(), debug: format!("{:?}", value), + info: value.info, } } } @@ -470,40 +478,31 @@ impl From<&RpcError> for ErrorData { .or_else(|| d.as_str().map(|s| s.to_owned())) }) .unwrap_or_else(|| value.message.clone().into_owned()), + info: to_value( + &value + .data + .as_ref() + .and_then(|d| d.as_object().and_then(|d| d.get("info"))), + ) + .unwrap_or_default(), } } } impl From for RpcError { fn from(e: Error) -> Self { - let mut data_object = serde_json::Map::with_capacity(3); - data_object.insert("details".to_owned(), format!("{}", e.source).into()); - data_object.insert("debug".to_owned(), format!("{:?}", e.source).into()); - data_object.insert( - "revision".to_owned(), - match serde_json::to_value(&e.revision) { + let kind = e.kind; + let data = ErrorData::from(e); + RpcError { + code: kind as i32, + message: kind.as_str().into(), + data: Some(match serde_json::to_value(&data) { Ok(a) => a, Err(e) => { - tracing::warn!("Error serializing revision for Error object: {}", e); + tracing::warn!("Error serializing ErrorData object: {}", e); serde_json::Value::Null } - }, - ); - RpcError { - code: e.kind as i32, - message: e.kind.as_str().into(), - data: Some( - match serde_json::to_value(&ErrorData { - details: format!("{}", e.source), - debug: format!("{:?}", e.source), - }) { - Ok(a) => a, - Err(e) => { - tracing::warn!("Error serializing revision for Error object: {}", e); - serde_json::Value::Null - } - }, - ), + }), } } } @@ -606,7 +605,7 @@ where kind, source, debug, - revision: None, + info: Value::Null, task: None, } }) diff --git a/core/src/install/mod.rs b/core/src/install/mod.rs index c610505d9..5fe707463 100644 --- a/core/src/install/mod.rs +++ b/core/src/install/mod.rs @@ -131,6 +131,9 @@ pub async fn install( let package: GetPackageResponse = from_value( ctx.call_remote_with::( "package.get", + [("get_device_info", Value::Bool(true))] + .into_iter() + .collect(), json!({ "id": id, "targetVersion": VersionRange::exactly(version.deref().clone()), diff --git a/core/src/lib.rs b/core/src/lib.rs index 756f880ac..d7cfc79b4 100644 --- a/core/src/lib.rs +++ b/core/src/lib.rs @@ -540,7 +540,10 @@ pub fn package() -> ParentHandler { .with_about("about.execute-commands-container") .no_cli(), ) - .subcommand("attach", from_fn_async(service::cli_attach).no_display()) + .subcommand( + "attach", + from_fn_async_local(service::cli_attach).no_display(), + ) .subcommand( "host", net::host::host_api::().with_about("about.manage-network-hosts-package"), diff --git a/core/src/registry/package/add.rs b/core/src/registry/package/add.rs index 3c35c51e9..b8ab5c5c8 100644 --- a/core/src/registry/package/add.rs +++ b/core/src/registry/package/add.rs @@ -135,20 +135,24 @@ pub struct CliAddPackageParams { } pub async fn cli_add_package( - HandlerArgs { - context: ctx, - parent_method, - method, - params: - CliAddPackageParams { - file, - url, - no_verify, - }, - .. - }: HandlerArgs, + ctx: CliContext, + CliAddPackageParams { + file, + url, + no_verify, + }: CliAddPackageParams, ) -> Result<(), Error> { let s9pk = S9pk::open(&file, None).await?; + cli_add_package_impl(ctx, s9pk, url, no_verify).await +} + +pub async fn cli_add_package_impl( + ctx: CliContext, + s9pk: S9pk, + url: Vec, + no_verify: bool, +) -> Result<(), Error> { + let manifest = s9pk.as_manifest(); let progress = FullProgressTracker::new(); let mut sign_phase = progress.add_phase(InternedString::intern("Signing File"), Some(1)); @@ -170,8 +174,16 @@ pub async fn cli_add_package( Some(1), ); - let progress_task = - progress.progress_bar_task(&format!("Adding {} to registry...", file.display())); + let progress_task = progress.progress_bar_task(&format!( + "Adding {}@{}{} to registry...", + manifest.id, + manifest.version, + manifest + .hardware_requirements + .arch + .as_ref() + .map_or(String::new(), |a| format!(" ({})", a.iter().join("/"))) + )); sign_phase.start(); let commitment = s9pk.as_archive().commitment().await?; @@ -188,7 +200,7 @@ pub async fn cli_add_package( index_phase.start(); ctx.call_remote::( - &parent_method.into_iter().chain(method).join("."), + "package.add", imbl_value::json!({ "urls": &url, "signature": AnySignature::Ed25519(signature), diff --git a/core/src/s9pk/rpc.rs b/core/src/s9pk/rpc.rs index f1cc71ecc..4c9180dbe 100644 --- a/core/src/s9pk/rpc.rs +++ b/core/src/s9pk/rpc.rs @@ -1,10 +1,13 @@ +use std::ops::Deref; use std::path::PathBuf; use std::sync::Arc; use clap::Parser; use rpc_toolkit::{Empty, HandlerExt, ParentHandler, from_fn_async}; use serde::{Deserialize, Serialize}; +use tokio::process::Command; use ts_rs::TS; +use url::Url; use crate::ImageId; use crate::context::CliContext; @@ -13,9 +16,9 @@ use crate::s9pk::manifest::Manifest; use crate::s9pk::merkle_archive::source::multi_cursor_file::MultiCursorFile; use crate::s9pk::v2::SIG_CONTEXT; use crate::s9pk::v2::pack::ImageConfig; -use crate::util::Apply; use crate::util::io::{TmpDir, create_file, open_file}; use crate::util::serde::{HandlerExtSerde, apply_expr}; +use crate::util::{Apply, Invoke}; pub const SKIP_ENV: &[&str] = &["TERM", "container", "HOME", "HOSTNAME"]; @@ -61,6 +64,12 @@ pub fn s9pk() -> ParentHandler { .no_display() .with_about("about.convert-s9pk-v1-to-v2"), ) + .subcommand( + "publish", + from_fn_async(publish) + .no_display() + .with_about("about.publish-s9pk"), + ) } #[derive(Deserialize, Serialize, Parser)] @@ -256,3 +265,60 @@ async fn convert(ctx: CliContext, S9pkPath { s9pk: s9pk_path }: S9pkPath) -> Res tokio::fs::rename(tmp_path, s9pk_path).await?; Ok(()) } + +async fn publish(ctx: CliContext, S9pkPath { s9pk: s9pk_path }: S9pkPath) -> Result<(), Error> { + let filename = s9pk_path.file_name().unwrap().to_string_lossy(); + let s9pk = super::S9pk::open(&s9pk_path, None).await?; + let manifest = s9pk.as_manifest(); + let path = [ + manifest.id.deref(), + manifest.version.as_str(), + filename.deref(), + ]; + let mut s3url = ctx + .s9pk_s3base + .as_ref() + .ok_or_else(|| Error::new(eyre!("--s9pk-s3base required"), ErrorKind::InvalidRequest))? + .clone(); + s3url + .path_segments_mut() + .map_err(|_| { + Error::new( + eyre!("s9pk-s3base is invalid (missing protocol?)"), + ErrorKind::ParseUrl, + ) + })? + .pop_if_empty() + .extend(path); + + let mut s3dest = format!( + "s3://{}", + ctx.s9pk_s3bucket + .as_deref() + .or_else(|| s3url + .host_str() + .and_then(|h| h.split_once(".").map(|h| h.0))) + .ok_or_else(|| { + Error::new(eyre!("--s9pk-s3bucket required"), ErrorKind::InvalidRequest) + })?, + ) + .parse::()?; + s3dest + .path_segments_mut() + .map_err(|_| { + Error::new( + eyre!("s9pk-s3base is invalid (missing protocol?)"), + ErrorKind::ParseUrl, + ) + })? + .pop_if_empty() + .extend(path); + Command::new("s3cmd") + .arg("put") + .arg("-P") + .arg(s9pk_path) + .arg(s3dest.as_str()) + .invoke(ErrorKind::Network) + .await?; + crate::registry::package::add::cli_add_package_impl(ctx, s9pk, vec![s3url], false).await +} diff --git a/core/src/s9pk/v2/pack.rs b/core/src/s9pk/v2/pack.rs index 087197a08..667241eca 100644 --- a/core/src/s9pk/v2/pack.rs +++ b/core/src/s9pk/v2/pack.rs @@ -727,8 +727,9 @@ pub async fn pack(ctx: CliContext, params: PackParams) -> Result<(), Error> { arches.iter().join("/"), ); } + } else { + c.arch = filtered; } - c.arch = filtered; }); manifest.hardware_requirements.arch = Some(arches); } diff --git a/core/src/service/effects/subcontainer/mod.rs b/core/src/service/effects/subcontainer/mod.rs index 24a382291..a36cf6e87 100644 --- a/core/src/service/effects/subcontainer/mod.rs +++ b/core/src/service/effects/subcontainer/mod.rs @@ -10,6 +10,7 @@ use crate::rpc_continuations::Guid; use crate::service::effects::prelude::*; use crate::service::persistent_container::Subcontainer; use crate::util::Invoke; +use crate::util::io::write_file_owned_atomic; pub const NVIDIA_OVERLAY_PATH: &str = "/var/tmp/startos/nvidia-overlay"; pub const NVIDIA_OVERLAY_DEBIAN: &str = "/var/tmp/startos/nvidia-overlay/debian"; @@ -94,7 +95,7 @@ pub async fn create_subcontainer_fs( .cloned() { let guid = Guid::new(); - let rootfs_dir = context + let lxc_container = context .seed .persistent_container .lxc_container @@ -104,8 +105,9 @@ pub async fn create_subcontainer_fs( eyre!("PersistentContainer has been destroyed"), ErrorKind::Incoherent, ) - })? - .rootfs_dir(); + })?; + let container_guid = &lxc_container.guid; + let rootfs_dir = lxc_container.rootfs_dir(); let mountpoint = rootfs_dir .join("media/startos/subcontainers") .join(guid.as_ref()); @@ -154,6 +156,20 @@ pub async fn create_subcontainer_fs( .arg(&mountpoint) .invoke(ErrorKind::Filesystem) .await?; + write_file_owned_atomic( + mountpoint.join("etc/hostname"), + format!("{container_guid}\n"), + 100000, + 100000, + ) + .await?; + write_file_owned_atomic( + mountpoint.join("etc/hosts"), + format!("127.0.0.1\tlocalhost\n127.0.1.1\t{container_guid}\n::1\tlocalhost ip6-localhost ip6-loopback\n"), + 100000, + 100000, + ) + .await?; tracing::info!("Mounted overlay {guid} for {image_id}"); context .seed diff --git a/core/src/service/effects/subcontainer/sync.rs b/core/src/service/effects/subcontainer/sync.rs index 176203c03..6bd569cab 100644 --- a/core/src/service/effects/subcontainer/sync.rs +++ b/core/src/service/effects/subcontainer/sync.rs @@ -1,7 +1,6 @@ -use std::collections::BTreeMap; use std::ffi::{OsStr, OsString, c_int}; use std::fs::File; -use std::io::{IsTerminal, Read}; +use std::io::{BufRead, BufReader, IsTerminal, Read}; use std::os::unix::process::{CommandExt, ExitStatusExt}; use std::path::{Path, PathBuf}; use std::process::{Command as StdCommand, Stdio}; @@ -146,95 +145,160 @@ impl ExecParams { let mut cmd = StdCommand::new(command); - let passwd = std::fs::read_to_string(chroot.join("etc/passwd")) - .with_ctx(|_| (ErrorKind::Filesystem, "read /etc/passwd")) - .log_err() - .unwrap_or_default(); - let mut home = None; + let mut uid = Err(None); + let mut gid = Err(None); + let mut needs_home = true; - if let Some((uid, gid)) = - if let Some(uid) = user.as_deref().and_then(|u| u.parse::().ok()) { - Some((uid, uid)) - } else if let Some((uid, gid)) = user - .as_deref() - .and_then(|u| u.split_once(":")) - .and_then(|(u, g)| Some((u.parse::().ok()?, g.parse::().ok()?))) - { - Some((uid, gid)) - } else if let Some(user) = user { - Some( - if let Some((uid, gid)) = passwd.lines().find_map(|l| { - let l = l.trim(); - let mut split = l.split(":"); - if user != split.next()? { - return None; - } - - split.next(); // throw away x - let uid = split.next()?.parse().ok()?; - let gid = split.next()?.parse().ok()?; - split.next(); // throw away group name - - home = split.next(); - - Some((uid, gid)) - // uid gid - }) { - (uid, gid) - } else if user == "root" { - (0, 0) - } else { - None.or_not_found(lazy_format!("{user} in /etc/passwd"))? - }, - ) + if let Some(user) = user { + if let Some((u, g)) = user.split_once(":") { + uid = Err(Some(u)); + gid = Err(Some(g)); } else { - None + uid = Err(Some(user)); } - { - if home.is_none() { - home = passwd.lines().find_map(|l| { - let l = l.trim(); - let mut split = l.split(":"); - - split.next(); // throw away user name - split.next(); // throw away x - if split.next()?.parse::().ok()? != uid { - return None; - } - split.next(); // throw away gid - split.next(); // throw away group name - - split.next() - }) - }; - std::os::unix::fs::chown("/proc/self/fd/0", Some(uid), Some(gid)).ok(); - std::os::unix::fs::chown("/proc/self/fd/1", Some(uid), Some(gid)).ok(); - std::os::unix::fs::chown("/proc/self/fd/2", Some(uid), Some(gid)).ok(); - cmd.uid(uid); - cmd.gid(gid); - } else { - home = Some("/root"); } - cmd.env("HOME", home.unwrap_or("/")); - let env_string = if let Some(env_file) = &env_file { - std::fs::read_to_string(env_file) - .with_ctx(|_| (ErrorKind::Filesystem, lazy_format!("read {env:?}")))? - } else { - Default::default() + if let Some(u) = uid.err().flatten().and_then(|u| u.parse::().ok()) { + uid = Ok(u); + } + if let Some(g) = gid.err().flatten().and_then(|g| g.parse::().ok()) { + gid = Ok(g); + } + + let mut update_env = |line: &str| { + if let Some((k, v)) = line.split_once("=") { + needs_home &= k != "HOME"; + cmd.env(k, v); + } else { + tracing::warn!("Invalid line in env: {line}"); + } }; - let env = env_string - .lines() - .chain(env.iter().map(|l| l.as_str())) - .map(|l| l.trim()) - .filter_map(|l| l.split_once("=")) - .collect::>(); + if let Some(f) = env_file { + let mut lines = BufReader::new( + File::open(&f).with_ctx(|_| (ErrorKind::Filesystem, format!("open r {f:?}")))?, + ) + .lines(); + while let Some(line) = lines.next().transpose()? { + update_env(&line); + } + } + + for line in env { + update_env(&line); + } + + let needs_gid = Err(None) == gid; + let mut username = InternedString::intern("root"); + let mut handle_passwd_line = |line: &str| -> Option<()> { + let l = line.trim(); + let mut split = l.split(":"); + let user = split.next()?; + match uid { + Err(Some(u)) if u != user => return None, + _ => (), + } + split.next(); // throw away x + let u: u32 = split.next()?.parse().ok()?; + match uid { + Err(Some(_)) => uid = Ok(u), + Err(None) if u == 0 => uid = Ok(u), + Ok(uid) if uid != u => return None, + _ => (), + } + + username = user.into(); + + if !needs_gid && !needs_home { + return Some(()); + } + let g = split.next()?; + if needs_gid { + gid = Ok(g.parse().ok()?); + } + + if needs_home { + split.next(); // throw away group name + + let home = split.next()?; + + cmd.env("HOME", home); + } + + Some(()) + }; + + let mut lines = BufReader::new( + File::open(chroot.join("etc/passwd")) + .with_ctx(|_| (ErrorKind::Filesystem, format!("open r /etc/passwd")))?, + ) + .lines(); + while let Some(line) = lines.next().transpose()? { + if handle_passwd_line(&line).is_some() { + break; + } + } + + let mut groups = Vec::new(); + let mut handle_group_line = |line: &str| -> Option<()> { + let l = line.trim(); + let mut split = l.split(":"); + let name = split.next()?; + split.next()?; // throw away x + let g = split.next()?.parse::().ok()?; + match gid { + Err(Some(n)) if n == name => gid = Ok(g), + _ => (), + } + let users = split.next()?; + if users.split(",").any(|u| u == &*username) { + groups.push(nix::unistd::Gid::from_raw(g)); + } + Some(()) + }; + let mut lines = BufReader::new( + File::open(chroot.join("etc/group")) + .with_ctx(|_| (ErrorKind::Filesystem, format!("open r /etc/group")))?, + ) + .lines(); + while let Some(line) = lines.next().transpose()? { + if handle_group_line(&line).is_none() { + tracing::warn!("Invalid /etc/group line: {line}"); + } + } + std::os::unix::fs::chroot(chroot) .with_ctx(|_| (ErrorKind::Filesystem, lazy_format!("chroot {chroot:?}")))?; - cmd.args(args); - for (k, v) in env { - cmd.env(k, v); + if let Ok(uid) = uid { + if uid != 0 { + std::os::unix::fs::chown("/proc/self/fd/0", Some(uid), gid.ok()).ok(); + std::os::unix::fs::chown("/proc/self/fd/1", Some(uid), gid.ok()).ok(); + std::os::unix::fs::chown("/proc/self/fd/2", Some(uid), gid.ok()).ok(); + } } + // Handle credential changes in pre_exec to control the order: + // setgroups must happen before setgid/setuid (requires CAP_SETGID) + { + let set_uid = uid.ok(); + let set_gid = gid.ok(); + unsafe { + cmd.pre_exec(move || { + if !groups.is_empty() { + nix::unistd::setgroups(&groups) + .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?; + } + if let Some(gid) = set_gid { + nix::unistd::setgid(nix::unistd::Gid::from_raw(gid)) + .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?; + } + if let Some(uid) = set_uid { + nix::unistd::setuid(nix::unistd::Uid::from_raw(uid)) + .map_err(|e| std::io::Error::from_raw_os_error(e as i32))?; + } + Ok(()) + }); + } + } + cmd.args(args); if let Some(workdir) = workdir { cmd.current_dir(workdir); diff --git a/core/src/service/mod.rs b/core/src/service/mod.rs index cb6097c84..81aae3229 100644 --- a/core/src/service/mod.rs +++ b/core/src/service/mod.rs @@ -50,6 +50,7 @@ use crate::util::io::{AsyncReadStream, AtomicFile, TermSize, delete_file}; use crate::util::net::WebSocket; use crate::util::serde::Pem; use crate::util::sync::SyncMutex; +use crate::util::tui::choose; use crate::volume::data_dir; use crate::{ActionId, CAP_1_KiB, DATA_DIR, HostId, ImageId, PackageId}; @@ -709,6 +710,19 @@ pub async fn rebuild(ctx: RpcContext, RebuildParams { id }: RebuildParams) -> Re Ok(()) } +#[derive(Debug, Deserialize, Serialize)] +pub struct SubcontainerInfo { + pub id: Guid, + pub name: InternedString, + pub image_id: ImageId, +} +impl std::fmt::Display for SubcontainerInfo { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let SubcontainerInfo { id, name, image_id } = self; + write!(f, "{id} => Name: {name}; Image: {image_id}") + } +} + #[derive(Deserialize, Serialize, TS)] #[serde(rename_all = "camelCase")] pub struct AttachParams { @@ -722,7 +736,7 @@ pub struct AttachParams { #[serde(rename = "__Auth_session")] session: Option, #[ts(type = "string | null")] - subcontainer: Option, + subcontainer: Option, #[ts(type = "string | null")] name: Option, #[ts(type = "string | null")] @@ -745,7 +759,7 @@ pub async fn attach( user, }: AttachParams, ) -> Result { - let (container_id, subcontainer_id, image_id, workdir, root_command) = { + let (container_id, subcontainer_id, image_id, user, workdir, root_command) = { let id = &id; let service = ctx.services.get(id).await; @@ -786,13 +800,6 @@ pub async fn attach( } }) .collect(); - let format_subcontainer_pair = |(guid, wrapper): (&Guid, &Subcontainer)| { - format!( - "{guid} imageId: {image_id} name: \"{name}\"", - name = &wrapper.name, - image_id = &wrapper.image_id - ) - }; let Some((subcontainer_id, image_id)) = subcontainer_ids .first() .map::<(Guid, ImageId), _>(|&x| (x.0.clone(), x.1.image_id.clone())) @@ -803,19 +810,17 @@ pub async fn attach( .lock() .await .iter() - .map(format_subcontainer_pair) - .join("\n"); + .map(|(g, s)| SubcontainerInfo { + id: g.clone(), + name: s.name.clone(), + image_id: s.image_id.clone(), + }) + .collect::>(); return Err(Error::new( - eyre!( - "{}", - t!( - "service.mod.no-matching-subcontainers", - id = id, - subcontainers = subcontainers - ) - ), + eyre!("{}", t!("service.mod.no-matching-subcontainers", id = id)), ErrorKind::NotFound, - )); + ) + .with_info(to_value(&subcontainers)?)); }; let passwd = root_dir @@ -835,38 +840,39 @@ pub async fn attach( ) .with_kind(ErrorKind::Deserialization)?; - let root_command = get_passwd_command( - passwd, - user.as_deref() - .or_else(|| image_meta["user"].as_str()) - .unwrap_or("root"), - ) - .await; + let user = user + .clone() + .or_else(|| image_meta["user"].as_str().map(InternedString::intern)) + .unwrap_or_else(|| InternedString::intern("root")); + + let root_command = get_passwd_command(passwd, &*user).await; let workdir = image_meta["workdir"].as_str().map(|s| s.to_owned()); if subcontainer_ids.len() > 1 { - let subcontainer_ids = subcontainer_ids + let subcontainers = subcontainer_ids .into_iter() - .map(format_subcontainer_pair) - .join("\n"); + .map(|(g, s)| SubcontainerInfo { + id: g.clone(), + name: s.name.clone(), + image_id: s.image_id.clone(), + }) + .collect::>(); return Err(Error::new( eyre!( "{}", - t!( - "service.mod.multiple-subcontainers-found", - id = id, - subcontainer_ids = subcontainer_ids - ) + t!("service.mod.multiple-subcontainers-found", id = id,) ), ErrorKind::InvalidRequest, - )); + ) + .with_info(to_value(&subcontainers)?)); } ( service_ref.container_id()?, subcontainer_id, image_id, + user.into(), workdir, root_command, ) @@ -883,7 +889,7 @@ pub async fn attach( pty_size: Option, image_id: ImageId, workdir: Option, - user: Option, + user: InternedString, root_command: &RootCommand, ) -> Result<(), Error> { use axum::extract::ws::Message; @@ -904,11 +910,9 @@ pub async fn attach( Path::new("/media/startos/images") .join(image_id) .with_extension("env"), - ); - - if let Some(user) = user { - cmd.arg("--user").arg(&*user); - } + ) + .arg("--user") + .arg(&*user); if let Some(workdir) = workdir { cmd.arg("--workdir").arg(workdir); @@ -1091,45 +1095,6 @@ pub async fn attach( Ok(guid) } -#[derive(Deserialize, Serialize, TS)] -#[serde(rename_all = "camelCase")] -pub struct ListSubcontainersParams { - pub id: PackageId, -} - -#[derive(Clone, Debug, Serialize, Deserialize, TS)] -#[serde(rename_all = "camelCase")] -pub struct SubcontainerInfo { - pub name: InternedString, - pub image_id: ImageId, -} - -pub async fn list_subcontainers( - ctx: RpcContext, - ListSubcontainersParams { id }: ListSubcontainersParams, -) -> Result, Error> { - let service = ctx.services.get(&id).await; - let service_ref = service.as_ref().or_not_found(&id)?; - let container = &service_ref.seed.persistent_container; - - let subcontainers = container.subcontainers.lock().await; - - let result: BTreeMap = subcontainers - .iter() - .map(|(guid, subcontainer)| { - ( - guid.clone(), - SubcontainerInfo { - name: subcontainer.name.clone(), - image_id: subcontainer.image_id.clone(), - }, - ) - }) - .collect(); - - Ok(result) -} - async fn get_passwd_command(etc_passwd_path: PathBuf, user: &str) -> RootCommand { async { let mut file = tokio::fs::File::open(etc_passwd_path).await?; @@ -1210,23 +1175,34 @@ pub async fn cli_attach( None }; + let method = parent_method.into_iter().chain(method).join("."); + let mut params = json!({ + "id": params.id, + "command": params.command, + "tty": tty, + "stderrTty": stderr.is_terminal(), + "ptySize": if tty { TermSize::get_current() } else { None }, + "subcontainer": params.subcontainer, + "imageId": params.image_id, + "name": params.name, + "user": params.user, + }); let guid: Guid = from_value( - context - .call_remote::( - &parent_method.into_iter().chain(method).join("."), - json!({ - "id": params.id, - "command": params.command, - "tty": tty, - "stderrTty": stderr.is_terminal(), - "ptySize": if tty { TermSize::get_current() } else { None }, - "subcontainer": params.subcontainer, - "imageId": params.image_id, - "name": params.name, - "user": params.user, - }), - ) - .await?, + match context + .call_remote::(&method, params.clone()) + .await + { + Ok(a) => a, + Err(e) => { + let prompt = e.to_string(); + let options: Vec = from_value(e.info)?; + let choice = choose(&prompt, &options).await?; + params["subcontainer"] = to_value(&choice.id)?; + context + .call_remote::(&method, params.clone()) + .await? + } + }, )?; let mut ws = context.ws_continuation(guid).await?; diff --git a/core/src/update/mod.rs b/core/src/update/mod.rs index 7a938cb92..fc75b96b2 100644 --- a/core/src/update/mod.rs +++ b/core/src/update/mod.rs @@ -6,6 +6,7 @@ use clap::{ArgAction, Parser}; use color_eyre::eyre::{Result, eyre}; use exver::{Version, VersionRange}; use futures::TryStreamExt; +use imbl::OrdMap; use imbl_value::json; use itertools::Itertools; use patch_db::json_ptr::JsonPointer; @@ -245,6 +246,7 @@ async fn maybe_do_update( let mut available = from_value::>( ctx.call_remote_with::( "os.version.get", + OrdMap::new(), json!({ "source": current_version, "target": target,