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
5 changes: 5 additions & 0 deletions .claude/commands/pr-compress.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
Deeply review existing change.

Try to find opportunities for DRY. i.e. where you can push the net balance of code additions as low as possible while simultaneously improving readability and behavior.

Do not venture into changes beyond the scope of the PR.
186 changes: 134 additions & 52 deletions src/jail/linux/docker.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,19 +12,51 @@ struct DockerNetwork {
network_name: String,
}

impl DockerNetwork {
const NETWORK_PREFIX: &'static str = "httpjail_";

/// Generate network name from jail ID
fn network_name_from_jail_id(jail_id: &str) -> String {
format!("{}{}", Self::NETWORK_PREFIX, jail_id)
}

/// Extract jail ID from network name
fn jail_id_from_network_name(network_name: &str) -> Option<&str> {
network_name.strip_prefix(Self::NETWORK_PREFIX)
}

/// Check if a Docker command failed due to resource not existing
fn is_not_found_error(stderr: &str) -> bool {
stderr.contains("not found")
|| stderr.contains("No such")
|| stderr.contains("does not exist")
}

/// Check if a Docker command failed due to resource already existing
fn is_already_exists_error(stderr: &str) -> bool {
stderr.contains("already exists")
}
}

/// Docker routing nftables resource that gets cleaned up on drop
struct DockerRoutingTable {
#[allow(dead_code)]
jail_id: String,
table_name: String,
}

impl DockerRoutingTable {
/// Generate table name from jail ID
fn table_name_from_jail_id(jail_id: &str) -> String {
format!("httpjail_docker_{}", jail_id)
}
}

