diff --git a/Cargo.lock b/Cargo.lock index 9fccd35..2877bad 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -612,11 +612,11 @@ dependencies = [ [[package]] name = "hopper" -version = "0.1.3" -source = "git+https://github.com/systemetric/hopper?rev=v0.1.3#f20d86e9508e043c55d648b1c27f28cf51e7ef53" +version = "0.1.5" +source = "git+https://github.com/systemetric/hopper?rev=v0.1.5#c9711b7173067ed0cfb6b6b95960f1af96807dea" dependencies = [ "bitflags 2.11.0", - "nix 0.31.2", + "nix 0.31.3", "tracing", ] @@ -809,9 +809,9 @@ checksum = "2c4a545a15244c7d945065b5d392b2d2d7f21526fba56ce51467b06ed445e8f7" [[package]] name = "libc" -version = "0.2.185" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "linux-raw-sys" @@ -922,9 +922,9 @@ dependencies = [ [[package]] name = "nix" -version = "0.31.2" +version = "0.31.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d6d0705320c1e6ba1d912b5e37cf18071b6c2e9b7fa8215a1e8a7651966f5d3" +checksum = "cf20d2fde8ff38632c426f1165ed7436270b44f199fc55284c38276f9db47c3d" dependencies = [ "bitflags 2.11.0", "cfg-if", @@ -1128,6 +1128,15 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + [[package]] name = "scopeguard" version = "1.2.0" @@ -1300,6 +1309,7 @@ dependencies = [ "anyhow", "base64", "hopper", + "nix 0.31.3", "serde", "serde_json", "shepherd-common", @@ -1307,6 +1317,7 @@ dependencies = [ "tokio", "tokio-gpiod", "tracing", + "walkdir", ] [[package]] @@ -1771,6 +1782,16 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -1874,6 +1895,15 @@ dependencies = [ "semver", ] +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + [[package]] name = "windows-core" version = "0.62.2" diff --git a/Cargo.toml b/Cargo.toml index df65e0a..fa820b4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,7 +25,8 @@ clap = { version = "4.6.1", features = ["derive"] } fs_extra = "1.3.0" futures = "0.3.32" futures-util = "0.3.32" -hopper = { git = "https://github.com/systemetric/hopper", rev = "v0.1.3", version = "0.1.3" } +hopper = { git = "https://github.com/systemetric/hopper", rev = "v0.1.5", version = "0.1.5" } +nix = { version = "0.31.3", features=["fs", "user"] } rumqttc = { version = "0.25.1", default-features = false } serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.149" @@ -39,4 +40,5 @@ tower-http = { version = "0.6.8", features = ["fs", "trace"] } tower-service = "0.3.3" tracing = "0.1.44" tracing-subscriber = "0.3.23" +walkdir = "2.5.0" zip = "8.5.1" diff --git a/crates/shepherd-app/src/patch.rs b/crates/shepherd-app/src/patch.rs index 2dd8845..bdb0e50 100644 --- a/crates/shepherd-app/src/patch.rs +++ b/crates/shepherd-app/src/patch.rs @@ -6,10 +6,10 @@ use axum::{ http::StatusCode, routing::post, }; -use shepherd_common::config::Config; +use shepherd_common::{Mode, Zone, config::Config}; use shepherd_mqtt::{ MqttAsyncClient, - messages::{PatchStatus, PatchStatusMessage}, + messages::{ControlMessage, ControlMessageType}, }; use tracing::info; use zip::ZipArchive; @@ -20,7 +20,7 @@ use crate::error::{ShepherdError, ShepherdResult}; struct PatchState { patch_dir: PathBuf, mqttc: MqttAsyncClient, - patch_status: String, + robot_control: String, } async fn process_zip(state: PatchState, mut field: Field<'_>) -> ShepherdResult<()> { @@ -55,11 +55,13 @@ async fn process_zip(state: PatchState, mut field: Field<'_>) -> ShepherdResult< state .mqttc .publish( - &state.patch_status, - PatchStatusMessage { - status: PatchStatus::Apply, + &state.robot_control, + ControlMessage { + _type: ControlMessageType::Patch, + mode: Mode::Dev, + zone: Zone::Red, }, - true, + false, ) .await .map_err(|e| ShepherdError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; @@ -68,18 +70,6 @@ async fn process_zip(state: PatchState, mut field: Field<'_>) -> ShepherdResult< } async fn upload(State(state): State, mut multipart: Multipart) -> ShepherdResult<()> { - state - .mqttc - .publish( - &state.patch_status, - PatchStatusMessage { - status: PatchStatus::Receive, - }, - true, - ) - .await - .map_err(|e| ShepherdError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; - while let Some(field) = multipart .next_field() .await @@ -110,6 +100,6 @@ pub fn router(config: &Config, mqttc: MqttAsyncClient) -> Router { .with_state(PatchState { patch_dir: config.app.patch_dir.clone(), mqttc, - patch_status: config.channel.patch_status.clone(), + robot_control: config.channel.robot_control.clone(), }) } diff --git a/crates/shepherd-common/src/config.rs b/crates/shepherd-common/src/config.rs index ac94760..9a7adbe 100644 --- a/crates/shepherd-common/src/config.rs +++ b/crates/shepherd-common/src/config.rs @@ -24,6 +24,10 @@ pub struct Config { pub channel: ChannelConfig, #[serde(default)] pub path: PathConfig, + #[serde(default)] + pub patch: PatchConfig, + #[serde(default)] + pub hopper: HopperConfig, } #[derive(Debug, Serialize, Deserialize, Clone)] @@ -250,8 +254,6 @@ pub struct ChannelConfig { pub user_state: String, #[serde(default = "default_status")] pub status: String, - #[serde(default = "default_patch_status")] - pub patch_status: String, } fn default_channel_robot_control() -> String { @@ -269,9 +271,6 @@ fn default_user_status() -> String { fn default_status() -> String { "status".to_string() } -fn default_patch_status() -> String { - "patch/status".to_string() -} impl Default for ChannelConfig { fn default() -> Self { @@ -281,7 +280,6 @@ impl Default for ChannelConfig { camera: default_channel_camera(), user_state: default_user_status(), status: default_status(), - patch_status: default_patch_status(), } } } @@ -340,6 +338,54 @@ impl Default for PathConfig { } } +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct PatchConfig { + #[serde(default = "default_patch_uid")] + pub uid: u32, + #[serde(default = "default_patch_gid")] + pub gid: u32, + #[serde(default = "default_patch_working_dir")] + pub working_dir: PathBuf, +} + +fn default_patch_uid() -> u32 { + 0u32 +} +fn default_patch_gid() -> u32 { + 0u32 +} +fn default_patch_working_dir() -> PathBuf { + PathBuf::from("/") +} + +impl Default for PatchConfig { + fn default() -> Self { + Self { + uid: default_patch_uid(), + gid: default_patch_gid(), + working_dir: default_patch_working_dir(), + } + } +} + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct HopperConfig { + #[serde(default = "default_hopper_gid")] + pub gid: Option, +} + +fn default_hopper_gid() -> Option { + None +} + +impl Default for HopperConfig { + fn default() -> Self { + Self { + gid: default_hopper_gid(), + } + } +} + impl Config { pub fn from_file(path: Option<&Path>) -> Result { let path = path.unwrap_or(Path::new(DEFAULT_CONFIG_PATH)); @@ -375,7 +421,6 @@ impl Config { } _create_dir(&self.path.root); - _create_dir(&self.path.hopper); _create_dir(&self.path.user_cur_dir); _create_dir(&self.app.static_dir); diff --git a/crates/shepherd-mqtt/src/messages.rs b/crates/shepherd-mqtt/src/messages.rs index ba42017..555b367 100644 --- a/crates/shepherd-mqtt/src/messages.rs +++ b/crates/shepherd-mqtt/src/messages.rs @@ -9,6 +9,7 @@ pub enum ControlMessageType { Start, Stop, Reset, + Patch, } #[derive(Debug, Serialize, Deserialize)] @@ -41,16 +42,3 @@ pub struct StatusMessage { pub struct StatusSummary { pub statuses: Vec, } - -#[derive(Clone, Debug, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] -pub enum PatchStatus { - Receive, - Apply, - Failed, -} - -#[derive(Clone, Debug, Serialize, Deserialize)] -pub struct PatchStatusMessage { - pub status: PatchStatus, -} diff --git a/crates/shepherd-run/Cargo.toml b/crates/shepherd-run/Cargo.toml index b893fc5..b71c8ef 100644 --- a/crates/shepherd-run/Cargo.toml +++ b/crates/shepherd-run/Cargo.toml @@ -9,6 +9,7 @@ license.workspace = true anyhow.workspace = true base64.workspace = true hopper.workspace = true +nix.workspace = true serde.workspace = true serde_json.workspace = true shepherd-common = { path = "../shepherd-common" } @@ -16,3 +17,4 @@ shepherd-mqtt = { path = "../shepherd-mqtt" } tokio.workspace = true tokio-gpiod.workspace = true tracing.workspace = true +walkdir.workspace = true diff --git a/crates/shepherd-run/src/runner.rs b/crates/shepherd-run/src/runner.rs index ee4d254..4df4af3 100644 --- a/crates/shepherd-run/src/runner.rs +++ b/crates/shepherd-run/src/runner.rs @@ -21,6 +21,7 @@ use crate::usercode::{Usercode, UsercodeHandle}; pub enum StateEvent { Transition(RunState, Option), SetTarget(Mode, Zone), + SpawnPatch, } pub struct Runner { @@ -209,6 +210,24 @@ impl Runner { info!("update (mode, zone) to ({:?}, {:?})", mode, zone); } + StateEvent::SpawnPatch => { + if self.state != RunState::PostRun { + warn!( + "cannot spawn patch, requires {:?}, got {:?}", + RunState::PostRun, + self.state + ); + continue; + } + + if let Some(uh) = &self.usercode_handle { + uh.start_patch()?; + } else { + return Err(anyhow!( + "tried to start patch, but usercode handle was not set?" + )); + } + } } } @@ -232,6 +251,11 @@ impl Runner { ControlMessageType::Reset => { sender.send(StateEvent::Transition(RunState::Ready, None))? } + ControlMessageType::Patch => { + // patch should overrides user code + sender.send(StateEvent::Transition(RunState::PostRun, None))?; + sender.send(StateEvent::SpawnPatch)?; + } } Ok(()) } @@ -284,6 +308,7 @@ impl Runner { &self.config.run.service_id, &self.config.channel.camera, Some(&self.config.path.hopper), + self.config.hopper.gid, )?; image_pipe.open()?; self.image_pipe = Some(Arc::new(image_pipe)); diff --git a/crates/shepherd-run/src/usercode.rs b/crates/shepherd-run/src/usercode.rs index 732dce9..3631a12 100644 --- a/crates/shepherd-run/src/usercode.rs +++ b/crates/shepherd-run/src/usercode.rs @@ -1,6 +1,11 @@ -use std::time::Duration; +use std::{ + ffi::{CString, OsStr}, + path::Path, + time::Duration, +}; use anyhow::Result; +use nix::unistd::{Gid, Uid, User}; use serde::{Deserialize, Serialize}; use shepherd_common::{Mode, Zone, config::Config}; use tokio::{ @@ -9,6 +14,7 @@ use tokio::{ time::sleep, }; use tracing::debug; +use walkdir::WalkDir; #[derive(Debug, Serialize, Deserialize)] struct ControlMessage { @@ -18,6 +24,7 @@ struct ControlMessage { enum UsercodeMessage { Start, + StartPatch, SendStartInfo(Mode, Zone), SetTimeout(Duration), Kill, @@ -34,6 +41,12 @@ impl UsercodeHandle { Ok(()) } + /// start patch applictaion, usercode will be killed + pub fn start_patch(&self) -> Result<()> { + self.send.send(UsercodeMessage::StartPatch)?; + Ok(()) + } + /// send start info to usercode pub fn send_start_info(&self, mode: Mode, zone: Zone) -> Result<()> { self.send.send(UsercodeMessage::SendStartInfo(mode, zone))?; @@ -54,6 +67,18 @@ impl UsercodeHandle { } } +struct SpawnChildArgs +where + P: AsRef, + I: IntoIterator, + S: AsRef, +{ + uid: u32, + gid: u32, + working_dir: P, + args: I, +} + pub struct Usercode { config: Config, recv: UnboundedReceiver, @@ -72,6 +97,7 @@ impl Usercode { &config.run.service_id, &config.channel.robot_control, Some(&config.path.hopper), + config.hopper.gid, )?; let mut log_pipe = hopper::Pipe::new( @@ -79,6 +105,7 @@ impl Usercode { &config.run.service_id, &config.channel.robot_log, Some(&config.path.hopper), + config.hopper.gid, )?; start_pipe.open()?; @@ -108,6 +135,98 @@ impl Usercode { } } + fn prep_user_cur_dir(&self) -> Result<()> { + let uid = Some(Uid::from_raw(self.config.run.uid)); + let gid = Some(Gid::from_raw(self.config.run.gid)); + + debug!("setting user dir ownership: ({:?}, {:?})", uid, gid); + + // set owner to usercode uid, gid + for entry in WalkDir::new(&self.config.path.user_cur_dir) { + let entry = entry?; + nix::unistd::chown(entry.path(), uid, gid)?; + } + + nix::unistd::chown(&self.config.path.user_cur_dir, uid, gid)?; + + Ok(()) + } + + /// spawn a generic child with logging to hopper + fn spawn_child(&self, args: SpawnChildArgs) -> Result + where + P: AsRef, + I: IntoIterator, + S: AsRef, + { + let hopper = self.config.path.hopper.to_string_lossy().to_string(); + + // stdio handles are owned, clone fds here + let log_pipe = self.log_pipe.fd()?.try_clone()?; + let err_pipe = self.log_pipe.fd()?.try_clone()?; + + let mut command = Command::new("/usr/bin/env"); + + command + .args(args.args) + .env("HOPPER_PATH", hopper) + .current_dir(args.working_dir) + .stdout(log_pipe) + .stderr(err_pipe); + + if let Some(gid) = self.config.hopper.gid { + command.env("HOPPER_GID", format!("{gid}")); + } + + unsafe { + command + // runs after fork to init suppl groups and change gid/uid + .pre_exec(move || { + fn init_suppl_groups(uid: Uid, gid: Gid) { + // user database lookup to init suppl groups + let user = match User::from_uid(uid) { + Ok(Some(u)) => u, + Ok(None) => { + println!("[warn] user {:?} not found in user database, skipping supplementary groups", uid); + return; + } + Err(e) => { + println!( + "[warn] failed to query user ({:?}) database, skipping supplementary groups: {:?}", + uid, + e + ); + return; + } + }; + + let Ok(un) = CString::new(user.name.clone()) else { + println!("[warn] user ({:?}) name contained a null byte, skipping supplementary groups", uid); + return; + }; + + if let Err(e) = nix::unistd::initgroups(&un, gid) { + println!("[warn] failed to initialise supplementary groups, ignoring: {:?}", e); + } + } + + let uid = Uid::from_raw(args.uid); + let gid = Gid::from_raw(args.gid); + + init_suppl_groups(uid, gid); + + nix::unistd::setgid(gid)?; + nix::unistd::setuid(uid)?; + + Ok(()) + }); + } + + let child = command.spawn()?; + + Ok(child) + } + pub async fn run(&mut self) -> Result<()> { let mut timeout = None; @@ -121,7 +240,6 @@ impl Usercode { let _ = child.wait().await; } - let hopper = self.config.path.hopper.to_string_lossy().to_string(); let entrypoint = self .config .path @@ -130,24 +248,42 @@ impl Usercode { .to_string_lossy() .to_string(); - // stdio handles are owned, clone fds here - let log_pipe = self.log_pipe.fd()?.try_clone()?; - let err_pipe = self.log_pipe.fd()?.try_clone()?; + self.prep_user_cur_dir()?; - let child = Command::new("/usr/bin/env") - .args(["python3", "-u", &self.config.run.usercode_script.to_string_lossy(), &entrypoint]) - .env("HOPPER_PATH", hopper) - .current_dir(&self.config.path.user_cur_dir) - .uid(self.config.run.uid) - .gid(self.config.run.gid) - .stdout(log_pipe) - .stderr(err_pipe).spawn()?; + let sc_args = SpawnChildArgs { + uid: self.config.run.uid, + gid: self.config.run.gid, + working_dir: &self.config.path.user_cur_dir, + args: ["python3", "-u", &self.config.run.usercode_script.to_string_lossy(), &entrypoint], + }; + + let child = self.spawn_child(sc_args)?; debug!("Start( {:?} )", child.id()); self.usercode = Some(child); timeout = None; }, + UsercodeMessage::StartPatch => { + if let Some(mut child) = self.usercode.take() { + let _ = child.kill().await; + let _ = child.wait().await; + } + + let sc_args = SpawnChildArgs { + uid: self.config.patch.uid, + gid: self.config.patch.gid, + working_dir: &self.config.patch.working_dir, + args: [&self.config.run.patch_apply], + }; + + let child = self.spawn_child(sc_args)?; + + debug!("StartPatch ( {:?} )", child.id()); + + self.usercode = Some(child); + timeout = None; + }, UsercodeMessage::SendStartInfo(mode, zone) => { let msg = ControlMessage { mode, zone: zone.to_id() }; let msg = serde_json::to_vec(&msg)?; diff --git a/crates/shepherd-ws/src/main.rs b/crates/shepherd-ws/src/main.rs index 7f35162..940a7ac 100644 --- a/crates/shepherd-ws/src/main.rs +++ b/crates/shepherd-ws/src/main.rs @@ -70,12 +70,14 @@ async fn _main(config: Config) -> Result<()> { &config.ws.service_id, &config.channel.robot_log, Some(config.path.hopper.clone()), + config.hopper.gid, )?; let mut camera_pipe = Pipe::new( PipeMode::OUT, &config.ws.service_id, &config.channel.camera, Some(config.path.hopper.clone()), + config.hopper.gid, )?; log_pipe.open()?; diff --git a/scripts/patchtool-apply.sh b/scripts/patchtool-apply.sh index d4b0f42..760be66 100755 --- a/scripts/patchtool-apply.sh +++ b/scripts/patchtool-apply.sh @@ -1,13 +1,16 @@ #!/bin/sh set -eu +# clear sheep +echo -e "\e[2J" + if [ "$(id -u)" != "0" ]; then echo "patchtool-apply must be run as root" exit 1 fi : "${STAGING:=/var/patchtool/staging}" -: "${WRAPPER:=/home/pi/robot/robot/wrapper.py}" +: "${WRAPPER:=/usr/local/bin/usercode.py}" echo "STAGING $STAGING" @@ -57,7 +60,9 @@ awk '{print $2}' ./CHECKSUMS \ diff -u "$TMP_FILES" "$TMP_HASHES" && echo "OK" || exit 1 echo -echo "Ready to apply patch..." +echo "PATCH OK" +echo +echo "APPLYING PATCH..." echo echo "COPYING ROOT" @@ -68,7 +73,7 @@ else EXCL="" fi -tar -cvf - $EXCL "$STAGING" | tar -xpf - -C / +tar -C "$STAGING" $EXCL -cvf - . | tar -C / -xpf - echo [ -f ./APPLY ] && echo "APPLY SCRIPT" && source ./APPLY && echo @@ -76,9 +81,11 @@ echo echo "MISC" [ -f ./VERSION ] && [ -f "$WRAPPER" ] \ && sed -i \ - "s/\(.*_logger.info(\"Patch Version:\).*/\1 $(cat ./VERSION)\"\)/" \ + "s/\(.*print(\"Patch Version:\).*/\1 $(cat ./VERSION)\"\)/" \ "$WRAPPER" echo cd "$OLD_DIR" echo "PATCH APPLIED" + +rm -rf "$STAGING" || true diff --git a/scripts/patchtool-clean.sh b/scripts/patchtool-clean.sh deleted file mode 100755 index f1e180c..0000000 --- a/scripts/patchtool-clean.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh -set -eu - -: "${STAGING:=/var/patchtool/staging}" -: "${READY:=/var/patchtool/ready}" - -echo "STAGING $STAGING" -echo "READY $READY" - -rm -rf "$STAGING" -rm -f "$READY" diff --git a/scripts/usercode.py b/scripts/usercode.py index 54152c3..bf53e8a 100755 --- a/scripts/usercode.py +++ b/scripts/usercode.py @@ -47,6 +47,15 @@ def read_int(s): if "project_name" in attrs: print(f"Loading project '{attrs['project_name']}'...", end="") + # set up hopper first + from hopper import HopperPipe, HopperPipeType, JsonReader + + start_pressed = False + warnings = [] + start_pipe = HopperPipe(HopperPipeType.OUT, "robot", "robot/control") + start_pipe.open() + start_json_reader = JsonReader(start_pipe) + # import everything big here for module caching import robocon from robocon.game import * @@ -62,13 +71,7 @@ def read_int(s): _GG_SERVO_GPIO_BASE, _GG_SERVO_PWM_BASE, ) - from hopper import HopperPipe, HopperPipeType, JsonReader - start_pressed = False - warnings = [] - start_pipe = HopperPipe(HopperPipeType.OUT, "robot", "robot/control") - start_pipe.open() - start_json_reader = JsonReader(start_pipe) bus = smbus2.SMBus(1) green_giant = GreenGiantInternal(bus) @@ -204,7 +207,7 @@ def get_start_info(): print("done") if setup_defaults() or "project_name" in attrs: - print("\n") + print() report_hardware_status()