Skip to content

Commit 1a8a539

Browse files
authored
Merge pull request #50 from Gerharddc/multiple-enter
Support multiple enter
2 parents 7ae4bc4 + 0d2922f commit 1a8a539

17 files changed

Lines changed: 688 additions & 494 deletions

ARCHITECTURE.md

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
# Architecture
2+
3+
## Core Components
4+
5+
### The Daemon
6+
7+
A long-running daemon process manages each Litterbox. It is started automatically when entering a Litterbox and persists until the container is stopped.
8+
9+
**Responsibilities:**
10+
11+
- Manages the SSH agent server for key access
12+
- Monitors active sessions via lockfiles
13+
- Automatically stops the container when all sessions close
14+
15+
**Lockfiles** in `~/Litterbox/`:
16+
17+
- `.daemon-{LBX_NAME}.lock` - Contains the daemon's PID. Prevents multiple daemons from running simultaneously.
18+
- `.session-{LBX_NAME}.lock` - Contains PIDs of all active terminal sessions. The daemon periodically cleans stale PIDs and stops the container when this file becomes empty.
19+
20+
### SSH Key Architecture
21+
22+
Litterbox implements a custom SSH agent to control key access.
23+
24+
**Components:**
25+
26+
1. **Key Storage** - Keys are stored in `~/Litterbox/keys.ron`, encrypted with a user-provided password.
27+
28+
2. **SSH Agent Server** - A custom agent implementation built on russh that:
29+
- Runs inside the daemon as an async task
30+
- Listens on a Unix socket mounted into the container
31+
- Decrypts keys only when needed and registers them with the agent
32+
33+
3. **Confirmation System** - Before any key operation (sign, list, add, remove), the agent spawns a GUI confirmation dialog using. Users can:
34+
- Approve the single request
35+
- Approve for the entire session (subsequent requests of the same type are auto-approved)
36+
- Decline the request
37+
38+
A new short-livived process is spawned for each confirmation dialog. This is mainly to work around threading issues with the GUI system.
39+
40+
### Container Lifecycle
41+
42+
**Building:**
43+
44+
- Litterbox uses Dockerfile templates to build images
45+
- Images are tagged with `work.litterbox.name` label for discovery
46+
- Each Litterbox has both an image and a container
47+
48+
**Starting:**
49+
50+
- The container entrypoint is `/litterbox wait`, which blocks until the session lock file is empty
51+
- This is detected using inotify for efficient watching
52+
53+
**Stopping:**
54+
55+
- When the daemon detects no active sessions, it exits
56+
- This triggers the container's entrypoint to return
57+
- Podman automatically stops the container
58+
59+
### File System Isolation
60+
61+
The isolation model restricts what the container can access:
62+
63+
**Mounted Paths:**
64+
65+
- `~/Litterbox/homes/{name}``/home/user` (user's home inside container)
66+
- `/litterbox` (binary, read-only) → Container entrypoint
67+
- `/session.lock` (host's session lock, read-only) → Used for waiting
68+
- `/tmp/ssh-agent.sock` → SSH agent socket for key access
69+
- `/tmp/pipewire-0` (optional) → PipeWire socket for audio
70+
- Wayland socket → Host's Wayland display
71+
72+
**Isolation Boundaries:**
73+
74+
- User namespace is set to `keep-id` to match host user
75+
- Root filesystem is fully isolated (no access to host except mounted paths)
76+
- Network mode defaults to `pasta` (user-mode networking stack)
77+
78+
### Session Tracking
79+
80+
Each terminal session is tracked via PID:
81+
82+
1. When `litterbox enter` runs, it adds the terminal's PID to the session lockfile
83+
2. When the terminal exits, the PID is removed from the lockfile
84+
3. The daemon periodically checks and cleans up PIDs for dead processes
85+
4. When the lockfile is empty, the container stops
86+
87+
This allows multiple concurrent sessions (e.g., multiple terminals) to share one container.
88+
89+
### Entry Flow
90+
91+
When a user runs `litterbox enter NAME`:
92+
93+
1. **Container Check** - Verify container exists; start if not running
94+
2. **Daemon Start** - If daemon not running, spawn it with the key password via stdin
95+
3. **Session Registration** - Add terminal PID to session lockfile
96+
4. **Home Setup** - Run `/litterbox setup-home` to initialize user's home directory (only on first entry)
97+
5. **Shell Launch** - Start user's shell inside the container

Cargo.lock

Lines changed: 5 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

README.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ During the build process, you will be asked various questions related to how you
8383

8484
### 3. Enter
8585

86-
Finally you can then enter your Litterbox by running `litterbox enter LBX_NAME`. Once inside the Litterbox you can then start working on your projects!
86+
Finally you can then enter your Litterbox by running `litterbox enter LBX_NAME`. Once inside the Litterbox you can then start working on your projects! You can enter the same Litterbox multiple times from different terminals - all terminals share the same running container and this container will automatically stop when the last terminal exits.
8787

8888
### 4. Keys
8989

@@ -109,6 +109,10 @@ Litterbox is very similar to DevContainers in that is uses Dockerfiles and conta
109109

110110
Litterbox is most similar to Distrobox in terms of its design and functionality. The primary difference is that Distrobox does not aim to provide any isolation/sandboxing at all whereas Litterbox has a strong emphasis on providing it. Distrobox avoids sandboxing in order to provide more seamless integration between applications running inside the Distrobox and the host system. It tries to solve the problem of running software intended for a different distro as if it is running natively. Litterbox instead sacrificies much of the convenience that Distrobox provides in exchange for some isolation/sandboxing capabilities.
111111

112+
## Stability
113+
114+
Litterbox is still early in its development lifecycle and not particularly stable yet.
115+
112116
## TODO
113117

114118
Litterbox is still very much WIP with many missing features or required improvements. Following is a list of some important pieces that are still missing:
@@ -119,6 +123,7 @@ Litterbox is still very much WIP with many missing features or required improvem
119123
- [x] Add function to approve some SSH agent requests for the duration of the session.
120124
- [x] Add optional support for using host network.
121125
- [x] Add optional support for port forwarding with the default "pasta" networking.
126+
- [x] Support entering a Litterbox multiple times.
122127
- [ ] Add a "prune" command to get rid of dangling images.
123128
- [ ] Add support for more granular network settings.
124129
- [ ] Show SSH key name when prompting for approval. (Currently blocked by https://github.com/Eugeny/russh/issues/602)

litterbox/Cargo.toml

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ default-run = "litterbox"
88
names = { path = "../names" }
99
clap = { version = "4.5", features = ["derive"] }
1010
serde = { version = "1.0", features = ["derive"] }
11-
nix = { version = "0.31", features = ["fs"] }
11+
nix = { version = "0.31", features = ["fs", "signal", "inotify"] }
1212
serde_json = "1.0"
1313
tabled = "0.20"
1414
inquire = "0.9"
@@ -23,5 +23,6 @@ tokio-stream = { version = "0.1", features = ["net"] }
2323
eframe = "0.33"
2424
egui_extras = { version="0.33", features=["svg"] }
2525
futures = "0.3"
26-
strum = "0.27"
27-
strum_macros = "0.27"
26+
strum = "0.28"
27+
strum_macros = "0.28"
28+
anyhow = "1.0"

litterbox/src/agent.rs

Lines changed: 5 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use anyhow::Result;
12
use eframe::egui;
23
use futures::Future;
34
use russh::keys::*;
@@ -7,7 +8,7 @@ use std::sync::atomic::{AtomicBool, Ordering};
78
use strum_macros::{Display, EnumString};
89
use tokio::process::Command;
910

10-
use crate::errors::LitterboxError;
11+
use crate::env::litterbox_binary_path;
1112
use crate::extract_stdout;
1213
use crate::files::SshSockFile;
1314

@@ -157,7 +158,7 @@ impl eframe::App for ConfirmationDialog<'_> {
157158
}
158159

159160
pub struct AgentState {
160-
/// When the agent is locked, uesrs will need to approve requests
161+
/// When the agent is locked, users will need to approve requests
161162
pub locked: AtomicBool,
162163

163164
/// When set, users no longer need to approve requests to list keys
@@ -173,12 +174,8 @@ impl Default for AgentState {
173174
}
174175
}
175176

176-
pub async fn start_ssh_agent(
177-
lbx_name: &str,
178-
agent_state: Arc<AgentState>,
179-
) -> Result<PathBuf, LitterboxError> {
180-
let mut args = std::env::args();
181-
let litterbox_path = args.next().expect("Binary path should be defined.");
177+
pub async fn start_ssh_agent(lbx_name: &str, agent_state: Arc<AgentState>) -> Result<PathBuf> {
178+
let litterbox_path = litterbox_binary_path();
182179

183180
let ssh_sock = SshSockFile::new(lbx_name, false)?;
184181
let agent_path = ssh_sock.path().to_owned();

litterbox/src/daemon.rs

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
use anyhow::{Context, Result};
2+
use log::info;
3+
use nix::sys::signal::kill;
4+
use nix::unistd::Pid;
5+
6+
use crate::Keys;
7+
use crate::files;
8+
use crate::podman::is_container_running;
9+
10+
pub async fn run(lbx_name: &str, password: &str) -> Result<()> {
11+
let daemon_lock = files::daemon_lock_path(lbx_name)?;
12+
13+
if daemon_lock.exists() {
14+
let pid_str =
15+
std::fs::read_to_string(&daemon_lock).context("Failed to read daemon lock file")?;
16+
17+
if let Ok(pid) = pid_str.trim().parse::<u32>() {
18+
let pid = Pid::from_raw(pid as i32);
19+
if kill(pid, None).is_ok() {
20+
info!("Daemon already running for {}", lbx_name);
21+
return Ok(());
22+
}
23+
}
24+
25+
info!("Stale daemon lock file found, removing");
26+
std::fs::remove_file(&daemon_lock).context("Failed to remove stale daemon lock file")?;
27+
}
28+
29+
let my_pid = std::process::id();
30+
std::fs::write(&daemon_lock, my_pid.to_string()).context("Failed to write daemon lock file")?;
31+
32+
let keys = Keys::load()?;
33+
keys.start_ssh_server(lbx_name, password).await?;
34+
35+
let session_path = files::session_lock_path(lbx_name)?;
36+
37+
loop {
38+
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
39+
40+
files::cleanup_dead_pids_from_session_lockfile(&session_path)?;
41+
42+
if !is_container_running(lbx_name)? {
43+
info!("Container no longer running, daemon will stop.");
44+
break;
45+
}
46+
}
47+
48+
if session_path.exists() {
49+
info!("Cleaning up session lockfile.");
50+
std::fs::remove_file(&session_path).context("Failed to remove session lock file")?;
51+
}
52+
53+
std::fs::remove_file(&daemon_lock).context("Failed to remove daemon lock file")?;
54+
info!("Daemon exiting for {}", lbx_name);
55+
Ok(())
56+
}
57+
58+
pub fn is_running(lbx_name: &str) -> Result<bool> {
59+
use std::fs::read_to_string;
60+
61+
let daemon_lock = files::daemon_lock_path(lbx_name)?;
62+
let running = daemon_lock.exists() && {
63+
if let Ok(pid_str) = read_to_string(&daemon_lock) {
64+
if let Ok(pid) = pid_str.trim().parse::<u32>() {
65+
let pid = Pid::from_raw(pid as i32);
66+
kill(pid, None).is_ok()
67+
} else {
68+
false
69+
}
70+
} else {
71+
false
72+
}
73+
};
74+
75+
Ok(running)
76+
}

litterbox/src/devices.rs

Lines changed: 11 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,13 @@
1+
use anyhow::{Context, Result, ensure};
12
use log::{debug, info};
23
use nix::sys::stat::{SFlag, major, minor, stat};
34
use std::fs;
45
use std::path::{Path, PathBuf};
56
use std::process::Command;
67

7-
use crate::{errors::LitterboxError, files::lbx_home_path};
8+
use crate::files::lbx_home_path;
89

9-
fn mknod(
10-
major_num: u64,
11-
minor_num: u64,
12-
dev_type: &str,
13-
path: &Path,
14-
) -> Result<(), LitterboxError> {
10+
fn mknod(major_num: u64, minor_num: u64, dev_type: &str, path: &Path) -> Result<()> {
1511
println!(
1612
"Root permissions are required to create a device node. Please enter your password if prompted."
1713
);
@@ -25,31 +21,26 @@ fn mknod(
2521
&minor_num.to_string(),
2622
])
2723
.spawn()
28-
.map_err(|e| LitterboxError::RunCommand(e, "mknod"))?;
24+
.context("Failed to run mknod command")?;
2925

30-
let res = child
31-
.wait()
32-
.map_err(|e| LitterboxError::RunCommand(e, "mknod"))?;
26+
let res = child.wait().context("Failed to run mknod command")?;
3327

34-
if !res.success() {
35-
Err(LitterboxError::CommandFailed(res, "mknod"))
36-
} else {
37-
Ok(())
38-
}
28+
ensure!(res.success(), "mknod command failed");
29+
Ok(())
3930
}
4031

41-
pub fn attach_device(lbx_name: &str, device_path: &str) -> Result<PathBuf, LitterboxError> {
32+
pub fn attach_device(lbx_name: &str, device_path: &str) -> Result<PathBuf> {
4233
let sub_path = device_path
4334
.strip_prefix("/dev/")
44-
.ok_or(LitterboxError::InvalidDevicePath(device_path.to_string()))?;
35+
.with_context(|| format!("Invalid device path: {device_path}"))?;
4536
debug!("sub_path: {:#?}", sub_path);
4637

4738
let lbx_path = lbx_home_path(lbx_name)?;
4839
debug!("lbx_path: {:#?}", lbx_path);
4940
let dest_path = lbx_path.join("dev").join(sub_path);
5041
debug!("dest_path: {:#?}", dest_path);
5142

52-
let metadata = stat(device_path).map_err(LitterboxError::Nix)?;
43+
let metadata = stat(device_path).context("Failed to stat device")?;
5344
let rdev = metadata.st_rdev;
5445
let kind = SFlag::from_bits_truncate(metadata.st_mode);
5546

@@ -71,8 +62,7 @@ pub fn attach_device(lbx_name: &str, device_path: &str) -> Result<PathBuf, Litte
7162
let output_dir = dest_path
7263
.parent()
7364
.expect("Destination path should have parent.");
74-
fs::create_dir_all(output_dir)
75-
.map_err(|e| LitterboxError::DirUncreatable(e, output_dir.to_path_buf()))?;
65+
fs::create_dir_all(output_dir).context("Failed to create output directory")?;
7666
debug!("Output dir ready!");
7767

7868
mknod(major_num, minor_num, dev_type, &dest_path)?;

litterbox/src/env.rs

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,29 @@
1-
use crate::errors::LitterboxError;
1+
use anyhow::{Context, Result};
22

3-
fn get_env(lbx_name: &'static str) -> Result<String, LitterboxError> {
4-
std::env::var_os(lbx_name)
5-
.ok_or(LitterboxError::EnvVarUndefined(lbx_name))?
6-
.into_string()
7-
.map_err(|value| LitterboxError::EnvVarInvalid(lbx_name, value))
3+
fn get_env(lbx_name: &'static str) -> Result<String> {
4+
let value = std::env::var(lbx_name)
5+
.with_context(|| format!("Environment variable {lbx_name} is not defined"))?;
6+
Ok(value)
87
}
98

10-
pub fn home_dir() -> Result<String, LitterboxError> {
9+
pub fn home_dir() -> Result<String> {
1110
get_env("HOME")
1211
}
1312

14-
pub fn wayland_display() -> Result<String, LitterboxError> {
13+
pub fn wayland_display() -> Result<String> {
1514
get_env("WAYLAND_DISPLAY")
1615
}
1716

18-
pub fn xdg_runtime_dir() -> Result<String, LitterboxError> {
17+
pub fn xdg_runtime_dir() -> Result<String> {
1918
get_env("XDG_RUNTIME_DIR")
2019
}
20+
21+
pub fn shell() -> Result<String> {
22+
get_env("SHELL")
23+
}
24+
25+
pub fn litterbox_binary_path() -> String {
26+
std::env::args()
27+
.next()
28+
.expect("Binary path should be defined.")
29+
}

0 commit comments

Comments
 (0)