Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
240 changes: 240 additions & 0 deletions desktop/src-tauri/src/engine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -877,6 +877,24 @@ fn main() {
}
}

/**
Where a container under rootless Podman reaches this machine's loopback.

Rootless Podman puts its containers in a network namespace of the person's own, and by default
nothing in it leads back to the host's `127.0.0.1`: slirp4netns runs with host loopback disabled
and pasta with `--no-map-gw`. The server is a container now, and what it calls on this machine
listens only on loopback: the person's browser, deliberately, and a local Intelligence. Measured
on Ubuntu 24.04 (Podman 4.9.3, slirp4netns) and Fedora 44 (Podman 5.8.9, pasta): a container on a
Compose network reached neither, at any address, until [`local_api::HOST_LOOPBACK_CONFIG`] was in
place, and then reached both at this address, which is slirp4netns's fixed one and which pasta is
told to map.

Docker Desktop and the Podman machines on macOS and Windows already forward `host.docker.internal`
to the host's loopback, so this is for rootless Podman on Linux alone. Compose is pointed at it
through `OPENBOT_HOST_GATEWAY` (see `env::compose`).
*/
pub const ROOTLESS_HOST_LOOPBACK: &str = "10.0.2.2";

/// A native Podman API service belongs to the desktop session only when this session spawned it.
/// Existing user/systemd services are probed and reused, never stopped or reconfigured.
#[cfg(any(target_os = "linux", all(test, unix)))]
Expand All @@ -894,6 +912,81 @@ pub mod local_api {
pub struct Service {
child: Option<Child>,
socket: Option<(PathBuf, u64, u64)>,
/// Where [`allow_host_loopback`](Service::allow_host_loopback) last wrote, so a Start can
/// ask whether the reset it calls for is still owed.
config_home: Option<PathBuf>,
}

/// Podman's own drop-in directory, under the person's configuration home.
pub const HOST_LOOPBACK_CONFIG: &str = "containers/containers.conf.d/90-openbot-host.conf";

/**
Present from the moment [`HOST_LOOPBACK_CONFIG`] changes until a Start has taken OpenBot's
stack down, so the containers after it build a new rootless network namespace.

A file rather than a flag in memory, so the reset is still owed after a Start whose teardown
failed, and after the app quit between writing the drop-in and starting. Beside Podman's
configuration rather than in `containers.conf.d`, where Podman reads drop-ins.
*/
pub const HOST_LOOPBACK_PENDING: &str = "containers/openbot-host-loopback.pending";

/**
The drop-in that opens the rootless network namespace to this machine's loopback at
[`super::ROOTLESS_HOST_LOOPBACK`], for both of the programs rootless Podman builds it with.
Only the one in use reads its own key.

Appended rather than set, so options the person gave either program in their own files are
kept: a later drop-in's list otherwise replaces an earlier one's. Both Podman 4.9 and 5.8
parse `{append=true}`.

Nothing here renames `host.containers.internal` or `host.docker.internal` for the person's
other containers. OpenBot's own services are pointed at this address by Compose, and anything
else would have to ask for it by number.
*/
pub fn host_loopback_config() -> String {
let address = super::ROOTLESS_HOST_LOOPBACK;
format!(
"# Written by OpenBot Desktop: lets its containers reach services this machine runs on\n\
# 127.0.0.1, such as the person's browser and a local Intelligence, at {address}.\n\
[engine]\n\
network_cmd_options = [\"allow_host_loopback=true\", {{append=true}}]\n\
\n\
[network]\n\
pasta_options = [\"--map-host-loopback\", \"{address}\", {{append=true}}]\n"
)
}

/// Write [`host_loopback_config`] under `config_home`, saying whether anything changed.
/// Replaced whole, through a sibling, so Podman never reads half of it. A change marks the
/// reset owed ([`HOST_LOOPBACK_PENDING`]) first, so no drop-in is ever in place without it.
pub fn write_host_loopback(config_home: &Path) -> Result<bool, String> {
let path = config_home.join(HOST_LOOPBACK_CONFIG);
let wanted = host_loopback_config();
match std::fs::read_to_string(&path) {
Ok(current) if current == wanted => return Ok(false),
Ok(_) => {}
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {}
Err(error) => return Err(format!("{}: {error}", path.display())),
}
let directory = path.parent().expect("the drop-in has a directory");
std::fs::create_dir_all(directory)
.map_err(|error| format!("{}: {error}", directory.display()))?;
let pending = config_home.join(HOST_LOOPBACK_PENDING);
std::fs::write(&pending, "").map_err(|error| format!("{}: {error}", pending.display()))?;
let partial = path.with_extension("conf.partial");
std::fs::write(&partial, wanted)
.and_then(|()| std::fs::rename(&partial, &path))
.map_err(|error| format!("{}: {error}", path.display()))?;
Ok(true)
}

/// Where the person's configuration lives, as Podman looks for it.
#[cfg(target_os = "linux")]
fn config_home() -> Option<PathBuf> {
std::env::var_os("XDG_CONFIG_HOME")
.filter(|value| !value.is_empty())
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".config")))
}