impl SystemResource for DockerRoutingTable {
fn create(jail_id: &str) -> Result<Self> {
let table_name = format!("httpjail_docker_{}", jail_id);
Ok(Self {
jail_id: jail_id.to_string(),
table_name,
table_name: Self::table_name_from_jail_id(jail_id),
})
}

Expand All@@ -38,10 +70,10 @@ impl SystemResource for DockerRoutingTable {

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("No such file or directory") && !stderr.contains("does not exist") {
warn!("Failed to delete Docker routing table: {}", stderr);
} else {
if DockerNetwork::is_not_found_error(&stderr) {
debug!("Docker routing table {} already removed", self.table_name);
} else {
warn!("Failed to delete Docker routing table: {}", stderr);
}
} else {
info!("Removed Docker routing table {}", self.table_name);
Expand All@@ -51,25 +83,16 @@ impl SystemResource for DockerRoutingTable {
}

fn for_existing(jail_id: &str) -> Self {
let table_name = format!("httpjail_docker_{}", jail_id);
Self {
jail_id: jail_id.to_string(),
table_name,
table_name: Self::table_name_from_jail_id(jail_id),
}
}
}

impl DockerNetwork {
#[allow(dead_code)]
fn new(jail_id: &str) -> Result<Self> {
let network_name = format!("httpjail_{}", jail_id);
Ok(Self { network_name })
}
}

impl SystemResource for DockerNetwork {
fn create(jail_id: &str) -> Result<Self> {
let network_name = format!("httpjail_{}", jail_id);
let network_name = Self::network_name_from_jail_id(jail_id);

// Create Docker network with no default gateway (isolated)
// Using a /24 subnet in the 172.20.x.x range
Expand All@@ -92,7 +115,7 @@ impl SystemResource for DockerNetwork {

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("already exists") {
if Self::is_already_exists_error(&stderr) {
info!("Docker network {} already exists", network_name);
} else {
anyhow::bail!("Failed to create Docker network: {}", stderr);
Expand All@@ -117,7 +140,7 @@ impl SystemResource for DockerNetwork {

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("not found") {
if Self::is_not_found_error(&stderr) {
debug!("Docker network {} already removed", self.network_name);
} else {
warn!("Failed to remove Docker network: {}", stderr);
Expand All@@ -130,8 +153,9 @@ impl SystemResource for DockerNetwork {
}

fn for_existing(jail_id: &str) -> Self {
let network_name = format!("httpjail_{}", jail_id);
Self { network_name }
Self {
network_name: Self::network_name_from_jail_id(jail_id),
}
}
}

Expand DownExpand Up@@ -202,47 +226,76 @@ impl DockerLinux {
})
}

/// Clean up all orphaned Docker networks that don't have corresponding canary files
fn cleanup_all_orphaned_docker_networks() -> Result<()> {
debug!("Scanning for orphaned Docker networks");

// List all Docker networks
let output = Command::new("docker")
.args(["network", "ls", "--format", "{{.Name}}"])
.output()
.context("Failed to list Docker networks")?;

if !output.status.success() {
warn!("Failed to list Docker networks for cleanup");
return Ok(());
}

let networks = String::from_utf8_lossy(&output.stdout);
let canary_dir = crate::jail::get_canary_dir();

for network_name in networks.lines() {
// Extract jail_id from network name (skip non-httpjail networks)
let Some(jail_id) = DockerNetwork::jail_id_from_network_name(network_name) else {
continue;
};

// Check if canary file exists for this jail
let canary_path = canary_dir.join(jail_id);
if !canary_path.exists() {
info!(
"Found orphaned Docker network {} without canary, removing",
network_name
);

// Remove the orphaned network
let rm_output = Command::new("docker")
.args(["network", "rm", network_name])
.output()
.context("Failed to remove orphaned Docker network")?;

if !rm_output.status.success() {
let stderr = String::from_utf8_lossy(&rm_output.stderr);
if !DockerNetwork::is_not_found_error(&stderr) {
warn!(
"Failed to remove orphaned Docker network {}: {}",
network_name, stderr
);
}
}
}
}

Ok(())
}

/// Docker flags that take a value as the next argument
const FLAGS_WITH_VALUES: &'static [&'static str] =
&["-e", "-v", "-p", "--name", "--entrypoint", "-w", "--user"];

/// Build the docker command with isolated network
fn build_docker_command(
&self,
docker_args: &[String],
extra_env: &[(String, String)],
) -> Result<Command> {
let network_name = format!("httpjail_{}", self.config.jail_id);
let network_name = DockerNetwork::network_name_from_jail_id(&self.config.jail_id);
// Parse docker arguments to filter out conflicting options and find the image
let modified_args = Self::filter_network_args(docker_args);

// Find where the image name is in the args
let mut image_idx = None;
let mut skip_next = false;

for (i, arg) in modified_args.iter().enumerate() {
if skip_next {
skip_next = false;
continue;
}

// Skip known flags that take values
if arg == "-e"
|| arg == "-v"
|| arg == "-p"
|| arg == "--name"
|| arg == "--entrypoint"
|| arg == "-w"
|| arg == "--user"
{
skip_next = true;
continue;
}

// If it doesn't start with -, it's likely the image
if !arg.starts_with('-') {
image_idx = Some(i);
break;
}
}

let image_idx = image_idx.context("Could not find Docker image in arguments")?;
let image_idx = Self::find_image_index(&modified_args)
.context("Could not find Docker image in arguments")?;

// Split args into: docker options, image, and command
let docker_opts = &modified_args[..image_idx];
Expand DownExpand Up@@ -302,6 +355,31 @@ impl DockerLinux {
Ok(cmd)
}

/// Find the index of the Docker image in the arguments
fn find_image_index(args: &[String]) -> Option<usize> {
let mut skip_next = false;

for (i, arg) in args.iter().enumerate() {
if skip_next {
skip_next = false;
continue;
}

// Skip known flags that take values
if Self::FLAGS_WITH_VALUES.contains(&arg.as_str()) {
skip_next = true;
continue;
}

// If it doesn't start with -, it's likely the image
if !arg.starts_with('-') {
return Some(i);
}
}

None
}

/// Filter out any existing --network arguments from docker args
fn filter_network_args(docker_args: &[String]) -> Vec<String> {
let mut modified_args = Vec::new();
Expand DownExpand Up@@ -347,7 +425,7 @@ impl DockerLinux {
// Add nftables rules to:
// 1. Allow traffic from Docker network to jail's proxy ports
// 2. DNAT HTTP/HTTPS traffic to the proxy
let table_name = format!("httpjail_docker_{}", self.config.jail_id);
let table_name = DockerRoutingTable::table_name_from_jail_id(&self.config.jail_id);

// Create nftables rules
let nft_rules = format!(
Expand DownExpand Up@@ -412,6 +490,10 @@ impl DockerLinux {

impl Jail for DockerLinux {
fn setup(&mut self, proxy_port: u16) -> Result<()> {
// Clean up any orphaned Docker networks first
// This handles cases where Docker networks exist without corresponding canary files
Self::cleanup_all_orphaned_docker_networks()?;
Comment thread
ammario marked this conversation as resolved.

// First setup the inner Linux jail
self.inner_jail.setup(proxy_port)?;

Expand Down
31 changes: 27 additions & 4 deletions src/jail/managed.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -225,12 +225,23 @@ impl<J: Jail> Jail for ManagedJail<J> {
// Cleanup orphans first
if self.enable_heartbeat {
self.cleanup_orphans()?;

// Create canary BEFORE setting up jail to prevent race condition
// where another jail's cleanup might delete our network
self.create_canary()?;
}

// Setup the inner jail
self.jail.setup(proxy_port)?;
// Setup the inner jail (which may create Docker networks)
let setup_result = self.jail.setup(proxy_port);

// If setup failed, clean up the canary we created
if setup_result.is_err() && self.enable_heartbeat {
let _ = self.delete_canary();
return setup_result;
}

// Start heartbeat after successful setup
// Start heartbeat thread after successful setup
// Note: This will try to create canary again but create_canary is idempotent
self.start_heartbeat()?;

Ok(())
Expand DownExpand Up@@ -272,8 +283,20 @@ impl<J: Jail> Drop for ManagedJail<J> {
fn drop(&mut self) {
// Best effort cleanup
let _ = self.stop_heartbeat();
if self.enable_heartbeat {

// Explicitly cleanup jail resources BEFORE deleting canary
// This ensures resources are freed before we signal that the jail is gone
let cleanup_result = self.jail.cleanup();

// Only delete canary if cleanup succeeded
// If cleanup failed, leave the canary so orphan cleanup can retry later
if self.enable_heartbeat && cleanup_result.is_ok() {
let _ = self.delete_canary();
} else if cleanup_result.is_err() {
error!(
"Failed to cleanup jail '{}', leaving canary for orphan cleanup",
self.jail.jail_id()
);
}
}
}
9 changes: 8 additions & 1 deletion src/jail/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,7 +51,14 @@ pub trait Jail: Send + Sync {

/// Get the canary directory for tracking jail lifetimes
pub fn get_canary_dir() -> std::path::PathBuf {
std::path::PathBuf::from("/tmp/httpjail")
// Use user data directory instead of /tmp to avoid issues with tmp cleaners
// This ensures canaries persist across reboots and are only removed when we want them to be
if let Some(data_dir) = dirs::data_dir() {
data_dir.join("httpjail").join("canaries")
} else {
// Fallback to /tmp if we can't get user data dir (should rarely happen)
std::path::PathBuf::from("/tmp/httpjail")
}
}

/// Get the directory for httpjail temporary files (like resolv.conf)
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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
5 changes: 5 additions & 0 deletions .claude/commands/pr-compress.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
Deeply review existing change.

Try to find opportunities for DRY. i.e. where you can push the net balance of code additions as low as possible while simultaneously improving readability and behavior.

Do not venture into changes beyond the scope of the PR.
186 changes: 134 additions & 52 deletions src/jail/linux/docker.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,19 +12,51 @@ struct DockerNetwork {
network_name: String,
}

impl DockerNetwork {
const NETWORK_PREFIX: &'static str = "httpjail_";

/// Generate network name from jail ID
fn network_name_from_jail_id(jail_id: &str) -> String {
format!("{}{}", Self::NETWORK_PREFIX, jail_id)
}

/// Extract jail ID from network name
fn jail_id_from_network_name(network_name: &str) -> Option<&str> {
network_name.strip_prefix(Self::NETWORK_PREFIX)
}

/// Check if a Docker command failed due to resource not existing
fn is_not_found_error(stderr: &str) -> bool {
stderr.contains("not found")
|| stderr.contains("No such")
|| stderr.contains("does not exist")
}

/// Check if a Docker command failed due to resource already existing
fn is_already_exists_error(stderr: &str) -> bool {
stderr.contains("already exists")
}
}

/// Docker routing nftables resource that gets cleaned up on drop
struct DockerRoutingTable {
#[allow(dead_code)]
jail_id: String,
table_name: String,
}

impl DockerRoutingTable {
/// Generate table name from jail ID
fn table_name_from_jail_id(jail_id: &str) -> String {
format!("httpjail_docker_{}", jail_id)
}
}

impl SystemResource for DockerRoutingTable {
fn create(jail_id: &str) -> Result<Self> {
let table_name = format!("httpjail_docker_{}", jail_id);
Ok(Self {
jail_id: jail_id.to_string(),
table_name,
table_name: Self::table_name_from_jail_id(jail_id),
})
}

Expand All@@ -38,10 +70,10 @@ impl SystemResource for DockerRoutingTable {

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("No such file or directory") && !stderr.contains("does not exist") {
warn!("Failed to delete Docker routing table: {}", stderr);
} else {
if DockerNetwork::is_not_found_error(&stderr) {
debug!("Docker routing table {} already removed", self.table_name);
} else {
warn!("Failed to delete Docker routing table: {}", stderr);
}
} else {
info!("Removed Docker routing table {}", self.table_name);
Expand All@@ -51,25 +83,16 @@ impl SystemResource for DockerRoutingTable {
}

fn for_existing(jail_id: &str) -> Self {
let table_name = format!("httpjail_docker_{}", jail_id);
Self {
jail_id: jail_id.to_string(),
table_name,
table_name: Self::table_name_from_jail_id(jail_id),
}
}
}

impl DockerNetwork {
#[allow(dead_code)]
fn new(jail_id: &str) -> Result<Self> {
let network_name = format!("httpjail_{}", jail_id);
Ok(Self { network_name })
}
}

impl SystemResource for DockerNetwork {
fn create(jail_id: &str) -> Result<Self> {
let network_name = format!("httpjail_{}", jail_id);
let network_name = Self::network_name_from_jail_id(jail_id);

// Create Docker network with no default gateway (isolated)
// Using a /24 subnet in the 172.20.x.x range
Expand All@@ -92,7 +115,7 @@ impl SystemResource for DockerNetwork {

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("already exists") {
if Self::is_already_exists_error(&stderr) {
info!("Docker network {} already exists", network_name);
} else {
anyhow::bail!("Failed to create Docker network: {}", stderr);
Expand All@@ -117,7 +140,7 @@ impl SystemResource for DockerNetwork {

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("not found") {
if Self::is_not_found_error(&stderr) {
debug!("Docker network {} already removed", self.network_name);
} else {
warn!("Failed to remove Docker network: {}", stderr);
Expand All@@ -130,8 +153,9 @@ impl SystemResource for DockerNetwork {
}

fn for_existing(jail_id: &str) -> Self {
let network_name = format!("httpjail_{}", jail_id);
Self { network_name }
Self {
network_name: Self::network_name_from_jail_id(jail_id),
}
}
}

Expand DownExpand Up@@ -202,47 +226,76 @@ impl DockerLinux {
})
}

/// Clean up all orphaned Docker networks that don't have corresponding canary files
fn cleanup_all_orphaned_docker_networks() -> Result<()> {
debug!("Scanning for orphaned Docker networks");

// List all Docker networks
let output = Command::new("docker")
.args(["network", "ls", "--format", "{{.Name}}"])
.output()
.context("Failed to list Docker networks")?;

if !output.status.success() {
warn!("Failed to list Docker networks for cleanup");
return Ok(());
}

let networks = String::from_utf8_lossy(&output.stdout);
let canary_dir = crate::jail::get_canary_dir();

for network_name in networks.lines() {
// Extract jail_id from network name (skip non-httpjail networks)
let Some(jail_id) = DockerNetwork::jail_id_from_network_name(network_name) else {
continue;
};

// Check if canary file exists for this jail
let canary_path = canary_dir.join(jail_id);
if !canary_path.exists() {
info!(
"Found orphaned Docker network {} without canary, removing",
network_name
);

// Remove the orphaned network
let rm_output = Command::new("docker")
.args(["network", "rm", network_name])
.output()
.context("Failed to remove orphaned Docker network")?;

if !rm_output.status.success() {
let stderr = String::from_utf8_lossy(&rm_output.stderr);
if !DockerNetwork::is_not_found_error(&stderr) {
warn!(
"Failed to remove orphaned Docker network {}: {}",
network_name, stderr
);
}
}
}
}

Ok(())
}

/// Docker flags that take a value as the next argument
const FLAGS_WITH_VALUES: &'static [&'static str] =
&["-e", "-v", "-p", "--name", "--entrypoint", "-w", "--user"];

/// Build the docker command with isolated network
fn build_docker_command(
&self,
docker_args: &[String],
extra_env: &[(String, String)],
) -> Result<Command> {
let network_name = format!("httpjail_{}", self.config.jail_id);
let network_name = DockerNetwork::network_name_from_jail_id(&self.config.jail_id);
// Parse docker arguments to filter out conflicting options and find the image
let modified_args = Self::filter_network_args(docker_args);

// Find where the image name is in the args
let mut image_idx = None;
let mut skip_next = false;

for (i, arg) in modified_args.iter().enumerate() {
if skip_next {
skip_next = false;
continue;
}

// Skip known flags that take values
if arg == "-e"
|| arg == "-v"
|| arg == "-p"
|| arg == "--name"
|| arg == "--entrypoint"
|| arg == "-w"
|| arg == "--user"
{
skip_next = true;
continue;
}

// If it doesn't start with -, it's likely the image
if !arg.starts_with('-') {
image_idx = Some(i);
break;
}
}

let image_idx = image_idx.context("Could not find Docker image in arguments")?;
let image_idx = Self::find_image_index(&modified_args)
.context("Could not find Docker image in arguments")?;

// Split args into: docker options, image, and command
let docker_opts = &modified_args[..image_idx];
Expand DownExpand Up@@ -302,6 +355,31 @@ impl DockerLinux {
Ok(cmd)
}

/// Find the index of the Docker image in the arguments
fn find_image_index(args: &[String]) -> Option<usize> {
let mut skip_next = false;

for (i, arg) in args.iter().enumerate() {
if skip_next {
skip_next = false;
continue;
}

// Skip known flags that take values
if Self::FLAGS_WITH_VALUES.contains(&arg.as_str()) {
skip_next = true;
continue;
}

// If it doesn't start with -, it's likely the image
if !arg.starts_with('-') {
return Some(i);
}
}

None
}

/// Filter out any existing --network arguments from docker args
fn filter_network_args(docker_args: &[String]) -> Vec<String> {
let mut modified_args = Vec::new();
Expand DownExpand Up@@ -347,7 +425,7 @@ impl DockerLinux {
// Add nftables rules to:
// 1. Allow traffic from Docker network to jail's proxy ports
// 2. DNAT HTTP/HTTPS traffic to the proxy
let table_name = format!("httpjail_docker_{}", self.config.jail_id);
let table_name = DockerRoutingTable::table_name_from_jail_id(&self.config.jail_id);

// Create nftables rules
let nft_rules = format!(
Expand DownExpand Up@@ -412,6 +490,10 @@ impl DockerLinux {

impl Jail for DockerLinux {
fn setup(&mut self, proxy_port: u16) -> Result<()> {
// Clean up any orphaned Docker networks first
// This handles cases where Docker networks exist without corresponding canary files
Self::cleanup_all_orphaned_docker_networks()?;
Comment thread
ammario marked this conversation as resolved.

// First setup the inner Linux jail
self.inner_jail.setup(proxy_port)?;

Expand Down
31 changes: 27 additions & 4 deletions src/jail/managed.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -225,12 +225,23 @@ impl<J: Jail> Jail for ManagedJail<J> {
// Cleanup orphans first
if self.enable_heartbeat {
self.cleanup_orphans()?;

// Create canary BEFORE setting up jail to prevent race condition
// where another jail's cleanup might delete our network
self.create_canary()?;
}

// Setup the inner jail
self.jail.setup(proxy_port)?;
// Setup the inner jail (which may create Docker networks)
let setup_result = self.jail.setup(proxy_port);

// If setup failed, clean up the canary we created
if setup_result.is_err() && self.enable_heartbeat {
let _ = self.delete_canary();
return setup_result;
}

// Start heartbeat after successful setup
// Start heartbeat thread after successful setup
// Note: This will try to create canary again but create_canary is idempotent
self.start_heartbeat()?;

Ok(())
Expand DownExpand Up@@ -272,8 +283,20 @@ impl<J: Jail> Drop for ManagedJail<J> {
fn drop(&mut self) {
// Best effort cleanup
let _ = self.stop_heartbeat();
if self.enable_heartbeat {

// Explicitly cleanup jail resources BEFORE deleting canary
// This ensures resources are freed before we signal that the jail is gone
let cleanup_result = self.jail.cleanup();

// Only delete canary if cleanup succeeded
// If cleanup failed, leave the canary so orphan cleanup can retry later
if self.enable_heartbeat && cleanup_result.is_ok() {
let _ = self.delete_canary();
} else if cleanup_result.is_err() {
error!(
"Failed to cleanup jail '{}', leaving canary for orphan cleanup",
self.jail.jail_id()
);
}
}
}
9 changes: 8 additions & 1 deletion src/jail/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,7 +51,14 @@ pub trait Jail: Send + Sync {

/// Get the canary directory for tracking jail lifetimes
pub fn get_canary_dir() -> std::path::PathBuf {
std::path::PathBuf::from("/tmp/httpjail")
// Use user data directory instead of /tmp to avoid issues with tmp cleaners
// This ensures canaries persist across reboots and are only removed when we want them to be
if let Some(data_dir) = dirs::data_dir() {
data_dir.join("httpjail").join("canaries")
} else {
// Fallback to /tmp if we can't get user data dir (should rarely happen)
std::path::PathBuf::from("/tmp/httpjail")
}
}

/// Get the directory for httpjail temporary files (like resolv.conf)
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
5 changes: 5 additions & 0 deletions .claude/commands/pr-compress.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
Deeply review existing change.

Try to find opportunities for DRY. i.e. where you can push the net balance of code additions as low as possible while simultaneously improving readability and behavior.

Do not venture into changes beyond the scope of the PR.
186 changes: 134 additions & 52 deletions src/jail/linux/docker.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,19 +12,51 @@ struct DockerNetwork {
network_name: String,
}

impl DockerNetwork {
const NETWORK_PREFIX: &'static str = "httpjail_";

/// Generate network name from jail ID
fn network_name_from_jail_id(jail_id: &str) -> String {
format!("{}{}", Self::NETWORK_PREFIX, jail_id)
}

/// Extract jail ID from network name
fn jail_id_from_network_name(network_name: &str) -> Option<&str> {
network_name.strip_prefix(Self::NETWORK_PREFIX)
}

/// Check if a Docker command failed due to resource not existing
fn is_not_found_error(stderr: &str) -> bool {
stderr.contains("not found")
|| stderr.contains("No such")
|| stderr.contains("does not exist")
}

/// Check if a Docker command failed due to resource already existing
fn is_already_exists_error(stderr: &str) -> bool {
stderr.contains("already exists")
}
}

/// Docker routing nftables resource that gets cleaned up on drop
struct DockerRoutingTable {
#[allow(dead_code)]
jail_id: String,
table_name: String,
}

impl DockerRoutingTable {
/// Generate table name from jail ID
fn table_name_from_jail_id(jail_id: &str) -> String {
format!("httpjail_docker_{}", jail_id)
}
}

impl SystemResource for DockerRoutingTable {
fn create(jail_id: &str) -> Result<Self> {
let table_name = format!("httpjail_docker_{}", jail_id);
Ok(Self {
jail_id: jail_id.to_string(),
table_name,
table_name: Self::table_name_from_jail_id(jail_id),
})
}

Expand All@@ -38,10 +70,10 @@ impl SystemResource for DockerRoutingTable {

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("No such file or directory") && !stderr.contains("does not exist") {
warn!("Failed to delete Docker routing table: {}", stderr);
} else {
if DockerNetwork::is_not_found_error(&stderr) {
debug!("Docker routing table {} already removed", self.table_name);
} else {
warn!("Failed to delete Docker routing table: {}", stderr);
}
} else {
info!("Removed Docker routing table {}", self.table_name);
Expand All@@ -51,25 +83,16 @@ impl SystemResource for DockerRoutingTable {
}

fn for_existing(jail_id: &str) -> Self {
let table_name = format!("httpjail_docker_{}", jail_id);
Self {
jail_id: jail_id.to_string(),
table_name,
table_name: Self::table_name_from_jail_id(jail_id),
}
}
}

impl DockerNetwork {
#[allow(dead_code)]
fn new(jail_id: &str) -> Result<Self> {
let network_name = format!("httpjail_{}", jail_id);
Ok(Self { network_name })
}
}

impl SystemResource for DockerNetwork {
fn create(jail_id: &str) -> Result<Self> {
let network_name = format!("httpjail_{}", jail_id);
let network_name = Self::network_name_from_jail_id(jail_id);

// Create Docker network with no default gateway (isolated)
// Using a /24 subnet in the 172.20.x.x range
Expand All@@ -92,7 +115,7 @@ impl SystemResource for DockerNetwork {

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("already exists") {
if Self::is_already_exists_error(&stderr) {
info!("Docker network {} already exists", network_name);
} else {
anyhow::bail!("Failed to create Docker network: {}", stderr);
Expand All@@ -117,7 +140,7 @@ impl SystemResource for DockerNetwork {

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("not found") {
if Self::is_not_found_error(&stderr) {
debug!("Docker network {} already removed", self.network_name);
} else {
warn!("Failed to remove Docker network: {}", stderr);
Expand All@@ -130,8 +153,9 @@ impl SystemResource for DockerNetwork {
}

fn for_existing(jail_id: &str) -> Self {
let network_name = format!("httpjail_{}", jail_id);
Self { network_name }
Self {
network_name: Self::network_name_from_jail_id(jail_id),
}
}
}

Expand DownExpand Up@@ -202,47 +226,76 @@ impl DockerLinux {
})
}

/// Clean up all orphaned Docker networks that don't have corresponding canary files
fn cleanup_all_orphaned_docker_networks() -> Result<()> {
debug!("Scanning for orphaned Docker networks");

// List all Docker networks
let output = Command::new("docker")
.args(["network", "ls", "--format", "{{.Name}}"])
.output()
.context("Failed to list Docker networks")?;

if !output.status.success() {
warn!("Failed to list Docker networks for cleanup");
return Ok(());
}

let networks = String::from_utf8_lossy(&output.stdout);
let canary_dir = crate::jail::get_canary_dir();

for network_name in networks.lines() {
// Extract jail_id from network name (skip non-httpjail networks)
let Some(jail_id) = DockerNetwork::jail_id_from_network_name(network_name) else {
continue;
};

// Check if canary file exists for this jail
let canary_path = canary_dir.join(jail_id);
if !canary_path.exists() {
info!(
"Found orphaned Docker network {} without canary, removing",
network_name
);

// Remove the orphaned network
let rm_output = Command::new("docker")
.args(["network", "rm", network_name])
.output()
.context("Failed to remove orphaned Docker network")?;

if !rm_output.status.success() {
let stderr = String::from_utf8_lossy(&rm_output.stderr);
if !DockerNetwork::is_not_found_error(&stderr) {
warn!(
"Failed to remove orphaned Docker network {}: {}",
network_name, stderr
);
}
}
}
}

Ok(())
}

/// Docker flags that take a value as the next argument
const FLAGS_WITH_VALUES: &'static [&'static str] =
&["-e", "-v", "-p", "--name", "--entrypoint", "-w", "--user"];

/// Build the docker command with isolated network
fn build_docker_command(
&self,
docker_args: &[String],
extra_env: &[(String, String)],
) -> Result<Command> {
let network_name = format!("httpjail_{}", self.config.jail_id);
let network_name = DockerNetwork::network_name_from_jail_id(&self.config.jail_id);
// Parse docker arguments to filter out conflicting options and find the image
let modified_args = Self::filter_network_args(docker_args);

// Find where the image name is in the args
let mut image_idx = None;
let mut skip_next = false;

for (i, arg) in modified_args.iter().enumerate() {
if skip_next {
skip_next = false;
continue;
}

// Skip known flags that take values
if arg == "-e"
|| arg == "-v"
|| arg == "-p"
|| arg == "--name"
|| arg == "--entrypoint"
|| arg == "-w"
|| arg == "--user"
{
skip_next = true;
continue;
}

// If it doesn't start with -, it's likely the image
if !arg.starts_with('-') {
image_idx = Some(i);
break;
}
}

let image_idx = image_idx.context("Could not find Docker image in arguments")?;
let image_idx = Self::find_image_index(&modified_args)
.context("Could not find Docker image in arguments")?;

// Split args into: docker options, image, and command
let docker_opts = &modified_args[..image_idx];
Expand DownExpand Up@@ -302,6 +355,31 @@ impl DockerLinux {
Ok(cmd)
}

/// Find the index of the Docker image in the arguments
fn find_image_index(args: &[String]) -> Option<usize> {
let mut skip_next = false;

for (i, arg) in args.iter().enumerate() {
if skip_next {
skip_next = false;
continue;
}

// Skip known flags that take values
if Self::FLAGS_WITH_VALUES.contains(&arg.as_str()) {
skip_next = true;
continue;
}

// If it doesn't start with -, it's likely the image
if !arg.starts_with('-') {
return Some(i);
}
}

None
}

/// Filter out any existing --network arguments from docker args
fn filter_network_args(docker_args: &[String]) -> Vec<String> {
let mut modified_args = Vec::new();
Expand DownExpand Up@@ -347,7 +425,7 @@ impl DockerLinux {
// Add nftables rules to:
// 1. Allow traffic from Docker network to jail's proxy ports
// 2. DNAT HTTP/HTTPS traffic to the proxy
let table_name = format!("httpjail_docker_{}", self.config.jail_id);
let table_name = DockerRoutingTable::table_name_from_jail_id(&self.config.jail_id);

// Create nftables rules
let nft_rules = format!(
Expand DownExpand Up@@ -412,6 +490,10 @@ impl DockerLinux {

impl Jail for DockerLinux {
fn setup(&mut self, proxy_port: u16) -> Result<()> {
// Clean up any orphaned Docker networks first
// This handles cases where Docker networks exist without corresponding canary files
Self::cleanup_all_orphaned_docker_networks()?;
Comment thread
ammario marked this conversation as resolved.

// First setup the inner Linux jail
self.inner_jail.setup(proxy_port)?;

Expand Down
31 changes: 27 additions & 4 deletions src/jail/managed.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -225,12 +225,23 @@ impl<J: Jail> Jail for ManagedJail<J> {
// Cleanup orphans first
if self.enable_heartbeat {
self.cleanup_orphans()?;

// Create canary BEFORE setting up jail to prevent race condition
// where another jail's cleanup might delete our network
self.create_canary()?;
}

// Setup the inner jail
self.jail.setup(proxy_port)?;
// Setup the inner jail (which may create Docker networks)
let setup_result = self.jail.setup(proxy_port);

// If setup failed, clean up the canary we created
if setup_result.is_err() && self.enable_heartbeat {
let _ = self.delete_canary();
return setup_result;
}

// Start heartbeat after successful setup
// Start heartbeat thread after successful setup
// Note: This will try to create canary again but create_canary is idempotent
self.start_heartbeat()?;

Ok(())
Expand DownExpand Up@@ -272,8 +283,20 @@ impl<J: Jail> Drop for ManagedJail<J> {
fn drop(&mut self) {
// Best effort cleanup
let _ = self.stop_heartbeat();
if self.enable_heartbeat {

// Explicitly cleanup jail resources BEFORE deleting canary
// This ensures resources are freed before we signal that the jail is gone
let cleanup_result = self.jail.cleanup();

// Only delete canary if cleanup succeeded
// If cleanup failed, leave the canary so orphan cleanup can retry later
if self.enable_heartbeat && cleanup_result.is_ok() {
let _ = self.delete_canary();
} else if cleanup_result.is_err() {
error!(
"Failed to cleanup jail '{}', leaving canary for orphan cleanup",
self.jail.jail_id()
);
}
}
}
9 changes: 8 additions & 1 deletion src/jail/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,7 +51,14 @@ pub trait Jail: Send + Sync {

/// Get the canary directory for tracking jail lifetimes
pub fn get_canary_dir() -> std::path::PathBuf {
std::path::PathBuf::from("/tmp/httpjail")
// Use user data directory instead of /tmp to avoid issues with tmp cleaners
// This ensures canaries persist across reboots and are only removed when we want them to be
if let Some(data_dir) = dirs::data_dir() {
data_dir.join("httpjail").join("canaries")
} else {
// Fallback to /tmp if we can't get user data dir (should rarely happen)
std::path::PathBuf::from("/tmp/httpjail")
}
}

/// Get the directory for httpjail temporary files (like resolv.conf)
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
5 changes: 5 additions & 0 deletions .claude/commands/pr-compress.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
Deeply review existing change.

Try to find opportunities for DRY. i.e. where you can push the net balance of code additions as low as possible while simultaneously improving readability and behavior.

Do not venture into changes beyond the scope of the PR.
186 changes: 134 additions & 52 deletions src/jail/linux/docker.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,19 +12,51 @@ struct DockerNetwork {
network_name: String,
}

impl DockerNetwork {
const NETWORK_PREFIX: &'static str = "httpjail_";

/// Generate network name from jail ID
fn network_name_from_jail_id(jail_id: &str) -> String {
format!("{}{}", Self::NETWORK_PREFIX, jail_id)
}

/// Extract jail ID from network name
fn jail_id_from_network_name(network_name: &str) -> Option<&str> {
network_name.strip_prefix(Self::NETWORK_PREFIX)
}

/// Check if a Docker command failed due to resource not existing
fn is_not_found_error(stderr: &str) -> bool {
stderr.contains("not found")
|| stderr.contains("No such")
|| stderr.contains("does not exist")
}

/// Check if a Docker command failed due to resource already existing
fn is_already_exists_error(stderr: &str) -> bool {
stderr.contains("already exists")
}
}

/// Docker routing nftables resource that gets cleaned up on drop
struct DockerRoutingTable {
#[allow(dead_code)]
jail_id: String,
table_name: String,
}

impl DockerRoutingTable {
/// Generate table name from jail ID
fn table_name_from_jail_id(jail_id: &str) -> String {
format!("httpjail_docker_{}", jail_id)
}
}

impl SystemResource for DockerRoutingTable {
fn create(jail_id: &str) -> Result<Self> {
let table_name = format!("httpjail_docker_{}", jail_id);
Ok(Self {
jail_id: jail_id.to_string(),
table_name,
table_name: Self::table_name_from_jail_id(jail_id),
})
}

Expand All@@ -38,10 +70,10 @@ impl SystemResource for DockerRoutingTable {

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("No such file or directory") && !stderr.contains("does not exist") {
warn!("Failed to delete Docker routing table: {}", stderr);
} else {
if DockerNetwork::is_not_found_error(&stderr) {
debug!("Docker routing table {} already removed", self.table_name);
} else {
warn!("Failed to delete Docker routing table: {}", stderr);
}
} else {
info!("Removed Docker routing table {}", self.table_name);
Expand All@@ -51,25 +83,16 @@ impl SystemResource for DockerRoutingTable {
}

fn for_existing(jail_id: &str) -> Self {
let table_name = format!("httpjail_docker_{}", jail_id);
Self {
jail_id: jail_id.to_string(),
table_name,
table_name: Self::table_name_from_jail_id(jail_id),
}
}
}

impl DockerNetwork {
#[allow(dead_code)]
fn new(jail_id: &str) -> Result<Self> {
let network_name = format!("httpjail_{}", jail_id);
Ok(Self { network_name })
}
}

impl SystemResource for DockerNetwork {
fn create(jail_id: &str) -> Result<Self> {
let network_name = format!("httpjail_{}", jail_id);
let network_name = Self::network_name_from_jail_id(jail_id);

// Create Docker network with no default gateway (isolated)
// Using a /24 subnet in the 172.20.x.x range
Expand All@@ -92,7 +115,7 @@ impl SystemResource for DockerNetwork {

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("already exists") {
if Self::is_already_exists_error(&stderr) {
info!("Docker network {} already exists", network_name);
} else {
anyhow::bail!("Failed to create Docker network: {}", stderr);
Expand All@@ -117,7 +140,7 @@ impl SystemResource for DockerNetwork {

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("not found") {
if Self::is_not_found_error(&stderr) {
debug!("Docker network {} already removed", self.network_name);
} else {
warn!("Failed to remove Docker network: {}", stderr);
Expand All@@ -130,8 +153,9 @@ impl SystemResource for DockerNetwork {
}

fn for_existing(jail_id: &str) -> Self {
let network_name = format!("httpjail_{}", jail_id);
Self { network_name }
Self {
network_name: Self::network_name_from_jail_id(jail_id),
}
}
}

Expand DownExpand Up@@ -202,47 +226,76 @@ impl DockerLinux {
})
}

/// Clean up all orphaned Docker networks that don't have corresponding canary files
fn cleanup_all_orphaned_docker_networks() -> Result<()> {
debug!("Scanning for orphaned Docker networks");

// List all Docker networks
let output = Command::new("docker")
.args(["network", "ls", "--format", "{{.Name}}"])
.output()
.context("Failed to list Docker networks")?;

if !output.status.success() {
warn!("Failed to list Docker networks for cleanup");
return Ok(());
}

let networks = String::from_utf8_lossy(&output.stdout);
let canary_dir = crate::jail::get_canary_dir();

for network_name in networks.lines() {
// Extract jail_id from network name (skip non-httpjail networks)
let Some(jail_id) = DockerNetwork::jail_id_from_network_name(network_name) else {
continue;
};

// Check if canary file exists for this jail
let canary_path = canary_dir.join(jail_id);
if !canary_path.exists() {
info!(
"Found orphaned Docker network {} without canary, removing",
network_name
);

// Remove the orphaned network
let rm_output = Command::new("docker")
.args(["network", "rm", network_name])
.output()
.context("Failed to remove orphaned Docker network")?;

if !rm_output.status.success() {
let stderr = String::from_utf8_lossy(&rm_output.stderr);
if !DockerNetwork::is_not_found_error(&stderr) {
warn!(
"Failed to remove orphaned Docker network {}: {}",
network_name, stderr
);
}
}
}
}

Ok(())
}

/// Docker flags that take a value as the next argument
const FLAGS_WITH_VALUES: &'static [&'static str] =
&["-e", "-v", "-p", "--name", "--entrypoint", "-w", "--user"];

/// Build the docker command with isolated network
fn build_docker_command(
&self,
docker_args: &[String],
extra_env: &[(String, String)],
) -> Result<Command> {
let network_name = format!("httpjail_{}", self.config.jail_id);
let network_name = DockerNetwork::network_name_from_jail_id(&self.config.jail_id);
// Parse docker arguments to filter out conflicting options and find the image
let modified_args = Self::filter_network_args(docker_args);

// Find where the image name is in the args
let mut image_idx = None;
let mut skip_next = false;

for (i, arg) in modified_args.iter().enumerate() {
if skip_next {
skip_next = false;
continue;
}

// Skip known flags that take values
if arg == "-e"
|| arg == "-v"
|| arg == "-p"
|| arg == "--name"
|| arg == "--entrypoint"
|| arg == "-w"
|| arg == "--user"
{
skip_next = true;
continue;
}

// If it doesn't start with -, it's likely the image
if !arg.starts_with('-') {
image_idx = Some(i);
break;
}
}

let image_idx = image_idx.context("Could not find Docker image in arguments")?;
let image_idx = Self::find_image_index(&modified_args)
.context("Could not find Docker image in arguments")?;

// Split args into: docker options, image, and command
let docker_opts = &modified_args[..image_idx];
Expand DownExpand Up@@ -302,6 +355,31 @@ impl DockerLinux {
Ok(cmd)
}

/// Find the index of the Docker image in the arguments
fn find_image_index(args: &[String]) -> Option<usize> {
let mut skip_next = false;

for (i, arg) in args.iter().enumerate() {
if skip_next {
skip_next = false;
continue;
}

// Skip known flags that take values
if Self::FLAGS_WITH_VALUES.contains(&arg.as_str()) {
skip_next = true;
continue;
}

// If it doesn't start with -, it's likely the image
if !arg.starts_with('-') {
return Some(i);
}
}

None
}

/// Filter out any existing --network arguments from docker args
fn filter_network_args(docker_args: &[String]) -> Vec<String> {
let mut modified_args = Vec::new();
Expand DownExpand Up@@ -347,7 +425,7 @@ impl DockerLinux {
// Add nftables rules to:
// 1. Allow traffic from Docker network to jail's proxy ports
// 2. DNAT HTTP/HTTPS traffic to the proxy
let table_name = format!("httpjail_docker_{}", self.config.jail_id);
let table_name = DockerRoutingTable::table_name_from_jail_id(&self.config.jail_id);

// Create nftables rules
let nft_rules = format!(
Expand DownExpand Up@@ -412,6 +490,10 @@ impl DockerLinux {

impl Jail for DockerLinux {
fn setup(&mut self, proxy_port: u16) -> Result<()> {
// Clean up any orphaned Docker networks first
// This handles cases where Docker networks exist without corresponding canary files
Self::cleanup_all_orphaned_docker_networks()?;
Comment thread
ammario marked this conversation as resolved.

// First setup the inner Linux jail
self.inner_jail.setup(proxy_port)?;

Expand Down
31 changes: 27 additions & 4 deletions src/jail/managed.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -225,12 +225,23 @@ impl<J: Jail> Jail for ManagedJail<J> {
// Cleanup orphans first
if self.enable_heartbeat {
self.cleanup_orphans()?;

// Create canary BEFORE setting up jail to prevent race condition
// where another jail's cleanup might delete our network
self.create_canary()?;
}

// Setup the inner jail
self.jail.setup(proxy_port)?;
// Setup the inner jail (which may create Docker networks)
let setup_result = self.jail.setup(proxy_port);

// If setup failed, clean up the canary we created
if setup_result.is_err() && self.enable_heartbeat {
let _ = self.delete_canary();
return setup_result;
}

// Start heartbeat after successful setup
// Start heartbeat thread after successful setup
// Note: This will try to create canary again but create_canary is idempotent
self.start_heartbeat()?;

Ok(())
Expand DownExpand Up@@ -272,8 +283,20 @@ impl<J: Jail> Drop for ManagedJail<J> {
fn drop(&mut self) {
// Best effort cleanup
let _ = self.stop_heartbeat();
if self.enable_heartbeat {

// Explicitly cleanup jail resources BEFORE deleting canary
// This ensures resources are freed before we signal that the jail is gone
let cleanup_result = self.jail.cleanup();

// Only delete canary if cleanup succeeded
// If cleanup failed, leave the canary so orphan cleanup can retry later
if self.enable_heartbeat && cleanup_result.is_ok() {
let _ = self.delete_canary();
} else if cleanup_result.is_err() {
error!(
"Failed to cleanup jail '{}', leaving canary for orphan cleanup",
self.jail.jail_id()
);
}
}
}
9 changes: 8 additions & 1 deletion src/jail/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,7 +51,14 @@ pub trait Jail: Send + Sync {

/// Get the canary directory for tracking jail lifetimes
pub fn get_canary_dir() -> std::path::PathBuf {
std::path::PathBuf::from("/tmp/httpjail")
// Use user data directory instead of /tmp to avoid issues with tmp cleaners
// This ensures canaries persist across reboots and are only removed when we want them to be
if let Some(data_dir) = dirs::data_dir() {
data_dir.join("httpjail").join("canaries")
} else {
// Fallback to /tmp if we can't get user data dir (should rarely happen)
std::path::PathBuf::from("/tmp/httpjail")
}
}

/// Get the directory for httpjail temporary files (like resolv.conf)
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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
5 changes: 5 additions & 0 deletions .claude/commands/pr-compress.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
Deeply review existing change.

Try to find opportunities for DRY. i.e. where you can push the net balance of code additions as low as possible while simultaneously improving readability and behavior.

Do not venture into changes beyond the scope of the PR.
186 changes: 134 additions & 52 deletions src/jail/linux/docker.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,19 +12,51 @@ struct DockerNetwork {
network_name: String,
}

impl DockerNetwork {
const NETWORK_PREFIX: &'static str = "httpjail_";

/// Generate network name from jail ID
fn network_name_from_jail_id(jail_id: &str) -> String {
format!("{}{}", Self::NETWORK_PREFIX, jail_id)
}

/// Extract jail ID from network name
fn jail_id_from_network_name(network_name: &str) -> Option<&str> {
network_name.strip_prefix(Self::NETWORK_PREFIX)
}

/// Check if a Docker command failed due to resource not existing
fn is_not_found_error(stderr: &str) -> bool {
stderr.contains("not found")
|| stderr.contains("No such")
|| stderr.contains("does not exist")
}

/// Check if a Docker command failed due to resource already existing
fn is_already_exists_error(stderr: &str) -> bool {
stderr.contains("already exists")
}
}

/// Docker routing nftables resource that gets cleaned up on drop
struct DockerRoutingTable {
#[allow(dead_code)]
jail_id: String,
table_name: String,
}

impl DockerRoutingTable {
/// Generate table name from jail ID
fn table_name_from_jail_id(jail_id: &str) -> String {
format!("httpjail_docker_{}", jail_id)
}
}

impl SystemResource for DockerRoutingTable {
fn create(jail_id: &str) -> Result<Self> {
let table_name = format!("httpjail_docker_{}", jail_id);
Ok(Self {
jail_id: jail_id.to_string(),
table_name,
table_name: Self::table_name_from_jail_id(jail_id),
})
}

Expand All@@ -38,10 +70,10 @@ impl SystemResource for DockerRoutingTable {

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("No such file or directory") && !stderr.contains("does not exist") {
warn!("Failed to delete Docker routing table: {}", stderr);
} else {
if DockerNetwork::is_not_found_error(&stderr) {
debug!("Docker routing table {} already removed", self.table_name);
} else {
warn!("Failed to delete Docker routing table: {}", stderr);
}
} else {
info!("Removed Docker routing table {}", self.table_name);
Expand All@@ -51,25 +83,16 @@ impl SystemResource for DockerRoutingTable {
}

fn for_existing(jail_id: &str) -> Self {
let table_name = format!("httpjail_docker_{}", jail_id);
Self {
jail_id: jail_id.to_string(),
table_name,
table_name: Self::table_name_from_jail_id(jail_id),
}
}
}

impl DockerNetwork {
#[allow(dead_code)]
fn new(jail_id: &str) -> Result<Self> {
let network_name = format!("httpjail_{}", jail_id);
Ok(Self { network_name })
}
}

impl SystemResource for DockerNetwork {
fn create(jail_id: &str) -> Result<Self> {
let network_name = format!("httpjail_{}", jail_id);
let network_name = Self::network_name_from_jail_id(jail_id);

// Create Docker network with no default gateway (isolated)
// Using a /24 subnet in the 172.20.x.x range
Expand All@@ -92,7 +115,7 @@ impl SystemResource for DockerNetwork {

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("already exists") {
if Self::is_already_exists_error(&stderr) {
info!("Docker network {} already exists", network_name);
} else {
anyhow::bail!("Failed to create Docker network: {}", stderr);
Expand All@@ -117,7 +140,7 @@ impl SystemResource for DockerNetwork {

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("not found") {
if Self::is_not_found_error(&stderr) {
debug!("Docker network {} already removed", self.network_name);
} else {
warn!("Failed to remove Docker network: {}", stderr);
Expand All@@ -130,8 +153,9 @@ impl SystemResource for DockerNetwork {
}

fn for_existing(jail_id: &str) -> Self {
let network_name = format!("httpjail_{}", jail_id);
Self { network_name }
Self {
network_name: Self::network_name_from_jail_id(jail_id),
}
}
}

Expand DownExpand Up@@ -202,47 +226,76 @@ impl DockerLinux {
})
}

/// Clean up all orphaned Docker networks that don't have corresponding canary files
fn cleanup_all_orphaned_docker_networks() -> Result<()> {
debug!("Scanning for orphaned Docker networks");

// List all Docker networks
let output = Command::new("docker")
.args(["network", "ls", "--format", "{{.Name}}"])
.output()
.context("Failed to list Docker networks")?;

if !output.status.success() {
warn!("Failed to list Docker networks for cleanup");
return Ok(());
}

let networks = String::from_utf8_lossy(&output.stdout);
let canary_dir = crate::jail::get_canary_dir();

for network_name in networks.lines() {
// Extract jail_id from network name (skip non-httpjail networks)
let Some(jail_id) = DockerNetwork::jail_id_from_network_name(network_name) else {
continue;
};

// Check if canary file exists for this jail
let canary_path = canary_dir.join(jail_id);
if !canary_path.exists() {
info!(
"Found orphaned Docker network {} without canary, removing",
network_name
);

// Remove the orphaned network
let rm_output = Command::new("docker")
.args(["network", "rm", network_name])
.output()
.context("Failed to remove orphaned Docker network")?;

if !rm_output.status.success() {
let stderr = String::from_utf8_lossy(&rm_output.stderr);
if !DockerNetwork::is_not_found_error(&stderr) {
warn!(
"Failed to remove orphaned Docker network {}: {}",
network_name, stderr
);
}
}
}
}

Ok(())
}

/// Docker flags that take a value as the next argument
const FLAGS_WITH_VALUES: &'static [&'static str] =
&["-e", "-v", "-p", "--name", "--entrypoint", "-w", "--user"];

/// Build the docker command with isolated network
fn build_docker_command(
&self,
docker_args: &[String],
extra_env: &[(String, String)],
) -> Result<Command> {
let network_name = format!("httpjail_{}", self.config.jail_id);
let network_name = DockerNetwork::network_name_from_jail_id(&self.config.jail_id);
// Parse docker arguments to filter out conflicting options and find the image
let modified_args = Self::filter_network_args(docker_args);

// Find where the image name is in the args
let mut image_idx = None;
let mut skip_next = false;

for (i, arg) in modified_args.iter().enumerate() {
if skip_next {
skip_next = false;
continue;
}

// Skip known flags that take values
if arg == "-e"
|| arg == "-v"
|| arg == "-p"
|| arg == "--name"
|| arg == "--entrypoint"
|| arg == "-w"
|| arg == "--user"
{
skip_next = true;
continue;
}

// If it doesn't start with -, it's likely the image
if !arg.starts_with('-') {
image_idx = Some(i);
break;
}
}

let image_idx = image_idx.context("Could not find Docker image in arguments")?;
let image_idx = Self::find_image_index(&modified_args)
.context("Could not find Docker image in arguments")?;

// Split args into: docker options, image, and command
let docker_opts = &modified_args[..image_idx];
Expand DownExpand Up@@ -302,6 +355,31 @@ impl DockerLinux {
Ok(cmd)
}

/// Find the index of the Docker image in the arguments
fn find_image_index(args: &[String]) -> Option<usize> {
let mut skip_next = false;

for (i, arg) in args.iter().enumerate() {
if skip_next {
skip_next = false;
continue;
}

// Skip known flags that take values
if Self::FLAGS_WITH_VALUES.contains(&arg.as_str()) {
skip_next = true;
continue;
}

// If it doesn't start with -, it's likely the image
if !arg.starts_with('-') {
return Some(i);
}
}

None
}

/// Filter out any existing --network arguments from docker args
fn filter_network_args(docker_args: &[String]) -> Vec<String> {
let mut modified_args = Vec::new();
Expand DownExpand Up@@ -347,7 +425,7 @@ impl DockerLinux {
// Add nftables rules to:
// 1. Allow traffic from Docker network to jail's proxy ports
// 2. DNAT HTTP/HTTPS traffic to the proxy
let table_name = format!("httpjail_docker_{}", self.config.jail_id);
let table_name = DockerRoutingTable::table_name_from_jail_id(&self.config.jail_id);

// Create nftables rules
let nft_rules = format!(
Expand DownExpand Up@@ -412,6 +490,10 @@ impl DockerLinux {

impl Jail for DockerLinux {
fn setup(&mut self, proxy_port: u16) -> Result<()> {
// Clean up any orphaned Docker networks first
// This handles cases where Docker networks exist without corresponding canary files
Self::cleanup_all_orphaned_docker_networks()?;
Comment thread
ammario marked this conversation as resolved.

// First setup the inner Linux jail
self.inner_jail.setup(proxy_port)?;

Expand Down
31 changes: 27 additions & 4 deletions src/jail/managed.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -225,12 +225,23 @@ impl<J: Jail> Jail for ManagedJail<J> {
// Cleanup orphans first
if self.enable_heartbeat {
self.cleanup_orphans()?;

// Create canary BEFORE setting up jail to prevent race condition
// where another jail's cleanup might delete our network
self.create_canary()?;
}

// Setup the inner jail
self.jail.setup(proxy_port)?;
// Setup the inner jail (which may create Docker networks)
let setup_result = self.jail.setup(proxy_port);

// If setup failed, clean up the canary we created
if setup_result.is_err() && self.enable_heartbeat {
let _ = self.delete_canary();
return setup_result;
}

// Start heartbeat after successful setup
// Start heartbeat thread after successful setup
// Note: This will try to create canary again but create_canary is idempotent
self.start_heartbeat()?;

Ok(())
Expand DownExpand Up@@ -272,8 +283,20 @@ impl<J: Jail> Drop for ManagedJail<J> {
fn drop(&mut self) {
// Best effort cleanup
let _ = self.stop_heartbeat();
if self.enable_heartbeat {

// Explicitly cleanup jail resources BEFORE deleting canary
// This ensures resources are freed before we signal that the jail is gone
let cleanup_result = self.jail.cleanup();

// Only delete canary if cleanup succeeded
// If cleanup failed, leave the canary so orphan cleanup can retry later
if self.enable_heartbeat && cleanup_result.is_ok() {
let _ = self.delete_canary();
} else if cleanup_result.is_err() {
error!(
"Failed to cleanup jail '{}', leaving canary for orphan cleanup",
self.jail.jail_id()
);
}
}
}
9 changes: 8 additions & 1 deletion src/jail/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,7 +51,14 @@ pub trait Jail: Send + Sync {

/// Get the canary directory for tracking jail lifetimes
pub fn get_canary_dir() -> std::path::PathBuf {
std::path::PathBuf::from("/tmp/httpjail")
// Use user data directory instead of /tmp to avoid issues with tmp cleaners
// This ensures canaries persist across reboots and are only removed when we want them to be
if let Some(data_dir) = dirs::data_dir() {
data_dir.join("httpjail").join("canaries")
} else {
// Fallback to /tmp if we can't get user data dir (should rarely happen)
std::path::PathBuf::from("/tmp/httpjail")
}
}

/// Get the directory for httpjail temporary files (like resolv.conf)
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
5 changes: 5 additions & 0 deletions .claude/commands/pr-compress.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
Deeply review existing change.

Try to find opportunities for DRY. i.e. where you can push the net balance of code additions as low as possible while simultaneously improving readability and behavior.

Do not venture into changes beyond the scope of the PR.
186 changes: 134 additions & 52 deletions src/jail/linux/docker.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,19 +12,51 @@ struct DockerNetwork {
network_name: String,
}

impl DockerNetwork {
const NETWORK_PREFIX: &'static str = "httpjail_";

/// Generate network name from jail ID
fn network_name_from_jail_id(jail_id: &str) -> String {
format!("{}{}", Self::NETWORK_PREFIX, jail_id)
}

/// Extract jail ID from network name
fn jail_id_from_network_name(network_name: &str) -> Option<&str> {
network_name.strip_prefix(Self::NETWORK_PREFIX)
}

/// Check if a Docker command failed due to resource not existing
fn is_not_found_error(stderr: &str) -> bool {
stderr.contains("not found")
|| stderr.contains("No such")
|| stderr.contains("does not exist")
}

/// Check if a Docker command failed due to resource already existing
fn is_already_exists_error(stderr: &str) -> bool {
stderr.contains("already exists")
}
}

/// Docker routing nftables resource that gets cleaned up on drop
struct DockerRoutingTable {
#[allow(dead_code)]
jail_id: String,
table_name: String,
}

impl DockerRoutingTable {
/// Generate table name from jail ID
fn table_name_from_jail_id(jail_id: &str) -> String {
format!("httpjail_docker_{}", jail_id)
}
}

impl SystemResource for DockerRoutingTable {
fn create(jail_id: &str) -> Result<Self> {
let table_name = format!("httpjail_docker_{}", jail_id);
Ok(Self {
jail_id: jail_id.to_string(),
table_name,
table_name: Self::table_name_from_jail_id(jail_id),
})
}

Expand All@@ -38,10 +70,10 @@ impl SystemResource for DockerRoutingTable {

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("No such file or directory") && !stderr.contains("does not exist") {
warn!("Failed to delete Docker routing table: {}", stderr);
} else {
if DockerNetwork::is_not_found_error(&stderr) {
debug!("Docker routing table {} already removed", self.table_name);
} else {
warn!("Failed to delete Docker routing table: {}", stderr);
}
} else {
info!("Removed Docker routing table {}", self.table_name);
Expand All@@ -51,25 +83,16 @@ impl SystemResource for DockerRoutingTable {
}

fn for_existing(jail_id: &str) -> Self {
let table_name = format!("httpjail_docker_{}", jail_id);
Self {
jail_id: jail_id.to_string(),
table_name,
table_name: Self::table_name_from_jail_id(jail_id),
}
}
}

impl DockerNetwork {
#[allow(dead_code)]
fn new(jail_id: &str) -> Result<Self> {
let network_name = format!("httpjail_{}", jail_id);
Ok(Self { network_name })
}
}

impl SystemResource for DockerNetwork {
fn create(jail_id: &str) -> Result<Self> {
let network_name = format!("httpjail_{}", jail_id);
let network_name = Self::network_name_from_jail_id(jail_id);

// Create Docker network with no default gateway (isolated)
// Using a /24 subnet in the 172.20.x.x range
Expand All@@ -92,7 +115,7 @@ impl SystemResource for DockerNetwork {

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("already exists") {
if Self::is_already_exists_error(&stderr) {
info!("Docker network {} already exists", network_name);
} else {
anyhow::bail!("Failed to create Docker network: {}", stderr);
Expand All@@ -117,7 +140,7 @@ impl SystemResource for DockerNetwork {

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("not found") {
if Self::is_not_found_error(&stderr) {
debug!("Docker network {} already removed", self.network_name);
} else {
warn!("Failed to remove Docker network: {}", stderr);
Expand All@@ -130,8 +153,9 @@ impl SystemResource for DockerNetwork {
}

fn for_existing(jail_id: &str) -> Self {
let network_name = format!("httpjail_{}", jail_id);
Self { network_name }
Self {
network_name: Self::network_name_from_jail_id(jail_id),
}
}
}

Expand DownExpand Up@@ -202,47 +226,76 @@ impl DockerLinux {
})
}

/// Clean up all orphaned Docker networks that don't have corresponding canary files
fn cleanup_all_orphaned_docker_networks() -> Result<()> {
debug!("Scanning for orphaned Docker networks");

// List all Docker networks
let output = Command::new("docker")
.args(["network", "ls", "--format", "{{.Name}}"])
.output()
.context("Failed to list Docker networks")?;

if !output.status.success() {
warn!("Failed to list Docker networks for cleanup");
return Ok(());
}

let networks = String::from_utf8_lossy(&output.stdout);
let canary_dir = crate::jail::get_canary_dir();

for network_name in networks.lines() {
// Extract jail_id from network name (skip non-httpjail networks)
let Some(jail_id) = DockerNetwork::jail_id_from_network_name(network_name) else {
continue;
};

// Check if canary file exists for this jail
let canary_path = canary_dir.join(jail_id);
if !canary_path.exists() {
info!(
"Found orphaned Docker network {} without canary, removing",
network_name
);

// Remove the orphaned network
let rm_output = Command::new("docker")
.args(["network", "rm", network_name])
.output()
.context("Failed to remove orphaned Docker network")?;

if !rm_output.status.success() {
let stderr = String::from_utf8_lossy(&rm_output.stderr);
if !DockerNetwork::is_not_found_error(&stderr) {
warn!(
"Failed to remove orphaned Docker network {}: {}",
network_name, stderr
);
}
}
}
}

Ok(())
}

/// Docker flags that take a value as the next argument
const FLAGS_WITH_VALUES: &'static [&'static str] =
&["-e", "-v", "-p", "--name", "--entrypoint", "-w", "--user"];

/// Build the docker command with isolated network
fn build_docker_command(
&self,
docker_args: &[String],
extra_env: &[(String, String)],
) -> Result<Command> {
let network_name = format!("httpjail_{}", self.config.jail_id);
let network_name = DockerNetwork::network_name_from_jail_id(&self.config.jail_id);
// Parse docker arguments to filter out conflicting options and find the image
let modified_args = Self::filter_network_args(docker_args);

// Find where the image name is in the args
let mut image_idx = None;
let mut skip_next = false;

for (i, arg) in modified_args.iter().enumerate() {
if skip_next {
skip_next = false;
continue;
}

// Skip known flags that take values
if arg == "-e"
|| arg == "-v"
|| arg == "-p"
|| arg == "--name"
|| arg == "--entrypoint"
|| arg == "-w"
|| arg == "--user"
{
skip_next = true;
continue;
}

// If it doesn't start with -, it's likely the image
if !arg.starts_with('-') {
image_idx = Some(i);
break;
}
}

let image_idx = image_idx.context("Could not find Docker image in arguments")?;
let image_idx = Self::find_image_index(&modified_args)
.context("Could not find Docker image in arguments")?;

// Split args into: docker options, image, and command
let docker_opts = &modified_args[..image_idx];
Expand DownExpand Up@@ -302,6 +355,31 @@ impl DockerLinux {
Ok(cmd)
}

/// Find the index of the Docker image in the arguments
fn find_image_index(args: &[String]) -> Option<usize> {
let mut skip_next = false;

for (i, arg) in args.iter().enumerate() {
if skip_next {
skip_next = false;
continue;
}

// Skip known flags that take values
if Self::FLAGS_WITH_VALUES.contains(&arg.as_str()) {
skip_next = true;
continue;
}

// If it doesn't start with -, it's likely the image
if !arg.starts_with('-') {
return Some(i);
}
}

None
}

/// Filter out any existing --network arguments from docker args
fn filter_network_args(docker_args: &[String]) -> Vec<String> {
let mut modified_args = Vec::new();
Expand DownExpand Up@@ -347,7 +425,7 @@ impl DockerLinux {
// Add nftables rules to:
// 1. Allow traffic from Docker network to jail's proxy ports
// 2. DNAT HTTP/HTTPS traffic to the proxy
let table_name = format!("httpjail_docker_{}", self.config.jail_id);
let table_name = DockerRoutingTable::table_name_from_jail_id(&self.config.jail_id);

// Create nftables rules
let nft_rules = format!(
Expand DownExpand Up@@ -412,6 +490,10 @@ impl DockerLinux {

impl Jail for DockerLinux {
fn setup(&mut self, proxy_port: u16) -> Result<()> {
// Clean up any orphaned Docker networks first
// This handles cases where Docker networks exist without corresponding canary files
Self::cleanup_all_orphaned_docker_networks()?;
Comment thread
ammario marked this conversation as resolved.

// First setup the inner Linux jail
self.inner_jail.setup(proxy_port)?;

Expand Down
31 changes: 27 additions & 4 deletions src/jail/managed.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -225,12 +225,23 @@ impl<J: Jail> Jail for ManagedJail<J> {
// Cleanup orphans first
if self.enable_heartbeat {
self.cleanup_orphans()?;

// Create canary BEFORE setting up jail to prevent race condition
// where another jail's cleanup might delete our network
self.create_canary()?;
}

// Setup the inner jail
self.jail.setup(proxy_port)?;
// Setup the inner jail (which may create Docker networks)
let setup_result = self.jail.setup(proxy_port);

// If setup failed, clean up the canary we created
if setup_result.is_err() && self.enable_heartbeat {
let _ = self.delete_canary();
return setup_result;
}

// Start heartbeat after successful setup
// Start heartbeat thread after successful setup
// Note: This will try to create canary again but create_canary is idempotent
self.start_heartbeat()?;

Ok(())
Expand DownExpand Up@@ -272,8 +283,20 @@ impl<J: Jail> Drop for ManagedJail<J> {
fn drop(&mut self) {
// Best effort cleanup
let _ = self.stop_heartbeat();
if self.enable_heartbeat {

// Explicitly cleanup jail resources BEFORE deleting canary
// This ensures resources are freed before we signal that the jail is gone
let cleanup_result = self.jail.cleanup();

// Only delete canary if cleanup succeeded
// If cleanup failed, leave the canary so orphan cleanup can retry later
if self.enable_heartbeat && cleanup_result.is_ok() {
let _ = self.delete_canary();
} else if cleanup_result.is_err() {
error!(
"Failed to cleanup jail '{}', leaving canary for orphan cleanup",
self.jail.jail_id()
);
}
}
}
9 changes: 8 additions & 1 deletion src/jail/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,7 +51,14 @@ pub trait Jail: Send + Sync {

/// Get the canary directory for tracking jail lifetimes
pub fn get_canary_dir() -> std::path::PathBuf {
std::path::PathBuf::from("/tmp/httpjail")
// Use user data directory instead of /tmp to avoid issues with tmp cleaners
// This ensures canaries persist across reboots and are only removed when we want them to be
if let Some(data_dir) = dirs::data_dir() {
data_dir.join("httpjail").join("canaries")
} else {
// Fallback to /tmp if we can't get user data dir (should rarely happen)
std::path::PathBuf::from("/tmp/httpjail")
}
}

/// Get the directory for httpjail temporary files (like resolv.conf)
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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
5 changes: 5 additions & 0 deletions .claude/commands/pr-compress.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
Deeply review existing change.

Try to find opportunities for DRY. i.e. where you can push the net balance of code additions as low as possible while simultaneously improving readability and behavior.

Do not venture into changes beyond the scope of the PR.
186 changes: 134 additions & 52 deletions src/jail/linux/docker.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,19 +12,51 @@ struct DockerNetwork {
network_name: String,
}

impl DockerNetwork {
const NETWORK_PREFIX: &'static str = "httpjail_";

/// Generate network name from jail ID
fn network_name_from_jail_id(jail_id: &str) -> String {
format!("{}{}", Self::NETWORK_PREFIX, jail_id)
}

/// Extract jail ID from network name
fn jail_id_from_network_name(network_name: &str) -> Option<&str> {
network_name.strip_prefix(Self::NETWORK_PREFIX)
}

/// Check if a Docker command failed due to resource not existing
fn is_not_found_error(stderr: &str) -> bool {
stderr.contains("not found")
|| stderr.contains("No such")
|| stderr.contains("does not exist")
}

/// Check if a Docker command failed due to resource already existing
fn is_already_exists_error(stderr: &str) -> bool {
stderr.contains("already exists")
}
}

/// Docker routing nftables resource that gets cleaned up on drop
struct DockerRoutingTable {
#[allow(dead_code)]
jail_id: String,
table_name: String,
}

impl DockerRoutingTable {
/// Generate table name from jail ID
fn table_name_from_jail_id(jail_id: &str) -> String {
format!("httpjail_docker_{}", jail_id)
}
}

impl SystemResource for DockerRoutingTable {
fn create(jail_id: &str) -> Result<Self> {
let table_name = format!("httpjail_docker_{}", jail_id);
Ok(Self {
jail_id: jail_id.to_string(),
table_name,
table_name: Self::table_name_from_jail_id(jail_id),
})
}

Expand All@@ -38,10 +70,10 @@ impl SystemResource for DockerRoutingTable {

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("No such file or directory") && !stderr.contains("does not exist") {
warn!("Failed to delete Docker routing table: {}", stderr);
} else {
if DockerNetwork::is_not_found_error(&stderr) {
debug!("Docker routing table {} already removed", self.table_name);
} else {
warn!("Failed to delete Docker routing table: {}", stderr);
}
} else {
info!("Removed Docker routing table {}", self.table_name);
Expand All@@ -51,25 +83,16 @@ impl SystemResource for DockerRoutingTable {
}

fn for_existing(jail_id: &str) -> Self {
let table_name = format!("httpjail_docker_{}", jail_id);
Self {
jail_id: jail_id.to_string(),
table_name,
table_name: Self::table_name_from_jail_id(jail_id),
}
}
}

impl DockerNetwork {
#[allow(dead_code)]
fn new(jail_id: &str) -> Result<Self> {
let network_name = format!("httpjail_{}", jail_id);
Ok(Self { network_name })
}
}

impl SystemResource for DockerNetwork {
fn create(jail_id: &str) -> Result<Self> {
let network_name = format!("httpjail_{}", jail_id);
let network_name = Self::network_name_from_jail_id(jail_id);

// Create Docker network with no default gateway (isolated)
// Using a /24 subnet in the 172.20.x.x range
Expand All@@ -92,7 +115,7 @@ impl SystemResource for DockerNetwork {

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("already exists") {
if Self::is_already_exists_error(&stderr) {
info!("Docker network {} already exists", network_name);
} else {
anyhow::bail!("Failed to create Docker network: {}", stderr);
Expand All@@ -117,7 +140,7 @@ impl SystemResource for DockerNetwork {

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("not found") {
if Self::is_not_found_error(&stderr) {
debug!("Docker network {} already removed", self.network_name);
} else {
warn!("Failed to remove Docker network: {}", stderr);
Expand All@@ -130,8 +153,9 @@ impl SystemResource for DockerNetwork {
}

fn for_existing(jail_id: &str) -> Self {
let network_name = format!("httpjail_{}", jail_id);
Self { network_name }
Self {
network_name: Self::network_name_from_jail_id(jail_id),
}
}
}

Expand DownExpand Up@@ -202,47 +226,76 @@ impl DockerLinux {
})
}

/// Clean up all orphaned Docker networks that don't have corresponding canary files
fn cleanup_all_orphaned_docker_networks() -> Result<()> {
debug!("Scanning for orphaned Docker networks");

// List all Docker networks
let output = Command::new("docker")
.args(["network", "ls", "--format", "{{.Name}}"])
.output()
.context("Failed to list Docker networks")?;

if !output.status.success() {
warn!("Failed to list Docker networks for cleanup");
return Ok(());
}

let networks = String::from_utf8_lossy(&output.stdout);
let canary_dir = crate::jail::get_canary_dir();

for network_name in networks.lines() {
// Extract jail_id from network name (skip non-httpjail networks)
let Some(jail_id) = DockerNetwork::jail_id_from_network_name(network_name) else {
continue;
};

// Check if canary file exists for this jail
let canary_path = canary_dir.join(jail_id);
if !canary_path.exists() {
info!(
"Found orphaned Docker network {} without canary, removing",
network_name
);

// Remove the orphaned network
let rm_output = Command::new("docker")
.args(["network", "rm", network_name])
.output()
.context("Failed to remove orphaned Docker network")?;

if !rm_output.status.success() {
let stderr = String::from_utf8_lossy(&rm_output.stderr);
if !DockerNetwork::is_not_found_error(&stderr) {
warn!(
"Failed to remove orphaned Docker network {}: {}",
network_name, stderr
);
}
}
}
}

Ok(())
}

/// Docker flags that take a value as the next argument
const FLAGS_WITH_VALUES: &'static [&'static str] =
&["-e", "-v", "-p", "--name", "--entrypoint", "-w", "--user"];

/// Build the docker command with isolated network
fn build_docker_command(
&self,
docker_args: &[String],
extra_env: &[(String, String)],
) -> Result<Command> {
let network_name = format!("httpjail_{}", self.config.jail_id);
let network_name = DockerNetwork::network_name_from_jail_id(&self.config.jail_id);
// Parse docker arguments to filter out conflicting options and find the image
let modified_args = Self::filter_network_args(docker_args);

// Find where the image name is in the args
let mut image_idx = None;
let mut skip_next = false;

for (i, arg) in modified_args.iter().enumerate() {
if skip_next {
skip_next = false;
continue;
}

// Skip known flags that take values
if arg == "-e"
|| arg == "-v"
|| arg == "-p"
|| arg == "--name"
|| arg == "--entrypoint"
|| arg == "-w"
|| arg == "--user"
{
skip_next = true;
continue;
}

// If it doesn't start with -, it's likely the image
if !arg.starts_with('-') {
image_idx = Some(i);
break;
}
}

let image_idx = image_idx.context("Could not find Docker image in arguments")?;
let image_idx = Self::find_image_index(&modified_args)
.context("Could not find Docker image in arguments")?;

// Split args into: docker options, image, and command
let docker_opts = &modified_args[..image_idx];
Expand DownExpand Up@@ -302,6 +355,31 @@ impl DockerLinux {
Ok(cmd)
}

/// Find the index of the Docker image in the arguments
fn find_image_index(args: &[String]) -> Option<usize> {
let mut skip_next = false;

for (i, arg) in args.iter().enumerate() {
if skip_next {
skip_next = false;
continue;
}

// Skip known flags that take values
if Self::FLAGS_WITH_VALUES.contains(&arg.as_str()) {
skip_next = true;
continue;
}

// If it doesn't start with -, it's likely the image
if !arg.starts_with('-') {
return Some(i);
}
}

None
}

/// Filter out any existing --network arguments from docker args
fn filter_network_args(docker_args: &[String]) -> Vec<String> {
let mut modified_args = Vec::new();
Expand DownExpand Up@@ -347,7 +425,7 @@ impl DockerLinux {
// Add nftables rules to:
// 1. Allow traffic from Docker network to jail's proxy ports
// 2. DNAT HTTP/HTTPS traffic to the proxy
let table_name = format!("httpjail_docker_{}", self.config.jail_id);
let table_name = DockerRoutingTable::table_name_from_jail_id(&self.config.jail_id);

// Create nftables rules
let nft_rules = format!(
Expand DownExpand Up@@ -412,6 +490,10 @@ impl DockerLinux {

impl Jail for DockerLinux {
fn setup(&mut self, proxy_port: u16) -> Result<()> {
// Clean up any orphaned Docker networks first
// This handles cases where Docker networks exist without corresponding canary files
Self::cleanup_all_orphaned_docker_networks()?;
Comment thread
ammario marked this conversation as resolved.

// First setup the inner Linux jail
self.inner_jail.setup(proxy_port)?;

Expand Down
31 changes: 27 additions & 4 deletions src/jail/managed.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -225,12 +225,23 @@ impl<J: Jail> Jail for ManagedJail<J> {
// Cleanup orphans first
if self.enable_heartbeat {
self.cleanup_orphans()?;

// Create canary BEFORE setting up jail to prevent race condition
// where another jail's cleanup might delete our network
self.create_canary()?;
}

// Setup the inner jail
self.jail.setup(proxy_port)?;
// Setup the inner jail (which may create Docker networks)
let setup_result = self.jail.setup(proxy_port);

// If setup failed, clean up the canary we created
if setup_result.is_err() && self.enable_heartbeat {
let _ = self.delete_canary();
return setup_result;
}

// Start heartbeat after successful setup
// Start heartbeat thread after successful setup
// Note: This will try to create canary again but create_canary is idempotent
self.start_heartbeat()?;

Ok(())
Expand DownExpand Up@@ -272,8 +283,20 @@ impl<J: Jail> Drop for ManagedJail<J> {
fn drop(&mut self) {
// Best effort cleanup
let _ = self.stop_heartbeat();
if self.enable_heartbeat {

// Explicitly cleanup jail resources BEFORE deleting canary
// This ensures resources are freed before we signal that the jail is gone
let cleanup_result = self.jail.cleanup();

// Only delete canary if cleanup succeeded
// If cleanup failed, leave the canary so orphan cleanup can retry later
if self.enable_heartbeat && cleanup_result.is_ok() {
let _ = self.delete_canary();
} else if cleanup_result.is_err() {
error!(
"Failed to cleanup jail '{}', leaving canary for orphan cleanup",
self.jail.jail_id()
);
}
}
}
9 changes: 8 additions & 1 deletion src/jail/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,7 +51,14 @@ pub trait Jail: Send + Sync {

/// Get the canary directory for tracking jail lifetimes
pub fn get_canary_dir() -> std::path::PathBuf {
std::path::PathBuf::from("/tmp/httpjail")
// Use user data directory instead of /tmp to avoid issues with tmp cleaners
// This ensures canaries persist across reboots and are only removed when we want them to be
if let Some(data_dir) = dirs::data_dir() {
data_dir.join("httpjail").join("canaries")
} else {
// Fallback to /tmp if we can't get user data dir (should rarely happen)
std::path::PathBuf::from("/tmp/httpjail")
}
}

/// Get the directory for httpjail temporary files (like resolv.conf)
Expand Down
Loading
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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
5 changes: 5 additions & 0 deletions .claude/commands/pr-compress.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
Deeply review existing change.

Try to find opportunities for DRY. i.e. where you can push the net balance of code additions as low as possible while simultaneously improving readability and behavior.

Do not venture into changes beyond the scope of the PR.
186 changes: 134 additions & 52 deletions src/jail/linux/docker.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -12,19 +12,51 @@ struct DockerNetwork {
network_name: String,
}

impl DockerNetwork {
const NETWORK_PREFIX: &'static str = "httpjail_";

/// Generate network name from jail ID
fn network_name_from_jail_id(jail_id: &str) -> String {
format!("{}{}", Self::NETWORK_PREFIX, jail_id)
}

/// Extract jail ID from network name
fn jail_id_from_network_name(network_name: &str) -> Option<&str> {
network_name.strip_prefix(Self::NETWORK_PREFIX)
}

/// Check if a Docker command failed due to resource not existing
fn is_not_found_error(stderr: &str) -> bool {
stderr.contains("not found")
|| stderr.contains("No such")
|| stderr.contains("does not exist")
}

/// Check if a Docker command failed due to resource already existing
fn is_already_exists_error(stderr: &str) -> bool {
stderr.contains("already exists")
}
}

/// Docker routing nftables resource that gets cleaned up on drop
struct DockerRoutingTable {
#[allow(dead_code)]
jail_id: String,
table_name: String,
}

impl DockerRoutingTable {
/// Generate table name from jail ID
fn table_name_from_jail_id(jail_id: &str) -> String {
format!("httpjail_docker_{}", jail_id)
}
}

impl SystemResource for DockerRoutingTable {
fn create(jail_id: &str) -> Result<Self> {
let table_name = format!("httpjail_docker_{}", jail_id);
Ok(Self {
jail_id: jail_id.to_string(),
table_name,
table_name: Self::table_name_from_jail_id(jail_id),
})
}

Expand All@@ -38,10 +70,10 @@ impl SystemResource for DockerRoutingTable {

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if !stderr.contains("No such file or directory") && !stderr.contains("does not exist") {
warn!("Failed to delete Docker routing table: {}", stderr);
} else {
if DockerNetwork::is_not_found_error(&stderr) {
debug!("Docker routing table {} already removed", self.table_name);
} else {
warn!("Failed to delete Docker routing table: {}", stderr);
}
} else {
info!("Removed Docker routing table {}", self.table_name);
Expand All@@ -51,25 +83,16 @@ impl SystemResource for DockerRoutingTable {
}

fn for_existing(jail_id: &str) -> Self {
let table_name = format!("httpjail_docker_{}", jail_id);
Self {
jail_id: jail_id.to_string(),
table_name,
table_name: Self::table_name_from_jail_id(jail_id),
}
}
}

impl DockerNetwork {
#[allow(dead_code)]
fn new(jail_id: &str) -> Result<Self> {
let network_name = format!("httpjail_{}", jail_id);
Ok(Self { network_name })
}
}

impl SystemResource for DockerNetwork {
fn create(jail_id: &str) -> Result<Self> {
let network_name = format!("httpjail_{}", jail_id);
let network_name = Self::network_name_from_jail_id(jail_id);

// Create Docker network with no default gateway (isolated)
// Using a /24 subnet in the 172.20.x.x range
Expand All@@ -92,7 +115,7 @@ impl SystemResource for DockerNetwork {

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("already exists") {
if Self::is_already_exists_error(&stderr) {
info!("Docker network {} already exists", network_name);
} else {
anyhow::bail!("Failed to create Docker network: {}", stderr);
Expand All@@ -117,7 +140,7 @@ impl SystemResource for DockerNetwork {

if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
if stderr.contains("not found") {
if Self::is_not_found_error(&stderr) {
debug!("Docker network {} already removed", self.network_name);
} else {
warn!("Failed to remove Docker network: {}", stderr);
Expand All@@ -130,8 +153,9 @@ impl SystemResource for DockerNetwork {
}

fn for_existing(jail_id: &str) -> Self {
let network_name = format!("httpjail_{}", jail_id);
Self { network_name }
Self {
network_name: Self::network_name_from_jail_id(jail_id),
}
}
}

Expand DownExpand Up@@ -202,47 +226,76 @@ impl DockerLinux {
})
}

/// Clean up all orphaned Docker networks that don't have corresponding canary files
fn cleanup_all_orphaned_docker_networks() -> Result<()> {
debug!("Scanning for orphaned Docker networks");

// List all Docker networks
let output = Command::new("docker")
.args(["network", "ls", "--format", "{{.Name}}"])
.output()
.context("Failed to list Docker networks")?;

if !output.status.success() {
warn!("Failed to list Docker networks for cleanup");
return Ok(());
}

let networks = String::from_utf8_lossy(&output.stdout);
let canary_dir = crate::jail::get_canary_dir();

for network_name in networks.lines() {
// Extract jail_id from network name (skip non-httpjail networks)
let Some(jail_id) = DockerNetwork::jail_id_from_network_name(network_name) else {
continue;
};

// Check if canary file exists for this jail
let canary_path = canary_dir.join(jail_id);
if !canary_path.exists() {
info!(
"Found orphaned Docker network {} without canary, removing",
network_name
);

// Remove the orphaned network
let rm_output = Command::new("docker")
.args(["network", "rm", network_name])
.output()
.context("Failed to remove orphaned Docker network")?;

if !rm_output.status.success() {
let stderr = String::from_utf8_lossy(&rm_output.stderr);
if !DockerNetwork::is_not_found_error(&stderr) {
warn!(
"Failed to remove orphaned Docker network {}: {}",
network_name, stderr
);
}
}
}
}

Ok(())
}

/// Docker flags that take a value as the next argument
const FLAGS_WITH_VALUES: &'static [&'static str] =
&["-e", "-v", "-p", "--name", "--entrypoint", "-w", "--user"];

/// Build the docker command with isolated network
fn build_docker_command(
&self,
docker_args: &[String],
extra_env: &[(String, String)],
) -> Result<Command> {
let network_name = format!("httpjail_{}", self.config.jail_id);
let network_name = DockerNetwork::network_name_from_jail_id(&self.config.jail_id);
// Parse docker arguments to filter out conflicting options and find the image
let modified_args = Self::filter_network_args(docker_args);

// Find where the image name is in the args
let mut image_idx = None;
let mut skip_next = false;

for (i, arg) in modified_args.iter().enumerate() {
if skip_next {
skip_next = false;
continue;
}

// Skip known flags that take values
if arg == "-e"
|| arg == "-v"
|| arg == "-p"
|| arg == "--name"
|| arg == "--entrypoint"
|| arg == "-w"
|| arg == "--user"
{
skip_next = true;
continue;
}

// If it doesn't start with -, it's likely the image
if !arg.starts_with('-') {
image_idx = Some(i);
break;
}
}

let image_idx = image_idx.context("Could not find Docker image in arguments")?;
let image_idx = Self::find_image_index(&modified_args)
.context("Could not find Docker image in arguments")?;

// Split args into: docker options, image, and command
let docker_opts = &modified_args[..image_idx];
Expand DownExpand Up@@ -302,6 +355,31 @@ impl DockerLinux {
Ok(cmd)
}

/// Find the index of the Docker image in the arguments
fn find_image_index(args: &[String]) -> Option<usize> {
let mut skip_next = false;

for (i, arg) in args.iter().enumerate() {
if skip_next {
skip_next = false;
continue;
}

// Skip known flags that take values
if Self::FLAGS_WITH_VALUES.contains(&arg.as_str()) {
skip_next = true;
continue;
}

// If it doesn't start with -, it's likely the image
if !arg.starts_with('-') {
return Some(i);
}
}

None
}

/// Filter out any existing --network arguments from docker args
fn filter_network_args(docker_args: &[String]) -> Vec<String> {
let mut modified_args = Vec::new();
Expand DownExpand Up@@ -347,7 +425,7 @@ impl DockerLinux {
// Add nftables rules to:
// 1. Allow traffic from Docker network to jail's proxy ports
// 2. DNAT HTTP/HTTPS traffic to the proxy
let table_name = format!("httpjail_docker_{}", self.config.jail_id);
let table_name = DockerRoutingTable::table_name_from_jail_id(&self.config.jail_id);

// Create nftables rules
let nft_rules = format!(
Expand DownExpand Up@@ -412,6 +490,10 @@ impl DockerLinux {

impl Jail for DockerLinux {
fn setup(&mut self, proxy_port: u16) -> Result<()> {
// Clean up any orphaned Docker networks first
// This handles cases where Docker networks exist without corresponding canary files
Self::cleanup_all_orphaned_docker_networks()?;
Comment thread
ammario marked this conversation as resolved.

// First setup the inner Linux jail
self.inner_jail.setup(proxy_port)?;

Expand Down
31 changes: 27 additions & 4 deletions src/jail/managed.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -225,12 +225,23 @@ impl<J: Jail> Jail for ManagedJail<J> {
// Cleanup orphans first
if self.enable_heartbeat {
self.cleanup_orphans()?;

// Create canary BEFORE setting up jail to prevent race condition
// where another jail's cleanup might delete our network
self.create_canary()?;
}

// Setup the inner jail
self.jail.setup(proxy_port)?;
// Setup the inner jail (which may create Docker networks)
let setup_result = self.jail.setup(proxy_port);

// If setup failed, clean up the canary we created
if setup_result.is_err() && self.enable_heartbeat {
let _ = self.delete_canary();
return setup_result;
}

// Start heartbeat after successful setup
// Start heartbeat thread after successful setup
// Note: This will try to create canary again but create_canary is idempotent
self.start_heartbeat()?;

Ok(())
Expand DownExpand Up@@ -272,8 +283,20 @@ impl<J: Jail> Drop for ManagedJail<J> {
fn drop(&mut self) {
// Best effort cleanup
let _ = self.stop_heartbeat();
if self.enable_heartbeat {

// Explicitly cleanup jail resources BEFORE deleting canary
// This ensures resources are freed before we signal that the jail is gone
let cleanup_result = self.jail.cleanup();

// Only delete canary if cleanup succeeded
// If cleanup failed, leave the canary so orphan cleanup can retry later
if self.enable_heartbeat && cleanup_result.is_ok() {
let _ = self.delete_canary();
} else if cleanup_result.is_err() {
error!(
"Failed to cleanup jail '{}', leaving canary for orphan cleanup",
self.jail.jail_id()
);
}
}
}
9 changes: 8 additions & 1 deletion src/jail/mod.rs
Original file line numberDiff line numberDiff line change
Expand Up@@ -51,7 +51,14 @@ pub trait Jail: Send + Sync {

/// Get the canary directory for tracking jail lifetimes
pub fn get_canary_dir() -> std::path::PathBuf {
std::path::PathBuf::from("/tmp/httpjail")
// Use user data directory instead of /tmp to avoid issues with tmp cleaners
// This ensures canaries persist across reboots and are only removed when we want them to be
if let Some(data_dir) = dirs::data_dir() {
data_dir.join("httpjail").join("canaries")
} else {
// Fallback to /tmp if we can't get user data dir (should rarely happen)
std::path::PathBuf::from("/tmp/httpjail")
}
}

/// Get the directory for httpjail temporary files (like resolv.conf)
Expand Down
Loading