forked from wrenn/wrenn
v0.2.1 (#55)
Co-authored-by: Tasnim Kabir Sadik <tksadik@omukk.dev> Reviewed-on: wrenn/wrenn#55 Co-authored-by: pptx704 <rafeed@omukk.dev> Co-committed-by: pptx704 <rafeed@omukk.dev>
This commit is contained in:
2
envd-rs/Cargo.lock
generated
2
envd-rs/Cargo.lock
generated
@ -529,7 +529,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "envd"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
dependencies = [
|
||||
"async-stream",
|
||||
"axum",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "envd"
|
||||
version = "0.3.0"
|
||||
version = "0.4.0"
|
||||
edition = "2024"
|
||||
rust-version = "1.95"
|
||||
|
||||
|
||||
@ -14,6 +14,7 @@ const ACCESS_TOKEN_HEADER: &str = "x-access-token";
|
||||
/// Format: "METHOD/path"
|
||||
const AUTH_EXCLUDED: &[&str] = &[
|
||||
"GET/health",
|
||||
"GET/activity",
|
||||
"GET/files",
|
||||
"POST/files",
|
||||
"POST/init",
|
||||
@ -21,11 +22,7 @@ const AUTH_EXCLUDED: &[&str] = &[
|
||||
];
|
||||
|
||||
/// Axum middleware that checks X-Access-Token header.
|
||||
pub async fn auth_layer(
|
||||
request: Request,
|
||||
next: Next,
|
||||
access_token: Arc<SecureToken>,
|
||||
) -> Response {
|
||||
pub async fn auth_layer(request: Request, next: Next, access_token: Arc<SecureToken>) -> Response {
|
||||
if access_token.is_set() {
|
||||
let method = request.method().as_str();
|
||||
let path = request.uri().path();
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
pub mod token;
|
||||
pub mod signing;
|
||||
pub mod middleware;
|
||||
pub mod signing;
|
||||
pub mod token;
|
||||
|
||||
@ -140,13 +140,32 @@ mod tests {
|
||||
#[test]
|
||||
fn validate_correct_header_token() {
|
||||
let token = test_token(b"secret");
|
||||
assert!(validate_signing(&token, Some("secret"), None, None, "root", "/f", READ_OPERATION).is_ok());
|
||||
assert!(
|
||||
validate_signing(
|
||||
&token,
|
||||
Some("secret"),
|
||||
None,
|
||||
None,
|
||||
"root",
|
||||
"/f",
|
||||
READ_OPERATION
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_wrong_header_token() {
|
||||
let token = test_token(b"secret");
|
||||
let result = validate_signing(&token, Some("wrong"), None, None, "root", "/f", READ_OPERATION);
|
||||
let result = validate_signing(
|
||||
&token,
|
||||
Some("wrong"),
|
||||
None,
|
||||
None,
|
||||
"root",
|
||||
"/f",
|
||||
READ_OPERATION,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("does not match"));
|
||||
}
|
||||
@ -156,13 +175,32 @@ mod tests {
|
||||
let token = test_token(b"secret");
|
||||
let exp = far_future();
|
||||
let sig = generate_signature(&token, "/file", "root", READ_OPERATION, Some(exp)).unwrap();
|
||||
assert!(validate_signing(&token, None, Some(&sig), Some(exp), "root", "/file", READ_OPERATION).is_ok());
|
||||
assert!(
|
||||
validate_signing(
|
||||
&token,
|
||||
None,
|
||||
Some(&sig),
|
||||
Some(exp),
|
||||
"root",
|
||||
"/file",
|
||||
READ_OPERATION
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn validate_invalid_signature() {
|
||||
let token = test_token(b"secret");
|
||||
let result = validate_signing(&token, None, Some("v1_bad"), Some(far_future()), "root", "/f", READ_OPERATION);
|
||||
let result = validate_signing(
|
||||
&token,
|
||||
None,
|
||||
Some("v1_bad"),
|
||||
Some(far_future()),
|
||||
"root",
|
||||
"/f",
|
||||
READ_OPERATION,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("invalid signature"));
|
||||
}
|
||||
@ -172,7 +210,15 @@ mod tests {
|
||||
let token = test_token(b"secret");
|
||||
let expired: i64 = 1_000_000;
|
||||
let sig = generate_signature(&token, "/f", "root", READ_OPERATION, Some(expired)).unwrap();
|
||||
let result = validate_signing(&token, None, Some(&sig), Some(expired), "root", "/f", READ_OPERATION);
|
||||
let result = validate_signing(
|
||||
&token,
|
||||
None,
|
||||
Some(&sig),
|
||||
Some(expired),
|
||||
"root",
|
||||
"/f",
|
||||
READ_OPERATION,
|
||||
);
|
||||
assert!(result.is_err());
|
||||
assert!(result.unwrap_err().contains("expired"));
|
||||
}
|
||||
@ -197,7 +243,18 @@ mod tests {
|
||||
fn validate_valid_signature_no_expiration() {
|
||||
let token = test_token(b"secret");
|
||||
let sig = generate_signature(&token, "/file", "root", READ_OPERATION, None).unwrap();
|
||||
assert!(validate_signing(&token, None, Some(&sig), None, "root", "/file", READ_OPERATION).is_ok());
|
||||
assert!(
|
||||
validate_signing(
|
||||
&token,
|
||||
None,
|
||||
Some(&sig),
|
||||
None,
|
||||
"root",
|
||||
"/file",
|
||||
READ_OPERATION
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -19,20 +19,25 @@ pub struct Cgroup2Manager {
|
||||
}
|
||||
|
||||
impl Cgroup2Manager {
|
||||
pub fn new(root: &str, configs: &[(ProcessType, &str, &[(&str, &str)])]) -> Result<Self, String> {
|
||||
pub fn new(
|
||||
root: &str,
|
||||
configs: &[(ProcessType, &str, &[(&str, &str)])],
|
||||
) -> Result<Self, String> {
|
||||
let mut fds = HashMap::new();
|
||||
|
||||
for (proc_type, sub_path, properties) in configs {
|
||||
let full_path = PathBuf::from(root).join(sub_path);
|
||||
|
||||
fs::create_dir_all(&full_path).map_err(|e| {
|
||||
format!("failed to create cgroup {}: {e}", full_path.display())
|
||||
})?;
|
||||
fs::create_dir_all(&full_path)
|
||||
.map_err(|e| format!("failed to create cgroup {}: {e}", full_path.display()))?;
|
||||
|
||||
for (name, value) in *properties {
|
||||
let prop_path = full_path.join(name);
|
||||
fs::write(&prop_path, value).map_err(|e| {
|
||||
format!("failed to write cgroup property {}: {e}", prop_path.display())
|
||||
format!(
|
||||
"failed to write cgroup property {}: {e}",
|
||||
prop_path.display()
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
|
||||
5
envd-rs/src/cmd/mod.rs
Normal file
5
envd-rs/src/cmd/mod.rs
Normal file
@ -0,0 +1,5 @@
|
||||
//! Client subcommands for the `envd` binary. These run as short-lived
|
||||
//! invocations (e.g. `envd ports`) inside the guest, separate from the
|
||||
//! long-running daemon, and exit when done.
|
||||
|
||||
pub mod ports;
|
||||
164
envd-rs/src/cmd/ports.rs
Normal file
164
envd-rs/src/cmd/ports.rs
Normal file
@ -0,0 +1,164 @@
|
||||
//! `envd ports` — list the open ports inside the sandbox that are reachable
|
||||
//! from outside, alongside the URL each is served at.
|
||||
//!
|
||||
//! Runs as a one-shot client (not the daemon): it scans `/proc/net/tcp[6]`
|
||||
//! directly via the shared port helper and reads the sandbox identity that the
|
||||
//! daemon recorded under /run/wrenn at /init time. It refuses to run outside a
|
||||
//! wrenn sandbox.
|
||||
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::config::{DEFAULT_PORT, DEFAULT_PROXY_DOMAIN, WRENN_RUN_DIR};
|
||||
use crate::port::conn::reachable_listening_ports;
|
||||
|
||||
/// Arguments for the `envd ports` subcommand.
|
||||
#[derive(clap::Args)]
|
||||
pub struct PortsArgs {
|
||||
/// Override the proxy domain used to build URLs (default: the domain
|
||||
/// injected by the host, falling back to the built-in default).
|
||||
#[arg(long)]
|
||||
domain: Option<String>,
|
||||
|
||||
/// Emit JSON instead of a table.
|
||||
#[arg(long)]
|
||||
json: bool,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize)]
|
||||
struct PortEntry {
|
||||
port: u32,
|
||||
url: String,
|
||||
}
|
||||
|
||||
/// Runs the subcommand and returns the desired process exit code.
|
||||
pub fn run(args: &PortsArgs) -> i32 {
|
||||
if !inside_sandbox() {
|
||||
eprintln!("envd ports: not running inside a wrenn sandbox");
|
||||
return 1;
|
||||
}
|
||||
|
||||
let sandbox_id = read_identity("WRENN_SANDBOX_ID", ".WRENN_SANDBOX_ID");
|
||||
let domain = args
|
||||
.domain
|
||||
.clone()
|
||||
.filter(|d| !d.is_empty())
|
||||
.or_else(|| read_identity("WRENN_PROXY_DOMAIN", ".WRENN_PROXY_DOMAIN"))
|
||||
.unwrap_or_else(|| DEFAULT_PROXY_DOMAIN.to_string());
|
||||
|
||||
let entries: Vec<PortEntry> = reachable_listening_ports(DEFAULT_PORT as u32)
|
||||
.into_iter()
|
||||
.map(|port| PortEntry {
|
||||
url: build_url(port, sandbox_id.as_deref(), &domain),
|
||||
port,
|
||||
})
|
||||
.collect();
|
||||
|
||||
if args.json {
|
||||
match serde_json::to_string_pretty(&entries) {
|
||||
Ok(s) => println!("{s}"),
|
||||
Err(e) => {
|
||||
eprintln!("envd ports: failed to encode JSON: {e}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
if entries.is_empty() {
|
||||
println!("No open ports.");
|
||||
return 0;
|
||||
}
|
||||
|
||||
println!("{:<6} {}", "PORT", "URL");
|
||||
for e in &entries {
|
||||
println!("{:<6} {}", e.port, e.url);
|
||||
}
|
||||
0
|
||||
}
|
||||
|
||||
/// A wrenn sandbox is identified by the marker the daemon writes at startup
|
||||
/// (`/run/wrenn/.WRENN_SANDBOX`) and the `WRENN_SANDBOX` env var it exports
|
||||
/// into spawned processes. Running `envd ports` on a normal host finds neither
|
||||
/// and is refused.
|
||||
fn inside_sandbox() -> bool {
|
||||
if std::env::var("WRENN_SANDBOX").as_deref() == Ok("true") {
|
||||
return true;
|
||||
}
|
||||
Path::new(WRENN_RUN_DIR).join(".WRENN_SANDBOX").exists()
|
||||
}
|
||||
|
||||
/// Reads an identity value from the environment, falling back to the matching
|
||||
/// /run/wrenn file. Returns None when neither is set or both are blank.
|
||||
fn read_identity(env_key: &str, file_name: &str) -> Option<String> {
|
||||
if let Ok(v) = std::env::var(env_key) {
|
||||
let v = v.trim().to_string();
|
||||
if !v.is_empty() {
|
||||
return Some(v);
|
||||
}
|
||||
}
|
||||
match fs::read_to_string(Path::new(WRENN_RUN_DIR).join(file_name)) {
|
||||
Ok(v) => {
|
||||
let v = v.trim().to_string();
|
||||
if v.is_empty() { None } else { Some(v) }
|
||||
}
|
||||
Err(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds the externally-reachable URL for a port. With a known sandbox ID the
|
||||
/// result is a working https URL; without it (identity not yet injected) the
|
||||
/// sandbox-ID segment degrades to a `<sandbox-id>` placeholder so output is
|
||||
/// still informative.
|
||||
fn build_url(port: u32, sandbox_id: Option<&str>, domain: &str) -> String {
|
||||
let id = sandbox_id.unwrap_or("<sandbox-id>");
|
||||
format!("https://{port}-{id}.{domain}")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn url_with_sandbox_id() {
|
||||
assert_eq!(
|
||||
build_url(8000, Some("cl-abcd1234"), "wrenn.dev"),
|
||||
"https://8000-cl-abcd1234.wrenn.dev"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_without_sandbox_id_uses_placeholder() {
|
||||
assert_eq!(
|
||||
build_url(5173, None, "wrenn.dev"),
|
||||
"https://5173-<sandbox-id>.wrenn.dev"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_honors_custom_domain() {
|
||||
assert_eq!(
|
||||
build_url(3000, Some("cl-deadbeef"), "sandbox.example.com"),
|
||||
"https://3000-cl-deadbeef.sandbox.example.com"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_identity_prefers_env() {
|
||||
// SAFETY: test-local env var, single-threaded test body.
|
||||
unsafe { std::env::set_var("ENVD_PORTS_TEST_ID", " cl-fromenv ") };
|
||||
assert_eq!(
|
||||
read_identity("ENVD_PORTS_TEST_ID", ".nonexistent-file"),
|
||||
Some("cl-fromenv".to_string())
|
||||
);
|
||||
unsafe { std::env::remove_var("ENVD_PORTS_TEST_ID") };
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_identity_none_when_unset() {
|
||||
assert_eq!(
|
||||
read_identity("ENVD_PORTS_TEST_UNSET", ".nonexistent-file"),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -7,5 +7,10 @@ pub const PORT_SCANNER_INTERVAL: Duration = Duration::from_millis(1000);
|
||||
pub const DEFAULT_USER: &str = "root";
|
||||
pub const WRENN_RUN_DIR: &str = "/run/wrenn";
|
||||
|
||||
/// Fallback proxy domain used by `envd ports` to build URLs when the host has
|
||||
/// not injected one via /init. Matches the host agent's WRENN_PROXY_DOMAIN
|
||||
/// default.
|
||||
pub const DEFAULT_PROXY_DOMAIN: &str = "wrenn.dev";
|
||||
|
||||
pub const KILOBYTE: u64 = 1024;
|
||||
pub const MEGABYTE: u64 = 1024 * KILOBYTE;
|
||||
|
||||
@ -1,3 +1,3 @@
|
||||
pub mod hmac_sha256;
|
||||
pub mod sha256;
|
||||
pub mod sha512;
|
||||
pub mod hmac_sha256;
|
||||
|
||||
@ -20,14 +20,22 @@ mod tests {
|
||||
const VECTORS: &[(&[u8], &str)] = &[
|
||||
(b"", "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU"),
|
||||
(b"abc", "ungWv48Bz+pBQUDeXa4iI7ADYaOWF3qctBD/YfIAFa0"),
|
||||
(b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", "JI1qYdIGOLjlwCaTDD5gOaM85Flk/yFn9uzt1BnbBsE"),
|
||||
(
|
||||
b"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq",
|
||||
"JI1qYdIGOLjlwCaTDD5gOaM85Flk/yFn9uzt1BnbBsE",
|
||||
),
|
||||
];
|
||||
|
||||
#[test]
|
||||
fn known_answer_with_prefix() {
|
||||
for (input, expected_b64) in VECTORS {
|
||||
let result = hash(input);
|
||||
assert_eq!(result, format!("$sha256${expected_b64}"), "input: {:?}", String::from_utf8_lossy(input));
|
||||
assert_eq!(
|
||||
result,
|
||||
format!("$sha256${expected_b64}"),
|
||||
"input: {:?}",
|
||||
String::from_utf8_lossy(input)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,7 +43,12 @@ mod tests {
|
||||
fn known_answer_without_prefix() {
|
||||
for (input, expected_b64) in VECTORS {
|
||||
let result = hash_without_prefix(input);
|
||||
assert_eq!(result, *expected_b64, "input: {:?}", String::from_utf8_lossy(input));
|
||||
assert_eq!(
|
||||
result,
|
||||
*expected_b64,
|
||||
"input: {:?}",
|
||||
String::from_utf8_lossy(input)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -15,9 +15,18 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
const VECTORS: &[(&str, &str)] = &[
|
||||
("", "cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e"),
|
||||
("abc", "ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f"),
|
||||
("abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq", "204a8fc6dda82f0a0ced7beb8e08a41657c16ef468b228a8279be331a703c33596fd15c13b1b07f9aa1d3bea57789ca031ad85c7a71dd70354ec631238ca3445"),
|
||||
(
|
||||
"",
|
||||
"cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e",
|
||||
),
|
||||
(
|
||||
"abc",
|
||||
"ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f",
|
||||
),
|
||||
(
|
||||
"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq",
|
||||
"204a8fc6dda82f0a0ced7beb8e08a41657c16ef468b228a8279be331a703c33596fd15c13b1b07f9aa1d3bea57789ca031ad85c7a71dd70354ec631238ca3445",
|
||||
),
|
||||
];
|
||||
|
||||
#[test]
|
||||
@ -30,7 +39,10 @@ mod tests {
|
||||
#[test]
|
||||
fn str_and_bytes_agree() {
|
||||
for (input, _) in VECTORS {
|
||||
assert_eq!(hash_access_token(input), hash_access_token_bytes(input.as_bytes()));
|
||||
assert_eq!(
|
||||
hash_access_token(input),
|
||||
hash_access_token_bytes(input.as_bytes())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -38,6 +50,9 @@ mod tests {
|
||||
fn output_is_lowercase_hex_128_chars() {
|
||||
let h = hash_access_token("anything");
|
||||
assert_eq!(h.len(), 128);
|
||||
assert!(h.chars().all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()));
|
||||
assert!(
|
||||
h.chars()
|
||||
.all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -62,7 +62,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn workdir_explicit_overrides_default() {
|
||||
assert_eq!(resolve_default_workdir("/explicit", Some("/default")), "/explicit");
|
||||
assert_eq!(
|
||||
resolve_default_workdir("/explicit", Some("/default")),
|
||||
"/explicit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -82,7 +85,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn username_explicit_returns_explicit() {
|
||||
assert_eq!(resolve_default_username(Some("root"), "wrenn").unwrap(), "root");
|
||||
assert_eq!(
|
||||
resolve_default_username(Some("root"), "wrenn").unwrap(),
|
||||
"root"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
37
envd-rs/src/http/activity.rs
Normal file
37
envd-rs/src/http/activity.rs
Normal file
@ -0,0 +1,37 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::Json;
|
||||
use axum::extract::State;
|
||||
use axum::http::header;
|
||||
use axum::response::IntoResponse;
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::state::AppState;
|
||||
|
||||
/// Liveness snapshot the host activity sampler polls to decide whether a
|
||||
/// sandbox is doing real work. All fields are served straight from atomics
|
||||
/// updated by the 1s sampler thread — no syscalls per request, so the host
|
||||
/// can poll cheaply at a few-second cadence.
|
||||
#[derive(Serialize)]
|
||||
pub struct Activity {
|
||||
cpu_count: u32,
|
||||
cpu_used_pct: f32,
|
||||
net_bps: u64,
|
||||
disk_bps: u64,
|
||||
}
|
||||
|
||||
pub async fn get_activity(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
tracing::trace!("get activity");
|
||||
|
||||
let body = Activity {
|
||||
cpu_count: state.cpu_count(),
|
||||
cpu_used_pct: state.cpu_used_pct(),
|
||||
net_bps: state.net_bps(),
|
||||
disk_bps: state.disk_bps(),
|
||||
};
|
||||
|
||||
(
|
||||
[(header::CACHE_CONTROL, "no-store")],
|
||||
Json(body),
|
||||
)
|
||||
}
|
||||
@ -20,7 +20,10 @@ fn parse_encoding_with_quality(value: &str) -> EncodingWithQuality {
|
||||
let enc = value[..idx].trim();
|
||||
for param in params.split(';') {
|
||||
let param = param.trim();
|
||||
if let Some(stripped) = param.strip_prefix("q=").or_else(|| param.strip_prefix("Q=")) {
|
||||
if let Some(stripped) = param
|
||||
.strip_prefix("q=")
|
||||
.or_else(|| param.strip_prefix("Q="))
|
||||
{
|
||||
if let Ok(q) = stripped.parse::<f64>() {
|
||||
quality = q;
|
||||
}
|
||||
@ -43,8 +46,10 @@ fn parse_accept_encoding_header(header: &str) -> (Vec<EncodingWithQuality>, bool
|
||||
return (Vec::new(), false);
|
||||
}
|
||||
|
||||
let encodings: Vec<EncodingWithQuality> =
|
||||
header.split(',').map(|v| parse_encoding_with_quality(v)).collect();
|
||||
let encodings: Vec<EncodingWithQuality> = header
|
||||
.split(',')
|
||||
.map(|v| parse_encoding_with_quality(v))
|
||||
.collect();
|
||||
|
||||
let mut identity_rejected = false;
|
||||
let mut identity_explicitly_accepted = false;
|
||||
@ -97,7 +102,11 @@ pub fn parse_accept_encoding<B>(r: &Request<B>) -> Result<&'static str, String>
|
||||
}
|
||||
|
||||
let (mut encodings, identity_rejected) = parse_accept_encoding_header(header);
|
||||
encodings.sort_by(|a, b| b.quality.partial_cmp(&a.quality).unwrap_or(std::cmp::Ordering::Equal));
|
||||
encodings.sort_by(|a, b| {
|
||||
b.quality
|
||||
.partial_cmp(&a.quality)
|
||||
.unwrap_or(std::cmp::Ordering::Equal)
|
||||
});
|
||||
|
||||
for eq in &encodings {
|
||||
if eq.quality == 0.0 {
|
||||
@ -121,7 +130,9 @@ pub fn parse_accept_encoding<B>(r: &Request<B>) -> Result<&'static str, String>
|
||||
return Ok(ENCODING_IDENTITY);
|
||||
}
|
||||
|
||||
Err(format!("no acceptable encoding found, supported: {SUPPORTED_ENCODINGS:?}"))
|
||||
Err(format!(
|
||||
"no acceptable encoding found, supported: {SUPPORTED_ENCODINGS:?}"
|
||||
))
|
||||
}
|
||||
|
||||
pub fn parse_content_encoding<B>(r: &Request<B>) -> Result<&'static str, String> {
|
||||
@ -143,7 +154,9 @@ pub fn parse_content_encoding<B>(r: &Request<B>) -> Result<&'static str, String>
|
||||
return Ok(ENCODING_GZIP);
|
||||
}
|
||||
|
||||
Err(format!("unsupported Content-Encoding: {header}, supported: {SUPPORTED_ENCODINGS:?}"))
|
||||
Err(format!(
|
||||
"unsupported Content-Encoding: {header}, supported: {SUPPORTED_ENCODINGS:?}"
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@ -236,17 +249,26 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn accept_encoding_no_header_returns_identity() {
|
||||
assert_eq!(parse_accept_encoding(&req_no_headers()).unwrap(), "identity");
|
||||
assert_eq!(
|
||||
parse_accept_encoding(&req_no_headers()).unwrap(),
|
||||
"identity"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_encoding_gzip() {
|
||||
assert_eq!(parse_accept_encoding(&req_with_accept("gzip")).unwrap(), "gzip");
|
||||
assert_eq!(
|
||||
parse_accept_encoding(&req_with_accept("gzip")).unwrap(),
|
||||
"gzip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn accept_encoding_identity_explicit() {
|
||||
assert_eq!(parse_accept_encoding(&req_with_accept("identity")).unwrap(), "identity");
|
||||
assert_eq!(
|
||||
parse_accept_encoding(&req_with_accept("identity")).unwrap(),
|
||||
"identity"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -259,7 +281,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn accept_encoding_wildcard_returns_identity() {
|
||||
assert_eq!(parse_accept_encoding(&req_with_accept("*")).unwrap(), "identity");
|
||||
assert_eq!(
|
||||
parse_accept_encoding(&req_with_accept("*")).unwrap(),
|
||||
"identity"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -277,7 +302,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn accept_encoding_unsupported_only_falls_to_identity() {
|
||||
assert_eq!(parse_accept_encoding(&req_with_accept("br")).unwrap(), "identity");
|
||||
assert_eq!(
|
||||
parse_accept_encoding(&req_with_accept("br")).unwrap(),
|
||||
"identity"
|
||||
);
|
||||
}
|
||||
|
||||
// is_identity_acceptable
|
||||
@ -311,17 +339,26 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn content_encoding_empty_returns_identity() {
|
||||
assert_eq!(parse_content_encoding(&req_no_headers()).unwrap(), "identity");
|
||||
assert_eq!(
|
||||
parse_content_encoding(&req_no_headers()).unwrap(),
|
||||
"identity"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_encoding_gzip() {
|
||||
assert_eq!(parse_content_encoding(&req_with_content("gzip")).unwrap(), "gzip");
|
||||
assert_eq!(
|
||||
parse_content_encoding(&req_with_content("gzip")).unwrap(),
|
||||
"gzip"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn content_encoding_identity_explicit() {
|
||||
assert_eq!(parse_content_encoding(&req_with_content("identity")).unwrap(), "identity");
|
||||
assert_eq!(
|
||||
parse_content_encoding(&req_with_content("identity")).unwrap(),
|
||||
"identity"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -331,6 +368,9 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn content_encoding_case_insensitive() {
|
||||
assert_eq!(parse_content_encoding(&req_with_content("GZIP")).unwrap(), "gzip");
|
||||
assert_eq!(
|
||||
parse_content_encoding(&req_with_content("GZIP")).unwrap(),
|
||||
"gzip"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -18,8 +18,5 @@ pub async fn get_envs(State(state): State<Arc<AppState>>) -> impl IntoResponse {
|
||||
.map(|entry| (entry.key().clone(), entry.value().clone()))
|
||||
.collect();
|
||||
|
||||
(
|
||||
[(header::CACHE_CONTROL, "no-store")],
|
||||
Json(envs),
|
||||
)
|
||||
([(header::CACHE_CONTROL, "no-store")], Json(envs))
|
||||
}
|
||||
|
||||
@ -72,13 +72,11 @@ pub async fn get_files(
|
||||
let header_token = extract_header_token(&req);
|
||||
|
||||
let default_user = state.defaults.user();
|
||||
let username = match execcontext::resolve_default_username(
|
||||
params.username.as_deref(),
|
||||
&default_user,
|
||||
) {
|
||||
Ok(u) => u.to_string(),
|
||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, e),
|
||||
};
|
||||
let username =
|
||||
match execcontext::resolve_default_username(params.username.as_deref(), &default_user) {
|
||||
Ok(u) => u.to_string(),
|
||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, e),
|
||||
};
|
||||
|
||||
if let Err(e) = validate_file_signing(
|
||||
&state,
|
||||
@ -98,8 +96,7 @@ pub async fn get_files(
|
||||
|
||||
let home_dir = user.dir.to_string_lossy().to_string();
|
||||
let default_workdir = state.defaults.workdir();
|
||||
let resolved = match expand_and_resolve(path_str, &home_dir, default_workdir.as_deref())
|
||||
{
|
||||
let resolved = match expand_and_resolve(path_str, &home_dir, default_workdir.as_deref()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, &e),
|
||||
};
|
||||
@ -177,8 +174,7 @@ pub async fn get_files(
|
||||
.unwrap_or("application/octet-stream");
|
||||
|
||||
if use_encoding == "gzip" {
|
||||
let mut encoder =
|
||||
flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
let mut encoder = flate2::write::GzEncoder::new(Vec::new(), flate2::Compression::default());
|
||||
if let Err(e) = encoder.write_all(&file_data) {
|
||||
return json_error(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
@ -225,13 +221,11 @@ pub async fn post_files(
|
||||
let header_token = extract_header_token(&req);
|
||||
|
||||
let default_user = state.defaults.user();
|
||||
let username = match execcontext::resolve_default_username(
|
||||
params.username.as_deref(),
|
||||
&default_user,
|
||||
) {
|
||||
Ok(u) => u.to_string(),
|
||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, e),
|
||||
};
|
||||
let username =
|
||||
match execcontext::resolve_default_username(params.username.as_deref(), &default_user) {
|
||||
Ok(u) => u.to_string(),
|
||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, e),
|
||||
};
|
||||
|
||||
if let Err(e) = validate_file_signing(
|
||||
&state,
|
||||
@ -283,10 +277,7 @@ pub async fn post_files(
|
||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, &e),
|
||||
}
|
||||
} else {
|
||||
let fname = field
|
||||
.file_name()
|
||||
.unwrap_or("upload")
|
||||
.to_string();
|
||||
let fname = field.file_name().unwrap_or("upload").to_string();
|
||||
match expand_and_resolve(&fname, &home_dir, default_workdir.as_deref()) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return json_error(StatusCode::BAD_REQUEST, &e),
|
||||
@ -382,7 +373,7 @@ fn process_file(
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("error getting file info: {e}"),
|
||||
))
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
@ -395,7 +386,7 @@ fn process_file(
|
||||
return Err((
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
format!("error changing ownership: {e}"),
|
||||
))
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,6 +26,9 @@ pub struct InitRequest {
|
||||
pub volume_mounts: Option<Vec<VolumeMount>>,
|
||||
pub sandbox_id: Option<String>,
|
||||
pub template_id: Option<String>,
|
||||
/// Public proxy domain (e.g. "wrenn.dev"). Used by `envd ports` to build
|
||||
/// the {port}-{sandbox_id}.{domain} URLs.
|
||||
pub proxy_domain: Option<String>,
|
||||
/// New lifecycle identifier for this resume. When it changes between
|
||||
/// /init calls, envd treats the call as a post-resume hook: port
|
||||
/// forwarder is restarted and NFS mounts are refreshed.
|
||||
@ -183,14 +186,32 @@ pub async fn post_init(
|
||||
// SAFETY: envd is single-threaded at init time; no concurrent env reads.
|
||||
unsafe { std::env::set_var("WRENN_SANDBOX_ID", id) };
|
||||
write_run_file(".WRENN_SANDBOX_ID", id);
|
||||
state.defaults.env_vars.insert("WRENN_SANDBOX_ID".into(), id.clone());
|
||||
state
|
||||
.defaults
|
||||
.env_vars
|
||||
.insert("WRENN_SANDBOX_ID".into(), id.clone());
|
||||
}
|
||||
if let Some(ref id) = init_req.template_id {
|
||||
tracing::debug!(template_id = %id, "setting template ID from init request");
|
||||
// SAFETY: envd is single-threaded at init time; no concurrent env reads.
|
||||
unsafe { std::env::set_var("WRENN_TEMPLATE_ID", id) };
|
||||
write_run_file(".WRENN_TEMPLATE_ID", id);
|
||||
state.defaults.env_vars.insert("WRENN_TEMPLATE_ID".into(), id.clone());
|
||||
state
|
||||
.defaults
|
||||
.env_vars
|
||||
.insert("WRENN_TEMPLATE_ID".into(), id.clone());
|
||||
}
|
||||
if let Some(ref domain) = init_req.proxy_domain {
|
||||
if !domain.is_empty() {
|
||||
tracing::debug!(proxy_domain = %domain, "setting proxy domain from init request");
|
||||
// SAFETY: envd is single-threaded at init time; no concurrent env reads.
|
||||
unsafe { std::env::set_var("WRENN_PROXY_DOMAIN", domain) };
|
||||
write_run_file(".WRENN_PROXY_DOMAIN", domain);
|
||||
state
|
||||
.defaults
|
||||
.env_vars
|
||||
.insert("WRENN_PROXY_DOMAIN".into(), domain.clone());
|
||||
}
|
||||
}
|
||||
|
||||
(
|
||||
@ -202,7 +223,10 @@ pub async fn post_init(
|
||||
|
||||
async fn validate_init_access_token(state: &AppState, request_token: &str) -> Result<(), String> {
|
||||
// Fast path: matches existing token
|
||||
if state.access_token.is_set() && !request_token.is_empty() && state.access_token.equals(request_token) {
|
||||
if state.access_token.is_set()
|
||||
&& !request_token.is_empty()
|
||||
&& state.access_token.equals(request_token)
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
@ -241,10 +265,7 @@ async fn setup_hyperloop(address: &str, env_vars: &dashmap::DashMap<String, Stri
|
||||
}
|
||||
}
|
||||
|
||||
env_vars.insert(
|
||||
"WRENN_EVENTS_ADDRESS".into(),
|
||||
format!("http://{address}"),
|
||||
);
|
||||
env_vars.insert("WRENN_EVENTS_ADDRESS".into(), format!("http://{address}"));
|
||||
}
|
||||
|
||||
async fn setup_nfs(nfs_target: &str, path: &str) {
|
||||
@ -287,7 +308,7 @@ async fn setup_nfs(nfs_target: &str, path: &str) {
|
||||
}
|
||||
|
||||
fn write_run_file(name: &str, value: &str) {
|
||||
let dir = std::path::Path::new("/run/wrenn");
|
||||
let dir = std::path::Path::new(crate::config::WRENN_RUN_DIR);
|
||||
if let Err(e) = std::fs::create_dir_all(dir) {
|
||||
tracing::warn!(error = %e, "failed to create /run/wrenn");
|
||||
return;
|
||||
@ -309,4 +330,3 @@ fn parse_timestamp_to_nanos(ts: &str) -> Result<i64, ()> {
|
||||
}
|
||||
Err(())
|
||||
}
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
pub mod activity;
|
||||
pub mod encoding;
|
||||
pub mod envs;
|
||||
pub mod error;
|
||||
@ -13,8 +14,8 @@ use std::time::Duration;
|
||||
|
||||
use axum::Router;
|
||||
use axum::routing::{get, post};
|
||||
use http::header::{CACHE_CONTROL, HeaderName};
|
||||
use http::Method;
|
||||
use http::header::{CACHE_CONTROL, HeaderName};
|
||||
use tower_http::cors::{AllowHeaders, AllowMethods, AllowOrigin, CorsLayer};
|
||||
|
||||
use crate::config::CORS_MAX_AGE;
|
||||
@ -47,6 +48,7 @@ pub fn router(state: Arc<AppState>) -> Router {
|
||||
|
||||
Router::new()
|
||||
.route("/health", get(health::get_health))
|
||||
.route("/activity", get(activity::get_activity))
|
||||
.route("/metrics", get(metrics::get_metrics))
|
||||
.route("/envs", get(envs::get_envs))
|
||||
.route("/init", post(init::post_init))
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
mod auth;
|
||||
mod cgroups;
|
||||
mod cmd;
|
||||
mod config;
|
||||
mod conntracker;
|
||||
mod crypto;
|
||||
@ -39,6 +40,10 @@ const COMMIT: &str = {
|
||||
#[derive(Parser)]
|
||||
#[command(name = "envd", about = "Wrenn guest agent daemon")]
|
||||
struct Cli {
|
||||
/// Client subcommand. When omitted, envd runs as the guest daemon.
|
||||
#[command(subcommand)]
|
||||
command: Option<Commands>,
|
||||
|
||||
#[arg(long, default_value_t = DEFAULT_PORT)]
|
||||
port: u16,
|
||||
|
||||
@ -55,6 +60,12 @@ struct Cli {
|
||||
cgroup_root: String,
|
||||
}
|
||||
|
||||
#[derive(clap::Subcommand)]
|
||||
enum Commands {
|
||||
/// List externally-reachable open ports and the URL each is served at.
|
||||
Ports(cmd::ports::PortsArgs),
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let cli = Cli::parse();
|
||||
@ -68,6 +79,11 @@ async fn main() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Client subcommands are short-lived: run and exit before any daemon setup.
|
||||
if let Some(Commands::Ports(args)) = &cli.command {
|
||||
std::process::exit(cmd::ports::run(args));
|
||||
}
|
||||
|
||||
logging::init(true);
|
||||
|
||||
if let Err(e) = fs::create_dir_all(WRENN_RUN_DIR) {
|
||||
@ -85,36 +101,35 @@ async fn main() {
|
||||
}
|
||||
|
||||
// Cgroup manager
|
||||
let cgroup_manager: Arc<dyn cgroups::CgroupManager> =
|
||||
match cgroups::Cgroup2Manager::new(
|
||||
&cli.cgroup_root,
|
||||
&[
|
||||
(
|
||||
cgroups::ProcessType::Pty,
|
||||
"wrenn/pty",
|
||||
&[] as &[(&str, &str)],
|
||||
),
|
||||
(
|
||||
cgroups::ProcessType::User,
|
||||
"wrenn/user",
|
||||
&[] as &[(&str, &str)],
|
||||
),
|
||||
(
|
||||
cgroups::ProcessType::Socat,
|
||||
"wrenn/socat",
|
||||
&[] as &[(&str, &str)],
|
||||
),
|
||||
],
|
||||
) {
|
||||
Ok(m) => {
|
||||
tracing::info!("cgroup2 manager initialized");
|
||||
Arc::new(m)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "cgroup2 init failed, using noop");
|
||||
Arc::new(cgroups::NoopCgroupManager)
|
||||
}
|
||||
};
|
||||
let cgroup_manager: Arc<dyn cgroups::CgroupManager> = match cgroups::Cgroup2Manager::new(
|
||||
&cli.cgroup_root,
|
||||
&[
|
||||
(
|
||||
cgroups::ProcessType::Pty,
|
||||
"wrenn/pty",
|
||||
&[] as &[(&str, &str)],
|
||||
),
|
||||
(
|
||||
cgroups::ProcessType::User,
|
||||
"wrenn/user",
|
||||
&[] as &[(&str, &str)],
|
||||
),
|
||||
(
|
||||
cgroups::ProcessType::Socat,
|
||||
"wrenn/socat",
|
||||
&[] as &[(&str, &str)],
|
||||
),
|
||||
],
|
||||
) {
|
||||
Ok(m) => {
|
||||
tracing::info!("cgroup2 manager initialized");
|
||||
Arc::new(m)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(error = %e, "cgroup2 init failed, using noop");
|
||||
Arc::new(cgroups::NoopCgroupManager)
|
||||
}
|
||||
};
|
||||
|
||||
// Port subsystem
|
||||
let port_subsystem = Arc::new(PortSubsystem::new(Arc::clone(&cgroup_manager)));
|
||||
@ -138,8 +153,7 @@ async fn main() {
|
||||
// RPC services (Connect protocol — serves Connect + gRPC + gRPC-Web on same port)
|
||||
let connect_router = rpc::rpc_router(Arc::clone(&state));
|
||||
|
||||
let app = http::router(Arc::clone(&state))
|
||||
.fallback_service(connect_router.into_axum_service());
|
||||
let app = http::router(Arc::clone(&state)).fallback_service(connect_router.into_axum_service());
|
||||
|
||||
// --cmd: spawn initial process if specified
|
||||
if !cli.start_cmd.is_empty() {
|
||||
@ -151,7 +165,12 @@ async fn main() {
|
||||
}
|
||||
|
||||
let addr = SocketAddr::from(([0, 0, 0, 0], cli.port));
|
||||
tracing::info!(port = cli.port, version = VERSION, commit = COMMIT, "envd starting");
|
||||
tracing::info!(
|
||||
port = cli.port,
|
||||
version = VERSION,
|
||||
commit = COMMIT,
|
||||
"envd starting"
|
||||
);
|
||||
|
||||
let listener = TcpListener::bind(addr).await.expect("failed to bind");
|
||||
|
||||
@ -186,9 +205,7 @@ fn spawn_initial_command(cmd: &str, state: &AppState) {
|
||||
|
||||
let home = user.dir.to_string_lossy().to_string();
|
||||
let default_workdir = state.defaults.workdir();
|
||||
let cwd = default_workdir
|
||||
.as_deref()
|
||||
.unwrap_or(&home);
|
||||
let cwd = default_workdir.as_deref().unwrap_or(&home);
|
||||
|
||||
match process_handler::spawn_process(
|
||||
cmd,
|
||||
@ -235,8 +252,7 @@ fn memory_reclaimer(_state: Arc<AppState>) {
|
||||
} else {
|
||||
let mut sys2 = sysinfo::System::new();
|
||||
sys2.refresh_memory();
|
||||
let freed_mb =
|
||||
sys2.available_memory().saturating_sub(available) / (1024 * 1024);
|
||||
let freed_mb = sys2.available_memory().saturating_sub(available) / (1024 * 1024);
|
||||
tracing::info!(used_pct, freed_mb, "page cache dropped");
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,2 +1,2 @@
|
||||
pub mod user;
|
||||
pub mod path;
|
||||
pub mod user;
|
||||
|
||||
@ -94,7 +94,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tilde_slash_path() {
|
||||
assert_eq!(expand_tilde("~/docs", "/home/user").unwrap(), "/home/user/docs");
|
||||
assert_eq!(
|
||||
expand_tilde("~/docs", "/home/user").unwrap(),
|
||||
"/home/user/docs"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -109,12 +112,18 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn tilde_relative_no_tilde() {
|
||||
assert_eq!(expand_tilde("relative/path", "/home/u").unwrap(), "relative/path");
|
||||
assert_eq!(
|
||||
expand_tilde("relative/path", "/home/u").unwrap(),
|
||||
"relative/path"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tilde_cmd_like() {
|
||||
assert_eq!(expand_tilde("~/bin/myapp", "/home/user").unwrap(), "/home/user/bin/myapp");
|
||||
assert_eq!(
|
||||
expand_tilde("~/bin/myapp", "/home/user").unwrap(),
|
||||
"/home/user/bin/myapp"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -144,12 +153,18 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resolve_absolute_passthrough() {
|
||||
assert_eq!(expand_and_resolve("/abs/path", "/home", None).unwrap(), "/abs/path");
|
||||
assert_eq!(
|
||||
expand_and_resolve("/abs/path", "/home", None).unwrap(),
|
||||
"/abs/path"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_empty_uses_default() {
|
||||
assert_eq!(expand_and_resolve("", "/home", Some("/default")).unwrap(), "/default");
|
||||
assert_eq!(
|
||||
expand_and_resolve("", "/home", Some("/default")).unwrap(),
|
||||
"/default"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@ -161,7 +176,10 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn resolve_tilde_expands() {
|
||||
assert_eq!(expand_and_resolve("~/dir", "/home/u", None).unwrap(), "/home/u/dir");
|
||||
assert_eq!(
|
||||
expand_and_resolve("~/dir", "/home/u", None).unwrap(),
|
||||
"/home/u/dir"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@ -37,6 +37,36 @@ pub fn read_tcp_connections() -> Vec<ConnStat> {
|
||||
conns
|
||||
}
|
||||
|
||||
/// Returns the TCP ports in LISTEN state that are reachable from outside the
|
||||
/// guest through the host proxy. A port qualifies when it is bound to a
|
||||
/// wildcard address (`0.0.0.0`/`::`, directly reachable on the TAP interface)
|
||||
/// or to loopback (`127.0.0.1`/`::1`, bridged to the TAP IP by the socat
|
||||
/// forwarder). Ports bound to any other specific address are not routable from
|
||||
/// the host and are excluded, as is `exclude_port` (envd's own control port).
|
||||
/// The result is deduplicated and sorted ascending.
|
||||
pub fn reachable_listening_ports(exclude_port: u32) -> Vec<u32> {
|
||||
filter_reachable_ports(&read_tcp_connections(), exclude_port)
|
||||
}
|
||||
|
||||
fn filter_reachable_ports(conns: &[ConnStat], exclude_port: u32) -> Vec<u32> {
|
||||
let mut ports: Vec<u32> = conns
|
||||
.iter()
|
||||
.filter(|c| c.status == "LISTEN")
|
||||
.filter(|c| is_reachable_bind(&c.local_ip))
|
||||
.map(|c| c.local_port)
|
||||
.filter(|p| *p != exclude_port)
|
||||
.collect();
|
||||
ports.sort_unstable();
|
||||
ports.dedup();
|
||||
ports
|
||||
}
|
||||
|
||||
/// A bind address is reachable from the host when it is a wildcard (directly
|
||||
/// routed via the TAP interface) or loopback (socat-forwarded to the TAP IP).
|
||||
fn is_reachable_bind(ip: &str) -> bool {
|
||||
matches!(ip, "0.0.0.0" | "::" | "127.0.0.1" | "::1")
|
||||
}
|
||||
|
||||
fn parse_proc_net_tcp(path: &str, family: u32) -> io::Result<Vec<ConnStat>> {
|
||||
let file = std::fs::File::open(path)?;
|
||||
let reader = io::BufReader::new(file);
|
||||
@ -92,7 +122,10 @@ fn parse_hex_addr(s: &str, family: u32) -> Option<(String, u32)> {
|
||||
if ip_bytes.len() != 4 {
|
||||
return None;
|
||||
}
|
||||
format!("{}.{}.{}.{}", ip_bytes[3], ip_bytes[2], ip_bytes[1], ip_bytes[0])
|
||||
format!(
|
||||
"{}.{}.{}.{}",
|
||||
ip_bytes[3], ip_bytes[2], ip_bytes[1], ip_bytes[0]
|
||||
)
|
||||
} else {
|
||||
if ip_bytes.len() != 16 {
|
||||
return None;
|
||||
@ -257,4 +290,76 @@ mod tests {
|
||||
fn parse_nonexistent_file_errors() {
|
||||
assert!(parse_proc_net_tcp("/nonexistent/path", libc::AF_INET as u32).is_err());
|
||||
}
|
||||
|
||||
// reachable port filtering
|
||||
|
||||
fn conn(ip: &str, port: u32, status: &str) -> ConnStat {
|
||||
ConnStat {
|
||||
local_ip: ip.to_string(),
|
||||
local_port: port,
|
||||
status: status.to_string(),
|
||||
family: libc::AF_INET as u32,
|
||||
inode: 0,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reachable_bind_accepts_wildcard_and_loopback() {
|
||||
assert!(is_reachable_bind("0.0.0.0"));
|
||||
assert!(is_reachable_bind("::"));
|
||||
assert!(is_reachable_bind("127.0.0.1"));
|
||||
assert!(is_reachable_bind("::1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reachable_bind_rejects_specific_address() {
|
||||
assert!(!is_reachable_bind("192.168.1.5"));
|
||||
assert!(!is_reachable_bind("169.254.0.21"));
|
||||
assert!(!is_reachable_bind("10.0.0.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_keeps_only_listen_state() {
|
||||
let conns = vec![
|
||||
conn("0.0.0.0", 8000, "LISTEN"),
|
||||
conn("0.0.0.0", 9000, "ESTABLISHED"),
|
||||
];
|
||||
assert_eq!(filter_reachable_ports(&conns, 49983), vec![8000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_excludes_unreachable_binds() {
|
||||
let conns = vec![
|
||||
conn("127.0.0.1", 8000, "LISTEN"),
|
||||
conn("169.254.0.21", 8001, "LISTEN"), // socat's own listener
|
||||
conn("192.168.1.5", 8002, "LISTEN"),
|
||||
];
|
||||
assert_eq!(filter_reachable_ports(&conns, 49983), vec![8000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_excludes_envd_control_port() {
|
||||
let conns = vec![
|
||||
conn("0.0.0.0", 49983, "LISTEN"),
|
||||
conn("0.0.0.0", 8000, "LISTEN"),
|
||||
];
|
||||
assert_eq!(filter_reachable_ports(&conns, 49983), vec![8000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_dedups_and_sorts() {
|
||||
// Same port on IPv4 wildcard and IPv6 loopback collapses to one entry.
|
||||
let conns = vec![
|
||||
conn("::1", 8000, "LISTEN"),
|
||||
conn("0.0.0.0", 8000, "LISTEN"),
|
||||
conn("0.0.0.0", 3000, "LISTEN"),
|
||||
];
|
||||
assert_eq!(filter_reachable_ports(&conns, 49983), vec![3000, 8000]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_empty_when_no_listeners() {
|
||||
let conns = vec![conn("0.0.0.0", 8000, "ESTABLISHED")];
|
||||
assert!(filter_reachable_ports(&conns, 49983).is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@ -53,9 +53,7 @@ pub fn build_entry_info(path: &str) -> Result<EntryInfo, ConnectError> {
|
||||
Err(_) => FileType::FILE_TYPE_UNSPECIFIED,
|
||||
};
|
||||
|
||||
let target_mode = std::fs::metadata(p)
|
||||
.map(|m| m.mode() & 0o7777)
|
||||
.unwrap_or(0);
|
||||
let target_mode = std::fs::metadata(p).map(|m| m.mode() & 0o7777).unwrap_or(0);
|
||||
|
||||
(target_type, target_mode, Some(target))
|
||||
} else {
|
||||
|
||||
@ -98,8 +98,7 @@ impl Filesystem for FilesystemServiceImpl {
|
||||
}
|
||||
|
||||
let username = extract_username(&ctx).unwrap_or_else(|| self.state.defaults.user());
|
||||
let user =
|
||||
lookup_user(&username).map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
|
||||
let user = lookup_user(&username).map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
|
||||
|
||||
ensure_dirs(&path, user.uid, user.gid)
|
||||
.map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
|
||||
@ -123,8 +122,7 @@ impl Filesystem for FilesystemServiceImpl {
|
||||
let destination = self.resolve_path(request.destination, &ctx)?;
|
||||
|
||||
let username = extract_username(&ctx).unwrap_or_else(|| self.state.defaults.user());
|
||||
let user =
|
||||
lookup_user(&username).map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
|
||||
let user = lookup_user(&username).map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
|
||||
|
||||
if let Some(parent) = Path::new(&destination).parent() {
|
||||
ensure_dirs(&parent.to_string_lossy(), user.uid, user.gid)
|
||||
@ -206,7 +204,12 @@ impl Filesystem for FilesystemServiceImpl {
|
||||
}
|
||||
}
|
||||
|
||||
Ok((RemoveResponse { ..Default::default() }, ctx))
|
||||
Ok((
|
||||
RemoveResponse {
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
))
|
||||
}
|
||||
|
||||
async fn watch_dir(
|
||||
@ -247,8 +250,8 @@ impl Filesystem for FilesystemServiceImpl {
|
||||
let events: Arc<Mutex<Vec<FilesystemEvent>>> = Arc::new(Mutex::new(Vec::new()));
|
||||
let events_cb = Arc::clone(&events);
|
||||
|
||||
let mut watcher = notify::recommended_watcher(
|
||||
move |res: Result<notify::Event, notify::Error>| {
|
||||
let mut watcher =
|
||||
notify::recommended_watcher(move |res: Result<notify::Event, notify::Error>| {
|
||||
if let Ok(event) = res {
|
||||
let event_type = match event.kind {
|
||||
notify::EventKind::Create(_) => EventType::EVENT_TYPE_CREATE,
|
||||
@ -275,11 +278,13 @@ impl Filesystem for FilesystemServiceImpl {
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
.map_err(|e| {
|
||||
ConnectError::new(ErrorCode::Internal, format!("failed to create watcher: {e}"))
|
||||
})?;
|
||||
})
|
||||
.map_err(|e| {
|
||||
ConnectError::new(
|
||||
ErrorCode::Internal,
|
||||
format!("failed to create watcher: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
let mode = if recursive {
|
||||
RecursiveMode::Recursive
|
||||
@ -342,7 +347,12 @@ impl Filesystem for FilesystemServiceImpl {
|
||||
) -> Result<(RemoveWatcherResponse, Context), ConnectError> {
|
||||
let watcher_id: &str = request.watcher_id;
|
||||
self.watchers.remove(watcher_id);
|
||||
Ok((RemoveWatcherResponse { ..Default::default() }, ctx))
|
||||
Ok((
|
||||
RemoveWatcherResponse {
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,17 +1,17 @@
|
||||
pub mod pb;
|
||||
pub mod entry;
|
||||
pub mod filesystem_service;
|
||||
pub mod pb;
|
||||
pub mod process_handler;
|
||||
pub mod process_service;
|
||||
pub mod filesystem_service;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use crate::rpc::process_service::ProcessServiceImpl;
|
||||
use crate::rpc::filesystem_service::FilesystemServiceImpl;
|
||||
use crate::rpc::process_service::ProcessServiceImpl;
|
||||
use crate::state::AppState;
|
||||
|
||||
use pb::process::ProcessExt;
|
||||
use pb::filesystem::FilesystemExt;
|
||||
use pb::process::ProcessExt;
|
||||
|
||||
/// Build the connect-rust Router with both RPC services registered.
|
||||
pub fn rpc_router(state: Arc<AppState>) -> connectrpc::Router {
|
||||
|
||||
@ -1,4 +1,9 @@
|
||||
#![allow(dead_code, non_camel_case_types, unused_imports, clippy::derivable_impls)]
|
||||
#![allow(
|
||||
dead_code,
|
||||
non_camel_case_types,
|
||||
unused_imports,
|
||||
clippy::derivable_impls
|
||||
)]
|
||||
|
||||
use ::buffa;
|
||||
use ::buffa_types;
|
||||
|
||||
@ -1,10 +1,11 @@
|
||||
use std::collections::VecDeque;
|
||||
use std::io::Read;
|
||||
use std::os::unix::process::CommandExt;
|
||||
use std::process::Stdio;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use connectrpc::{ConnectError, ErrorCode};
|
||||
use nix::pty::{openpty, Winsize};
|
||||
use nix::pty::{Winsize, openpty};
|
||||
use nix::sys::signal::{self, Signal};
|
||||
use nix::unistd::Pid;
|
||||
use tokio::sync::broadcast;
|
||||
@ -15,6 +16,11 @@ const STD_CHUNK_SIZE: usize = 32768;
|
||||
const PTY_CHUNK_SIZE: usize = 16384;
|
||||
const BROADCAST_CAPACITY: usize = 4096;
|
||||
|
||||
// Upper bound on the per-process output kept for replay. A late Connect gets
|
||||
// the most recent OUTPUT_LOG_CAPACITY bytes (older output is evicted) so the
|
||||
// buffer can never grow without bound for a chatty long-running process.
|
||||
const OUTPUT_LOG_CAPACITY: usize = 256 * 1024;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub enum DataEvent {
|
||||
Stdout(Vec<u8>),
|
||||
@ -30,6 +36,37 @@ pub struct EndEvent {
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// Bounded ring of recent output, kept so a late Connect can replay what it
|
||||
/// missed. Evicts oldest events once the retained bytes exceed the cap.
|
||||
#[derive(Default)]
|
||||
struct OutputLog {
|
||||
events: VecDeque<DataEvent>,
|
||||
bytes: usize,
|
||||
}
|
||||
|
||||
impl OutputLog {
|
||||
fn push(&mut self, ev: &DataEvent) {
|
||||
self.bytes += ev_len(ev);
|
||||
self.events.push_back(ev.clone());
|
||||
while self.bytes > OUTPUT_LOG_CAPACITY {
|
||||
match self.events.pop_front() {
|
||||
Some(old) => self.bytes -= ev_len(&old),
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn snapshot(&self) -> Vec<DataEvent> {
|
||||
self.events.iter().cloned().collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn ev_len(ev: &DataEvent) -> usize {
|
||||
match ev {
|
||||
DataEvent::Stdout(d) | DataEvent::Stderr(d) | DataEvent::Pty(d) => d.len(),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ProcessHandle {
|
||||
pub config: ProcessConfig,
|
||||
pub tag: Option<String>,
|
||||
@ -38,6 +75,7 @@ pub struct ProcessHandle {
|
||||
data_tx: broadcast::Sender<DataEvent>,
|
||||
end_tx: broadcast::Sender<EndEvent>,
|
||||
ended: Mutex<Option<EndEvent>>,
|
||||
output_log: Mutex<OutputLog>,
|
||||
|
||||
stdin: Mutex<Option<std::process::ChildStdin>>,
|
||||
pty_master: Mutex<Option<std::fs::File>>,
|
||||
@ -48,6 +86,26 @@ impl ProcessHandle {
|
||||
self.data_tx.subscribe()
|
||||
}
|
||||
|
||||
/// Append a chunk to the replay buffer and broadcast it live, under one
|
||||
/// lock. The shared lock is what makes [`subscribe_data_replay`] race-free:
|
||||
/// a concurrent attach sees this chunk either in its snapshot or on its live
|
||||
/// receiver — never both, never neither.
|
||||
pub fn publish_data(&self, ev: DataEvent) {
|
||||
let mut log = self.output_log.lock().unwrap();
|
||||
log.push(&ev);
|
||||
let _ = self.data_tx.send(ev);
|
||||
}
|
||||
|
||||
/// Snapshot the buffered output and subscribe to live output atomically, so
|
||||
/// a late Connect replays what it missed and then continues live with no gap
|
||||
/// or duplicate across the handoff.
|
||||
pub fn subscribe_data_replay(&self) -> (Vec<DataEvent>, broadcast::Receiver<DataEvent>) {
|
||||
let log = self.output_log.lock().unwrap();
|
||||
let snapshot = log.snapshot();
|
||||
let rx = self.data_tx.subscribe();
|
||||
(snapshot, rx)
|
||||
}
|
||||
|
||||
pub fn subscribe_end(&self) -> broadcast::Receiver<EndEvent> {
|
||||
self.end_tx.subscribe()
|
||||
}
|
||||
@ -160,6 +218,9 @@ pub fn spawn_process(
|
||||
env.push(("HOME".into(), home));
|
||||
env.push(("USER".into(), user.name.clone()));
|
||||
env.push(("LOGNAME".into(), user.name.clone()));
|
||||
if !user.shell.as_os_str().is_empty() {
|
||||
env.push(("SHELL".into(), user.shell.to_string_lossy().to_string()));
|
||||
}
|
||||
|
||||
default_env_vars.iter().for_each(|entry| {
|
||||
env.push((entry.key().clone(), entry.value().clone()));
|
||||
@ -179,21 +240,40 @@ pub fn spawn_process(
|
||||
let nice_delta = 0 - current_nice();
|
||||
let profile_source = r#"test -f /etc/profile && . /etc/profile
|
||||
test -f "${HOME}/.bashrc" && . "${HOME}/.bashrc""#;
|
||||
let oom_script = if nice_delta > 0 {
|
||||
format!(
|
||||
r#"echo 100 > /proc/$$/oom_score_adj
|
||||
{}
|
||||
exec /usr/bin/nice -n {} "${{@}}""#,
|
||||
profile_source, nice_delta,
|
||||
)
|
||||
} else {
|
||||
format!(
|
||||
r#"echo 100 > /proc/$$/oom_score_adj
|
||||
{}
|
||||
exec "$@""#,
|
||||
profile_source
|
||||
)
|
||||
|
||||
// Resolve the user's login shell, falling back to /bin/sh. Commands without
|
||||
// explicit args are interpreted by this shell so pipes, quoting, escape
|
||||
// sequences, backslash line-continuations, and other shell syntax work
|
||||
// without the caller having to wrap them in `sh -c` themselves.
|
||||
let shell = {
|
||||
let s = user.shell.to_string_lossy();
|
||||
if s.is_empty() {
|
||||
"/bin/sh".to_string()
|
||||
} else {
|
||||
s.to_string()
|
||||
}
|
||||
};
|
||||
|
||||
// What the wrapper finally exec's, after the optional `nice` prefix.
|
||||
// - no args: run cmd_str as a shell command line via the login shell
|
||||
// ($1 is cmd_str; $0 of the inner shell is the shell path).
|
||||
// - with args: exec the program + args directly, no shell interpretation
|
||||
// (backward-compatible program/argv form).
|
||||
let target = if args.is_empty() {
|
||||
format!(r#""{shell}" -c "$1" "{shell}""#)
|
||||
} else {
|
||||
r#""$@""#.to_string()
|
||||
};
|
||||
let nice_prefix = if nice_delta > 0 {
|
||||
format!("/usr/bin/nice -n {nice_delta} ")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let oom_script = format!(
|
||||
r#"echo 100 > /proc/$$/oom_score_adj
|
||||
{profile_source}
|
||||
exec {nice_prefix}{target}"#
|
||||
);
|
||||
let mut wrapper_args = vec![
|
||||
"-c".to_string(),
|
||||
oom_script,
|
||||
@ -264,7 +344,10 @@ exec "$@""#,
|
||||
command.stderr(Stdio::null());
|
||||
|
||||
let child = command.spawn().map_err(|e| {
|
||||
ConnectError::new(ErrorCode::Internal, format!("error starting pty process: {e}"))
|
||||
ConnectError::new(
|
||||
ErrorCode::Internal,
|
||||
format!("error starting pty process: {e}"),
|
||||
)
|
||||
})?;
|
||||
|
||||
drop(slave_fd);
|
||||
@ -280,6 +363,7 @@ exec "$@""#,
|
||||
data_tx: data_tx.clone(),
|
||||
end_tx: end_tx.clone(),
|
||||
ended: Mutex::new(None),
|
||||
output_log: Mutex::new(OutputLog::default()),
|
||||
stdin: Mutex::new(None),
|
||||
pty_master: Mutex::new(Some(master_file)),
|
||||
});
|
||||
@ -287,7 +371,7 @@ exec "$@""#,
|
||||
let data_rx = handle.subscribe_data();
|
||||
let end_rx = handle.subscribe_end();
|
||||
|
||||
let data_tx_clone = data_tx.clone();
|
||||
let handle_for_reader = Arc::clone(&handle);
|
||||
let pty_reader = std::thread::spawn(move || {
|
||||
let mut master = master_clone;
|
||||
let mut buf = vec![0u8; PTY_CHUNK_SIZE];
|
||||
@ -295,7 +379,7 @@ exec "$@""#,
|
||||
match master.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
let _ = data_tx_clone.send(DataEvent::Pty(buf[..n].to_vec()));
|
||||
handle_for_reader.publish_data(DataEvent::Pty(buf[..n].to_vec()));
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
@ -329,7 +413,11 @@ exec "$@""#,
|
||||
});
|
||||
|
||||
tracing::info!(pid, cmd = cmd_str, "process started (pty)");
|
||||
Ok(SpawnedProcess { handle, data_rx, end_rx })
|
||||
Ok(SpawnedProcess {
|
||||
handle,
|
||||
data_rx,
|
||||
end_rx,
|
||||
})
|
||||
} else {
|
||||
let mut command = std::process::Command::new("/bin/bash");
|
||||
command
|
||||
@ -375,6 +463,7 @@ exec "$@""#,
|
||||
data_tx: data_tx.clone(),
|
||||
end_tx: end_tx.clone(),
|
||||
ended: Mutex::new(None),
|
||||
output_log: Mutex::new(OutputLog::default()),
|
||||
stdin: Mutex::new(stdin),
|
||||
pty_master: Mutex::new(None),
|
||||
});
|
||||
@ -385,14 +474,14 @@ exec "$@""#,
|
||||
let mut output_readers: Vec<std::thread::JoinHandle<()>> = Vec::new();
|
||||
|
||||
if let Some(mut out) = stdout {
|
||||
let tx = data_tx.clone();
|
||||
let handle_for_reader = Arc::clone(&handle);
|
||||
output_readers.push(std::thread::spawn(move || {
|
||||
let mut buf = vec![0u8; STD_CHUNK_SIZE];
|
||||
loop {
|
||||
match out.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
let _ = tx.send(DataEvent::Stdout(buf[..n].to_vec()));
|
||||
handle_for_reader.publish_data(DataEvent::Stdout(buf[..n].to_vec()));
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
@ -401,14 +490,14 @@ exec "$@""#,
|
||||
}
|
||||
|
||||
if let Some(mut err_pipe) = stderr {
|
||||
let tx = data_tx.clone();
|
||||
let handle_for_reader = Arc::clone(&handle);
|
||||
output_readers.push(std::thread::spawn(move || {
|
||||
let mut buf = vec![0u8; STD_CHUNK_SIZE];
|
||||
loop {
|
||||
match err_pipe.read(&mut buf) {
|
||||
Ok(0) => break,
|
||||
Ok(n) => {
|
||||
let _ = tx.send(DataEvent::Stderr(buf[..n].to_vec()));
|
||||
handle_for_reader.publish_data(DataEvent::Stderr(buf[..n].to_vec()));
|
||||
}
|
||||
Err(_) => break,
|
||||
}
|
||||
@ -444,7 +533,11 @@ exec "$@""#,
|
||||
});
|
||||
|
||||
tracing::info!(pid, cmd = cmd_str, "process started (pipe)");
|
||||
Ok(SpawnedProcess { handle, data_rx, end_rx })
|
||||
Ok(SpawnedProcess {
|
||||
handle,
|
||||
data_rx,
|
||||
end_rx,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -4,7 +4,8 @@ use std::sync::Arc;
|
||||
|
||||
use connectrpc::{ConnectError, Context, ErrorCode};
|
||||
use dashmap::DashMap;
|
||||
use futures::Stream;
|
||||
use futures::{Stream, StreamExt};
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use crate::permissions::path::{expand_and_resolve, expand_tilde};
|
||||
use crate::permissions::user::lookup_user;
|
||||
@ -72,8 +73,7 @@ impl ProcessServiceImpl {
|
||||
})?;
|
||||
|
||||
let username = self.state.defaults.user();
|
||||
let user =
|
||||
lookup_user(&username).map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
|
||||
let user = lookup_user(&username).map_err(|e| ConnectError::new(ErrorCode::Internal, e))?;
|
||||
|
||||
let cmd_raw: &str = proc_config.cmd;
|
||||
let args_raw: Vec<String> = proc_config.args.iter().map(|s| s.to_string()).collect();
|
||||
@ -87,7 +87,8 @@ impl ProcessServiceImpl {
|
||||
|
||||
let cmd = expand_tilde(cmd_raw, &home_dir)
|
||||
.map_err(|e| ConnectError::new(ErrorCode::InvalidArgument, e))?;
|
||||
let args: Vec<String> = args_raw.into_iter()
|
||||
let args: Vec<String> = args_raw
|
||||
.into_iter()
|
||||
.map(|a| expand_tilde(&a, &home_dir).unwrap_or(a))
|
||||
.collect();
|
||||
|
||||
@ -136,7 +137,8 @@ impl ProcessServiceImpl {
|
||||
&self.state.defaults.env_vars,
|
||||
)?;
|
||||
|
||||
self.processes.insert(spawned.handle.pid, Arc::clone(&spawned.handle));
|
||||
self.processes
|
||||
.insert(spawned.handle.pid, Arc::clone(&spawned.handle));
|
||||
|
||||
let processes = Arc::clone(&self.processes);
|
||||
let pid = spawned.handle.pid;
|
||||
@ -203,50 +205,10 @@ impl Process for ProcessServiceImpl {
|
||||
let spawned = self.spawn_from_request(&request)?;
|
||||
let pid = spawned.handle.pid;
|
||||
|
||||
let mut data_rx = spawned.data_rx;
|
||||
let mut end_rx = spawned.end_rx;
|
||||
|
||||
let stream = async_stream::stream! {
|
||||
yield Ok(make_start_response(pid));
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
data = data_rx.recv() => {
|
||||
match data {
|
||||
Ok(ev) => yield Ok(make_data_start_response(ev)),
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
// Data channel closed: the process ended and its
|
||||
// handle was dropped. The end event is published
|
||||
// before the handle drop, so it is still buffered
|
||||
// — emit it rather than losing the exit code.
|
||||
if let Ok(end) = end_rx.try_recv() {
|
||||
yield Ok(make_end_start_response(end));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
end = end_rx.recv() => {
|
||||
// Process ended. The waiter joins the output readers
|
||||
// before sending this event, so every byte is already
|
||||
// in the data channel — drain it fully before the end.
|
||||
loop {
|
||||
match data_rx.try_recv() {
|
||||
Ok(ev) => yield Ok(make_data_start_response(ev)),
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
if let Ok(end) = end {
|
||||
yield Ok(make_end_start_response(end));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
// Start subscribes before any output is produced, so there is nothing to
|
||||
// replay and the process cannot have ended yet.
|
||||
let stream = process_event_stream(pid, Vec::new(), spawned.data_rx, spawned.end_rx, None)
|
||||
.map(|r| r.map(wrap_start_response));
|
||||
|
||||
Ok((Box::pin(stream), ctx))
|
||||
}
|
||||
@ -268,81 +230,17 @@ impl Process for ProcessServiceImpl {
|
||||
let handle = self.get_process_by_selector(selector)?;
|
||||
let pid = handle.pid;
|
||||
|
||||
let mut data_rx = handle.subscribe_data();
|
||||
let mut end_rx = handle.subscribe_end();
|
||||
// Snapshot buffered output + subscribe live atomically, then read the
|
||||
// exit state. Ordering matters: end_rx must be subscribed before
|
||||
// cached_end is read so a process that exits in the window is still
|
||||
// observed (via the channel if subscribed in time, via cached_end
|
||||
// otherwise).
|
||||
let (replay, data_rx) = handle.subscribe_data_replay();
|
||||
let end_rx = handle.subscribe_end();
|
||||
let cached_end = handle.cached_end();
|
||||
|
||||
let stream = async_stream::stream! {
|
||||
yield Ok(ConnectResponse {
|
||||
event: buffa::MessageField::some(ProcessEvent {
|
||||
event: Some(process_event::Event::Start(Box::new(
|
||||
process_event::StartEvent { pid, ..Default::default() },
|
||||
))),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
});
|
||||
|
||||
if let Some(end) = cached_end {
|
||||
yield Ok(ConnectResponse {
|
||||
event: buffa::MessageField::some(make_end_event(end)),
|
||||
..Default::default()
|
||||
});
|
||||
} else {
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
data = data_rx.recv() => {
|
||||
match data {
|
||||
Ok(ev) => {
|
||||
yield Ok(ConnectResponse {
|
||||
event: buffa::MessageField::some(make_data_event(ev)),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => {
|
||||
// Data channel closed: the process ended and
|
||||
// its handle was dropped. The end event is
|
||||
// published before the handle drop, so it is
|
||||
// still buffered — emit it rather than losing
|
||||
// the exit code.
|
||||
if let Ok(end) = end_rx.try_recv() {
|
||||
yield Ok(ConnectResponse {
|
||||
event: buffa::MessageField::some(make_end_event(end)),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
end = end_rx.recv() => {
|
||||
// Process ended. The waiter joins the output readers
|
||||
// before sending this event, so every byte is already
|
||||
// in the data channel — drain it fully before the end.
|
||||
loop {
|
||||
match data_rx.try_recv() {
|
||||
Ok(ev) => yield Ok(ConnectResponse {
|
||||
event: buffa::MessageField::some(make_data_event(ev)),
|
||||
..Default::default()
|
||||
}),
|
||||
Err(tokio::sync::broadcast::error::TryRecvError::Lagged(_)) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
if let Ok(end) = end {
|
||||
yield Ok(ConnectResponse {
|
||||
event: buffa::MessageField::some(make_end_event(end)),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
let stream = process_event_stream(pid, replay, data_rx, end_rx, cached_end)
|
||||
.map(|r| r.map(wrap_connect_response));
|
||||
|
||||
Ok((Box::pin(stream), ctx))
|
||||
}
|
||||
@ -363,7 +261,12 @@ impl Process for ProcessServiceImpl {
|
||||
}
|
||||
}
|
||||
|
||||
Ok((UpdateResponse { ..Default::default() }, ctx))
|
||||
Ok((
|
||||
UpdateResponse {
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
))
|
||||
}
|
||||
|
||||
async fn stream_input(
|
||||
@ -372,11 +275,11 @@ impl Process for ProcessServiceImpl {
|
||||
mut requests: Pin<
|
||||
Box<
|
||||
dyn Stream<
|
||||
Item = Result<
|
||||
buffa::view::OwnedView<StreamInputRequestView<'static>>,
|
||||
ConnectError,
|
||||
>,
|
||||
> + Send,
|
||||
Item = Result<
|
||||
buffa::view::OwnedView<StreamInputRequestView<'static>>,
|
||||
ConnectError,
|
||||
>,
|
||||
> + Send,
|
||||
>,
|
||||
>,
|
||||
) -> Result<(StreamInputResponse, Context), ConnectError> {
|
||||
@ -405,7 +308,12 @@ impl Process for ProcessServiceImpl {
|
||||
}
|
||||
}
|
||||
|
||||
Ok((StreamInputResponse { ..Default::default() }, ctx))
|
||||
Ok((
|
||||
StreamInputResponse {
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
))
|
||||
}
|
||||
|
||||
async fn send_input(
|
||||
@ -422,7 +330,12 @@ impl Process for ProcessServiceImpl {
|
||||
write_input(&handle, input)?;
|
||||
}
|
||||
|
||||
Ok((SendInputResponse { ..Default::default() }, ctx))
|
||||
Ok((
|
||||
SendInputResponse {
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
))
|
||||
}
|
||||
|
||||
async fn send_signal(
|
||||
@ -442,12 +355,17 @@ impl Process for ProcessServiceImpl {
|
||||
return Err(ConnectError::new(
|
||||
ErrorCode::InvalidArgument,
|
||||
"invalid or unspecified signal",
|
||||
))
|
||||
));
|
||||
}
|
||||
};
|
||||
|
||||
handle.send_signal(sig)?;
|
||||
Ok((SendSignalResponse { ..Default::default() }, ctx))
|
||||
Ok((
|
||||
SendSignalResponse {
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
))
|
||||
}
|
||||
|
||||
async fn close_stdin(
|
||||
@ -460,7 +378,12 @@ impl Process for ProcessServiceImpl {
|
||||
})?;
|
||||
let handle = self.get_process_by_selector(selector)?;
|
||||
handle.close_stdin()?;
|
||||
Ok((CloseStdinResponse { ..Default::default() }, ctx))
|
||||
Ok((
|
||||
CloseStdinResponse {
|
||||
..Default::default()
|
||||
},
|
||||
ctx,
|
||||
))
|
||||
}
|
||||
}
|
||||
|
||||
@ -472,17 +395,106 @@ fn write_input(handle: &ProcessHandle, input: &ProcessInputView) -> Result<(), C
|
||||
}
|
||||
}
|
||||
|
||||
fn make_start_response(pid: u32) -> StartResponse {
|
||||
/// Shared event pump for `Start` and `Connect`. Yields a leading start event,
|
||||
/// replays any buffered output (empty for `Start`), then forwards live output
|
||||
/// and the final exit event. The caller wraps each `ProcessEvent` into its own
|
||||
/// response envelope, so the streaming logic lives in exactly one place.
|
||||
fn process_event_stream(
|
||||
pid: u32,
|
||||
replay: Vec<DataEvent>,
|
||||
mut data_rx: broadcast::Receiver<DataEvent>,
|
||||
mut end_rx: broadcast::Receiver<process_handler::EndEvent>,
|
||||
cached_end: Option<process_handler::EndEvent>,
|
||||
) -> impl Stream<Item = Result<ProcessEvent, ConnectError>> {
|
||||
use broadcast::error::{RecvError, TryRecvError};
|
||||
|
||||
async_stream::stream! {
|
||||
yield Ok(make_start_event(pid));
|
||||
|
||||
for ev in replay {
|
||||
yield Ok(make_data_event(ev));
|
||||
}
|
||||
|
||||
// Process already exited before we attached. The snapshot above covers
|
||||
// output up to the attach point; drain anything the live receiver
|
||||
// buffered after the snapshot, then emit the cached exit. end_rx may
|
||||
// never deliver here — a broadcast receiver only sees events sent after
|
||||
// it subscribed, and the exit can predate that — so cached_end is the
|
||||
// source of truth.
|
||||
if let Some(end) = cached_end {
|
||||
loop {
|
||||
match data_rx.try_recv() {
|
||||
Ok(ev) => yield Ok(make_data_event(ev)),
|
||||
Err(TryRecvError::Lagged(_)) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
yield Ok(make_end_event(end));
|
||||
return;
|
||||
}
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
biased;
|
||||
data = data_rx.recv() => {
|
||||
match data {
|
||||
Ok(ev) => yield Ok(make_data_event(ev)),
|
||||
Err(RecvError::Lagged(_)) => continue,
|
||||
Err(RecvError::Closed) => {
|
||||
// Data channel closed: the process ended and its
|
||||
// handle was dropped. The end event is published
|
||||
// before the handle drop, so it is still buffered —
|
||||
// emit it rather than losing the exit code.
|
||||
if let Ok(end) = end_rx.try_recv() {
|
||||
yield Ok(make_end_event(end));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
end = end_rx.recv() => {
|
||||
// Process ended. The waiter joins the output readers before
|
||||
// sending this event, so every byte is already in the data
|
||||
// channel — drain it fully before the end.
|
||||
loop {
|
||||
match data_rx.try_recv() {
|
||||
Ok(ev) => yield Ok(make_data_event(ev)),
|
||||
Err(TryRecvError::Lagged(_)) => continue,
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
if let Ok(end) = end {
|
||||
yield Ok(make_end_event(end));
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_start_response(event: ProcessEvent) -> StartResponse {
|
||||
StartResponse {
|
||||
event: buffa::MessageField::some(ProcessEvent {
|
||||
event: Some(process_event::Event::Start(Box::new(
|
||||
process_event::StartEvent {
|
||||
pid,
|
||||
..Default::default()
|
||||
},
|
||||
))),
|
||||
..Default::default()
|
||||
}),
|
||||
event: buffa::MessageField::some(event),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn wrap_connect_response(event: ProcessEvent) -> ConnectResponse {
|
||||
ConnectResponse {
|
||||
event: buffa::MessageField::some(event),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn make_start_event(pid: u32) -> ProcessEvent {
|
||||
ProcessEvent {
|
||||
event: Some(process_event::Event::Start(Box::new(
|
||||
process_event::StartEvent {
|
||||
pid,
|
||||
..Default::default()
|
||||
},
|
||||
))),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
@ -504,13 +516,6 @@ fn make_data_event(ev: DataEvent) -> ProcessEvent {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_data_start_response(ev: DataEvent) -> StartResponse {
|
||||
StartResponse {
|
||||
event: buffa::MessageField::some(make_data_event(ev)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn make_end_event(end: process_handler::EndEvent) -> ProcessEvent {
|
||||
ProcessEvent {
|
||||
event: Some(process_event::Event::End(Box::new(
|
||||
@ -526,13 +531,6 @@ fn make_end_event(end: process_handler::EndEvent) -> ProcessEvent {
|
||||
}
|
||||
}
|
||||
|
||||
fn make_end_start_response(end: process_handler::EndEvent) -> StartResponse {
|
||||
StartResponse {
|
||||
event: buffa::MessageField::some(make_end_event(end)),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@ -589,7 +587,8 @@ mod tests {
|
||||
fn args_other_user_left_literal() {
|
||||
let home_dir = "/home/testuser";
|
||||
let args_raw = vec!["~other".to_string(), "~other/path".to_string()];
|
||||
let args: Vec<String> = args_raw.into_iter()
|
||||
let args: Vec<String> = args_raw
|
||||
.into_iter()
|
||||
.map(|a| expand_tilde(&a, home_dir).unwrap_or(a))
|
||||
.collect();
|
||||
assert_eq!(args, vec!["~other", "~other/path"]);
|
||||
@ -618,17 +617,22 @@ mod tests {
|
||||
"/tmp/out".to_string(),
|
||||
"~other".to_string(),
|
||||
];
|
||||
let args: Vec<String> = args_raw.into_iter()
|
||||
let args: Vec<String> = args_raw
|
||||
.into_iter()
|
||||
.map(|a| expand_tilde(&a, home_dir).unwrap_or(a))
|
||||
.collect();
|
||||
assert_eq!(args, vec!["-p", "/home/testuser/data", "/tmp/out", "~other"]);
|
||||
assert_eq!(
|
||||
args,
|
||||
vec!["-p", "/home/testuser/data", "/tmp/out", "~other"]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn args_empty_passthrough() {
|
||||
let home_dir = "/home/testuser";
|
||||
let args_raw: Vec<String> = vec![];
|
||||
let args: Vec<String> = args_raw.into_iter()
|
||||
let args: Vec<String> = args_raw
|
||||
.into_iter()
|
||||
.map(|a| expand_tilde(&a, home_dir).unwrap_or(a))
|
||||
.collect();
|
||||
assert!(args.is_empty());
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
use std::sync::atomic::{AtomicBool, AtomicU32, AtomicU64, AtomicU8, Ordering};
|
||||
use std::sync::atomic::{AtomicBool, AtomicU8, AtomicU32, AtomicU64, Ordering};
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use crate::auth::token::SecureToken;
|
||||
@ -17,6 +17,11 @@ pub struct AppState {
|
||||
pub port_subsystem: Option<Arc<PortSubsystem>>,
|
||||
pub cpu_used_pct: AtomicU32,
|
||||
pub cpu_count: AtomicU32,
|
||||
/// Whole-VM IO throughput, bytes/sec, sampled over the last 1s tick. Used
|
||||
/// by the host activity sampler to keep IO-bound-but-CPU-idle workloads
|
||||
/// (e.g. a long download) from being mistaken for inactive.
|
||||
pub net_bps: AtomicU64,
|
||||
pub disk_bps: AtomicU64,
|
||||
|
||||
/// Memory preload coordination. The host agent POSTs /memory/preload after
|
||||
/// a snapshot restore to materialise every physical page (so the next
|
||||
@ -56,6 +61,8 @@ impl AppState {
|
||||
port_subsystem,
|
||||
cpu_used_pct: AtomicU32::new(0),
|
||||
cpu_count: AtomicU32::new(0),
|
||||
net_bps: AtomicU64::new(0),
|
||||
disk_bps: AtomicU64::new(0),
|
||||
mem_preload_started: AtomicBool::new(false),
|
||||
mem_preload_done: AtomicBool::new(false),
|
||||
mem_preload_cancel: AtomicBool::new(false),
|
||||
@ -70,7 +77,7 @@ impl AppState {
|
||||
|
||||
let state_clone = Arc::clone(&state);
|
||||
std::thread::spawn(move || {
|
||||
cpu_sampler(state_clone);
|
||||
activity_sampler(state_clone);
|
||||
});
|
||||
|
||||
state
|
||||
@ -84,6 +91,14 @@ impl AppState {
|
||||
self.cpu_count.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn net_bps(&self) -> u64 {
|
||||
self.net_bps.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
pub fn disk_bps(&self) -> u64 {
|
||||
self.disk_bps.load(Ordering::Relaxed)
|
||||
}
|
||||
|
||||
/// Records a new lifecycle ID, returning true if it changed (i.e. this
|
||||
/// is the first /init since a resume). First-ever call returns false:
|
||||
/// boot-time /init doesn't need port-subsystem restart since the
|
||||
@ -99,12 +114,16 @@ impl AppState {
|
||||
}
|
||||
}
|
||||
|
||||
fn cpu_sampler(state: Arc<AppState>) {
|
||||
fn activity_sampler(state: Arc<AppState>) {
|
||||
use sysinfo::System;
|
||||
|
||||
let mut sys = System::new();
|
||||
sys.refresh_cpu_all();
|
||||
|
||||
// Cumulative IO counters from the previous tick. None until the first read.
|
||||
let mut prev_net: Option<u64> = read_net_bytes();
|
||||
let mut prev_disk: Option<u64> = read_disk_bytes();
|
||||
|
||||
loop {
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
|
||||
@ -123,5 +142,73 @@ fn cpu_sampler(state: Arc<AppState>) {
|
||||
state
|
||||
.cpu_count
|
||||
.store(sys.cpus().len() as u32, Ordering::Relaxed);
|
||||
|
||||
// Throughput = cumulative-counter delta over the ~1s tick. Counters can
|
||||
// reset across a snapshot restore; a wrapped/negative delta reads as 0.
|
||||
let cur_net = read_net_bytes();
|
||||
let net_bps = match (prev_net, cur_net) {
|
||||
(Some(p), Some(c)) => c.saturating_sub(p),
|
||||
_ => 0,
|
||||
};
|
||||
prev_net = cur_net;
|
||||
|
||||
let cur_disk = read_disk_bytes();
|
||||
let disk_bps = match (prev_disk, cur_disk) {
|
||||
(Some(p), Some(c)) => c.saturating_sub(p),
|
||||
_ => 0,
|
||||
};
|
||||
prev_disk = cur_disk;
|
||||
|
||||
state.net_bps.store(net_bps, Ordering::Relaxed);
|
||||
state.disk_bps.store(disk_bps, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sum of rx+tx bytes across all non-loopback interfaces, from /proc/net/dev.
|
||||
/// Returns None if the file can't be read/parsed.
|
||||
fn read_net_bytes() -> Option<u64> {
|
||||
let content = std::fs::read_to_string("/proc/net/dev").ok()?;
|
||||
let mut total: u64 = 0;
|
||||
// First two lines are headers.
|
||||
for line in content.lines().skip(2) {
|
||||
let Some((iface, rest)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
if iface.trim() == "lo" {
|
||||
continue;
|
||||
}
|
||||
let fields: Vec<&str> = rest.split_whitespace().collect();
|
||||
// Column 0 = rx bytes, column 8 = tx bytes.
|
||||
if let Some(rx) = fields.first().and_then(|v| v.parse::<u64>().ok()) {
|
||||
total = total.saturating_add(rx);
|
||||
}
|
||||
if let Some(tx) = fields.get(8).and_then(|v| v.parse::<u64>().ok()) {
|
||||
total = total.saturating_add(tx);
|
||||
}
|
||||
}
|
||||
Some(total)
|
||||
}
|
||||
|
||||
/// Sum of sectors read+written across all block devices, ×512, from
|
||||
/// /proc/diskstats. Skips partitions and loop/ram devices to avoid double
|
||||
/// counting. Returns None if the file can't be read/parsed.
|
||||
fn read_disk_bytes() -> Option<u64> {
|
||||
let content = std::fs::read_to_string("/proc/diskstats").ok()?;
|
||||
let mut sectors: u64 = 0;
|
||||
for line in content.lines() {
|
||||
let fields: Vec<&str> = line.split_whitespace().collect();
|
||||
// 0=major 1=minor 2=name ... 5=sectors read ... 9=sectors written.
|
||||
if fields.len() < 10 {
|
||||
continue;
|
||||
}
|
||||
let name = fields[2];
|
||||
if name.starts_with("loop") || name.starts_with("ram") {
|
||||
continue;
|
||||
}
|
||||
let read = fields[5].parse::<u64>().unwrap_or(0);
|
||||
let written = fields[9].parse::<u64>().unwrap_or(0);
|
||||
sectors = sectors.saturating_add(read).saturating_add(written);
|
||||
}
|
||||
// Linux reports diskstats sectors in fixed 512-byte units.
|
||||
Some(sectors.saturating_mul(512))
|
||||
}
|
||||
|
||||
@ -23,12 +23,10 @@ impl AtomicMax {
|
||||
if new <= current {
|
||||
return false;
|
||||
}
|
||||
match self.val.compare_exchange_weak(
|
||||
current,
|
||||
new,
|
||||
Ordering::Release,
|
||||
Ordering::Relaxed,
|
||||
) {
|
||||
match self
|
||||
.val
|
||||
.compare_exchange_weak(current, new, Ordering::Release, Ordering::Relaxed)
|
||||
{
|
||||
Ok(_) => return true,
|
||||
Err(_) => continue,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user