fn problem(detail: impl Into<String>) -> Problem {
Expand Down Expand Up @@ -960,6 +1053,9 @@ pub mod local_api {
});
let socket = runtime.join("podman/podman.sock");
let log = root.join("logs/podman-api.log");
let config_home =
config_home().ok_or_else(|| problem("Neither XDG_CONFIG_HOME nor HOME is set."))?;
self.allow_host_loopback(&config_home)?;
self.ensure_with(&socket, &log, || {
let mut command = address.command();
command
Expand Down Expand Up @@ -1060,6 +1156,48 @@ pub mod local_api {
}))
}

/// Write the host loopback drop-in before any container needs it. A service this session
/// started read its configuration when it started, so a change stops it, and the
/// `ensure` that follows starts it again. A service somebody else runs is left alone.
pub(super) fn allow_host_loopback(&mut self, config_home: &Path) -> Result<(), Problem> {
self.config_home = Some(config_home.to_path_buf());
if write_host_loopback(config_home).map_err(problem)? {
self.stop()?;
}
Ok(())
}

/**
Whether a Start still owes the host loopback configuration a teardown of OpenBot's stack.

Podman reads it only when the rootless network namespace is created, and that namespace
lives while any container on a Compose network does, so a stack still running from before
keeps the old one. Measured: written while a container held the namespace, nothing
changed; once the last one stopped, the next container reached the host.

Owed until [`acknowledge_host_loopback_reset`](Self::acknowledge_host_loopback_reset),
which a Start calls only once the teardown succeeded.
*/
pub fn host_loopback_reset_pending(&self) -> bool {
self.config_home
.as_ref()
.is_some_and(|home| home.join(HOST_LOOPBACK_PENDING).exists())
}

/// The teardown [`host_loopback_reset_pending`](Self::host_loopback_reset_pending) asked
/// for is done.
pub fn acknowledge_host_loopback_reset(&self) -> Result<(), Problem> {
let Some(home) = self.config_home.as_ref() else {
return Ok(());
};
let pending = home.join(HOST_LOOPBACK_PENDING);
match std::fs::remove_file(&pending) {
Ok(()) => Ok(()),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(problem(format!("{}: {error}", pending.display()))),
}
}

