Added password protection

This commit is contained in:
Armin Ronacher 2025-06-16 20:27:07 +02:00
parent f8ac02d5e5
commit f328e2c1cb
5 changed files with 68 additions and 2 deletions

7
tty-fwd/Cargo.lock generated
View file

@ -77,6 +77,12 @@ dependencies = [
"windows-sys 0.59.0",
]
[[package]]
name = "data-encoding"
version = "2.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"
[[package]]
name = "dirs"
version = "5.0.1"
@ -571,6 +577,7 @@ dependencies = [
"argument-parser",
"bytes",
"ctrlc",
"data-encoding",
"dirs",
"http",
"jiff",

View file

@ -29,6 +29,7 @@ regex = "1.10"
dirs = "5.0"
notify = "6.1.1"
ctrlc = "3.4.2"
data-encoding = "2.5"
[target.'cfg(windows)'.dependencies]
windows-sys = { version = "0.59", features = ["Win32_System_Console"] }

View file

@ -127,6 +127,7 @@ When running with `--serve`, the following REST API endpoints are available:
- `--serve`: Start HTTP API server on specified address/port
- `--static-path`: Directory to serve static files from (requires --serve)
- `--cleanup`: Remove exited sessions
- `--password`: Enables an HTTP basic auth password (username is ignored)
## License

View file

@ -1,4 +1,5 @@
use anyhow::Result;
use data_encoding::BASE64;
use notify::{Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher};
use regex::Regex;
use serde::{Deserialize, Serialize};
@ -85,6 +86,33 @@ struct ApiResponse {
session_id: Option<String>,
}
fn check_basic_auth(req: &HttpRequest, expected_password: &str) -> bool {
if let Some(auth_header) = req.headers().get("authorization") {
if let Ok(auth_str) = auth_header.to_str() {
if let Some(credentials) = auth_str.strip_prefix("Basic ") {
if let Ok(decoded_bytes) = BASE64.decode(credentials.as_bytes()) {
if let Ok(decoded_str) = String::from_utf8(decoded_bytes) {
if let Some(colon_pos) = decoded_str.find(':') {
let password = &decoded_str[colon_pos + 1..];
return password == expected_password;
}
}
}
}
}
}
false
}
fn unauthorized_response() -> Response<String> {
Response::builder()
.status(StatusCode::UNAUTHORIZED)
.header("WWW-Authenticate", "Basic realm=\"tty-fwd\"")
.header("Content-Type", "text/plain")
.body("Unauthorized".to_string())
.unwrap()
}
fn get_mime_type(file_path: &Path) -> &'static str {
match file_path.extension().and_then(|ext| ext.to_str()) {
Some("html") | Some("htm") => "text/html",
@ -203,16 +231,32 @@ pub fn start_server(
bind_address: &str,
control_path: PathBuf,
static_path: Option<String>,
password: Option<String>,
) -> Result<()> {
fs::create_dir_all(&control_path)?;
let server = HttpServer::bind(bind_address)
.map_err(|e| anyhow::anyhow!("Failed to bind server: {}", e))?;
println!("HTTP API server listening on {}", bind_address);
// Set up auth if password is provided
let auth_password = if let Some(ref password) = password {
println!(
"HTTP API server listening on {} with Basic Auth enabled (any username)",
bind_address
);
Some(password.clone())
} else {
println!(
"HTTP API server listening on {} with no authentication",
bind_address
);
None
};
for req in server.incoming() {
let control_path = control_path.clone();
let static_path = static_path.clone();
let auth_password = auth_password.clone();
thread::spawn(move || {
let mut req = match req {
@ -229,6 +273,14 @@ pub fn start_server(
println!("{:?} {} (full URI: {})", method, path, full_uri);
// Check authentication if enabled (but skip /api/health)
if let Some(ref expected_password) = auth_password {
if path != "/api/health" && !check_basic_auth(&req, expected_password) {
let _ = req.respond(unauthorized_response());
return;
}
}
// Check for static file serving first
if method == &Method::GET && !path.starts_with("/api/") {
if let Some(ref static_dir) = static_path {

View file

@ -29,6 +29,7 @@ fn main() -> Result<(), anyhow::Error> {
let mut cleanup = false;
let mut serve_address = None::<String>;
let mut static_path = None::<String>;
let mut password = None::<String>;
let mut cmdline = Vec::<OsString>::new();
while let Some(param) = parser.param()? {
@ -82,6 +83,9 @@ fn main() -> Result<(), anyhow::Error> {
p if p.is_long("static-path") => {
static_path = Some(parser.value()?);
}
p if p.is_long("password") => {
password = Some(parser.value()?);
}
p if p.is_pos() => {
cmdline.push(parser.value()?);
}
@ -107,6 +111,7 @@ fn main() -> Result<(), anyhow::Error> {
println!(
" --static-path <path> Path to static files directory for HTTP server"
);
println!(" --password <password> Enable basic auth with random username and specified password");
println!(" --help Show this help message");
return Ok(());
}
@ -171,7 +176,7 @@ fn main() -> Result<(), anyhow::Error> {
std::process::exit(0);
})
.unwrap();
return crate::api_server::start_server(&addr, control_path, static_path);
return crate::api_server::start_server(&addr, control_path, static_path, password);
}
let exit_code = sessions::spawn_command(control_path, session_name, cmdline)?;