pub fn stop(&mut self) -> Result<(), Problem> {
if let Some(child) = self.child.as_mut() {
if child
Expand Down Expand Up @@ -1169,6 +1307,108 @@ mod local_api_tests {
}
}

/// The drop-in opens host loopback for both programs, at the one address Compose is given, and
/// appends to what the person configured rather than replacing it.
#[test]
fn the_host_loopback_drop_in_names_both_programs_and_keeps_the_persons_options() {
let config = super::local_api::host_loopback_config();
let address = super::ROOTLESS_HOST_LOOPBACK;
assert!(config.contains(
"[engine]\nnetwork_cmd_options = [\"allow_host_loopback=true\", {append=true}]\n"
));
assert!(config.contains(&format!(
"[network]\npasta_options = [\"--map-host-loopback\", \"{address}\", {{append=true}}]\n"
)));
// slirp4netns serves the host's loopback at its gateway, which is fixed.
assert_eq!(address, "10.0.2.2");
assert!(!config.contains("host_containers_internal_ip"));
}

#[test]
fn the_host_loopback_drop_in_is_written_once_and_restored_when_changed() {
let home = temp_root("host-loopback-drop-in");
let path = home.join(super::local_api::HOST_LOOPBACK_CONFIG);
let theirs = path.with_file_name("10-person.conf");
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(
&theirs,
"[network]\npasta_options = [\"--mtu\", \"1400\"]\n",
)
.unwrap();

let pending = home.join(super::local_api::HOST_LOOPBACK_PENDING);
assert!(super::local_api::write_host_loopback(&home).unwrap());
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
super::local_api::host_loopback_config()
);
assert!(pending.exists(), "a new drop-in owes a reset");
std::fs::remove_file(&pending).unwrap();
assert!(!super::local_api::write_host_loopback(&home).unwrap());
assert!(!pending.exists(), "an unchanged drop-in owes nothing");

std::fs::write(&path, "[network]\n").unwrap();
assert!(super::local_api::write_host_loopback(&home).unwrap());
assert!(pending.exists(), "a changed drop-in owes a reset");
assert_eq!(
std::fs::read_to_string(&path).unwrap(),
super::local_api::host_loopback_config()
);
assert_eq!(
std::fs::read_to_string(&theirs).unwrap(),
"[network]\npasta_options = [\"--mtu\", \"1400\"]\n",
"the person's own drop-in is not touched"
);
assert!(!path.with_extension("conf.partial").exists());
std::fs::remove_dir_all(home).unwrap();
}

/// A change restarts the service this session owns, because it read its configuration when it
/// started, and owes a Start a teardown until one acknowledges it. No change leaves it running.
#[test]
fn a_host_loopback_change_restarts_the_owned_api_and_owes_a_reset_until_acknowledged() {
let root = temp_root("host-loopback-restart");
std::fs::create_dir_all(&root).unwrap();
let home = root.join("config");
let socket = root.join("api.sock");
let log = root.join("api.log");
let mut owned = Service::default();
owned
.ensure_with(&socket, &log, || fixture(&socket))
.unwrap();
let first = owned.child_id().unwrap();

owned.allow_host_loopback(&home).unwrap();
assert_eq!(
unsafe { libc::kill(first as i32, 0) },
-1,
"the owned service that predates the change is stopped"
);
owned
.ensure_with(&socket, &log, || fixture(&socket))
.unwrap();
let second = owned.child_id().unwrap();
assert_ne!(first, second);
assert!(owned.host_loopback_reset_pending());
// A Start whose teardown failed acknowledges nothing, so asking again still says owed.
assert!(owned.host_loopback_reset_pending());

// Nor is it forgotten with the app: a later session, with the drop-in already in place
// and nothing written, still owes it.
owned.allow_host_loopback(&home).unwrap();
assert_eq!(owned.child_id(), Some(second), "no change, no restart");
let mut later = Service::default();
later.allow_host_loopback(&home).unwrap();
assert!(later.host_loopback_reset_pending());

later.acknowledge_host_loopback_reset().unwrap();
assert!(!later.host_loopback_reset_pending());
assert!(!owned.host_loopback_reset_pending());
later.acknowledge_host_loopback_reset().unwrap();
owned.stop().unwrap();
std::fs::remove_dir_all(root).unwrap();
}

#[test]
fn linux_api_starts_a_real_socket_and_reaps_only_its_own_child() {
let root = temp_root("linux-api");
Expand Down
39 changes: 38 additions & 1 deletion desktop/src-tauri/src/env.rs
Original file line number Diff line number Diff line change
Expand Up @@ -899,6 +899,12 @@ pub fn compose(
// Only rootless Podman on Linux needs this; see engine.rs.
if let Some(socket) = &engine.engine_socket {
env.insert("ENGINE_SOCKET".into(), socket.clone());
// And only there is `host-gateway` not this machine's loopback, which is where the
// person's browser and a local Intelligence listen. See `engine::ROOTLESS_HOST_LOOPBACK`.
env.insert(
HOST_GATEWAY_ENV.into(),
crate::engine::ROOTLESS_HOST_LOOPBACK.into(),
);
}

env
Expand Down Expand Up @@ -1049,15 +1055,20 @@ pub const MINTED: [&str; 6] = [
/// earlier run wrote outlives the answer that implied it: `BOT_MODEL` once asked OpenAI for a model
/// an earlier endpoint had named, and `OPENBOT_BROWSER_URL` would send the person to a browser that
/// no longer runs.
pub const REMOVED_WHEN_UNSET: [&str; 6] = [
pub const REMOVED_WHEN_UNSET: [&str; 7] = [
"BOT_PROVIDER",
"BOT_MODEL",
"AGENT_BOT_MODEL",
crate::local_browser::BROWSER_URL_ENV,
BROWSER_CONTAINER_URL_ENV,
crate::local_browser::BROWSER_ABSENT_ENV,
// A machine that moves from rootless Podman to Docker must go back to `host-gateway`.
HOST_GATEWAY_ENV,
];

/// What `docker-compose.yml` maps `host.docker.internal` to, in place of `host-gateway`.
pub const HOST_GATEWAY_ENV: &str = "OPENBOT_HOST_GATEWAY";

/// [`crate::local_browser::BROWSER_URL_ENV`] as the server's container reaches it, which
/// `docker-compose.yml` prefers for the `server` service.
pub const BROWSER_CONTAINER_URL_ENV: &str = "OPENBOT_BROWSER_CONTAINER_URL";
Expand Down Expand Up @@ -1918,6 +1929,32 @@ HTTPS_PROXY=http://proxy:8080
);
}

/// Rootless Podman alone sends `host.docker.internal` to the address its namespace maps to this
/// machine's loopback. Everywhere else `host-gateway` already gets there, and a value left from
/// rootless Podman would send Docker's containers to an address nothing answers on.
#[test]
fn host_docker_internal_is_moved_only_under_rootless_podman() {
let compose_with = |socket: Option<&str>| {
compose(
&intelligence(),
&Model::default(),
&engine_status(socket),
&Ports::default(),
&pinned(),
None,
&BTreeMap::new(),
)
};
assert!(!compose_with(None).contains_key(HOST_GATEWAY_ENV));
assert_eq!(
compose_with(Some("/run/user/501/podman/podman.sock"))
.get(HOST_GATEWAY_ENV)
.map(String::as_str),
Some(crate::engine::ROOTLESS_HOST_LOOPBACK)
);
assert!(REMOVED_WHEN_UNSET.contains(&HOST_GATEWAY_ENV));
}

#[test]
fn no_model_choice_does_not_advertise_an_unselected_bundled_service() {
let env = compose(
Expand Down
27 changes: 27 additions & 0 deletions desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1765,6 +1765,33 @@ async fn start_stack_inner<R: tauri::Runtime>(
let found = found.pin()?;
#[cfg(target_os = "linux")]
ensure_linux_podman_api(&shell, &found)?;
/*
* A rootless network namespace that predates the host loopback configuration keeps the old
* options for as long as any container holds it, and an OpenBot left running by an earlier
* version does. Taken down once, here, so the containers about to start build a new one,
* and acknowledged only once that worked, so "Try Start again" tries the teardown again.
* See `engine::local_api::Service::host_loopback_reset_pending`.
*/
#[cfg(target_os = "linux")]
if shell
.podman_api
.lock()
.unwrap()
.host_loopback_reset_pending()
{
stack::down(&found, &root).map_err(|error| {
Problem::with(
"OpenBot could not restart its containers with their new network settings. \
Try Start again.",
error,
)
})?;
shell
.podman_api
.lock()
.unwrap()
.acknowledge_host_loopback_reset()?;
}
#[cfg(target_os = "linux")]
let status = found.status();
acquire::prepare_for_compose(&found)?;
Expand Down
Loading
Loading