mirror of
https://github.com/sol1/rustguac.git
synced 2026-11-01 08:20:19 +00:00
d75446f040
Ad-hoc sessions had their own form on the Sessions page with a subset
of the options a connection entry has. "Connect to a host" now opens
the Connections entry editor in an ad-hoc mode instead, so an ad-hoc
connection has every option an entry has, now and as entries gain more.
The button sits beside the global search box on every page that has
one. It is shown to poweruser and above under the same rules as the
search box, and opens the editor in place on Connections or goes to
/connections.html#adhoc elsewhere. Ad-hoc mode has no name or folder,
Connect instead of Save, and for admins a "Save as entry" action.
POST /api/addressbook/adhoc/connect takes the entry as the editor
builds it. It resolves only the caller's own credential variables,
never lends folder defaults, and goes through apply_adhoc_policy and
the session rate limit like every other ad-hoc route.
POST /api/sessions is unchanged.
session_request_from_entry() replaces the two copies of the entry to
session request conversion, used by the Connections connect endpoint
and /api/connect, so an option added to entries works in all three.
can_connect_adhoc() is the one place that decides who may connect
ad-hoc.
The Sessions ad-hoc form is removed. loadFolders() now runs its
callback, so #open= links work again.
Refs sol1/rustguac#295
(cherry picked from commit dd40bf08ec)
2463 lines
95 KiB
Rust
2463 lines
95 KiB
Rust
mod api;
|
|
mod auth;
|
|
mod browser;
|
|
mod config;
|
|
mod db;
|
|
mod drive;
|
|
mod guacd;
|
|
mod import;
|
|
mod migrate;
|
|
mod oidc;
|
|
mod protocol;
|
|
mod pve;
|
|
mod recording;
|
|
mod recording_mp4;
|
|
mod session;
|
|
mod tunnel;
|
|
mod vault;
|
|
mod vdi;
|
|
mod websocket;
|
|
|
|
use crate::api::{
|
|
AppState, CredentialDefaultScope, DriveConfigured, OidcEnabled, OidcHandle, SiteTitle,
|
|
ThemeData, VaultBackends, VaultCell, VaultConfigured, VaultState,
|
|
};
|
|
use crate::config::Config;
|
|
use crate::db::Db;
|
|
use crate::session::SessionManager;
|
|
use axum::extract::{DefaultBodyLimit, Request};
|
|
use axum::response::Html;
|
|
use axum::response::Response;
|
|
use axum::routing::{delete, get, post, put};
|
|
use axum::{middleware, Extension, Router};
|
|
use clap::{Parser, Subcommand};
|
|
use std::net::SocketAddr;
|
|
use std::sync::Arc;
|
|
use tower::{Layer, ServiceExt};
|
|
use tower_governor::{governor::GovernorConfigBuilder, GovernorLayer};
|
|
use tower_http::services::ServeDir;
|
|
use tracing_subscriber::EnvFilter;
|
|
|
|
#[derive(Parser)]
|
|
#[command(name = "rustguac", about = "Lightweight Guacamole SSH proxy")]
|
|
struct Cli {
|
|
/// Path to TOML config file
|
|
#[arg(short, long)]
|
|
config: Option<String>,
|
|
|
|
#[command(subcommand)]
|
|
command: Option<Command>,
|
|
}
|
|
|
|
#[derive(Subcommand)]
|
|
enum Command {
|
|
/// Run the server (default)
|
|
Serve,
|
|
|
|
/// Create a new admin with an API key
|
|
AddAdmin {
|
|
/// Admin name (unique)
|
|
#[arg(long)]
|
|
name: String,
|
|
/// Comma-separated allowed IP CIDRs (e.g. "10.0.0.0/8,192.168.1.0/24")
|
|
#[arg(long)]
|
|
allowed_ips: Option<String>,
|
|
/// Expiry date in ISO 8601 format (e.g. "2025-12-31T23:59:59Z")
|
|
#[arg(long)]
|
|
expires: Option<String>,
|
|
/// Only create the admin if no admin account exists yet; otherwise
|
|
/// do nothing and exit successfully. For first-run provisioning
|
|
/// (the Docker entrypoint), where whether this is a first run
|
|
/// depends on the database named by db_path, not on any fixed file.
|
|
#[arg(long)]
|
|
if_none: bool,
|
|
},
|
|
|
|
/// List all admin accounts
|
|
ListAdmins,
|
|
|
|
/// Disable an admin account
|
|
DisableAdmin {
|
|
#[arg(long)]
|
|
name: String,
|
|
},
|
|
|
|
/// Enable an admin account
|
|
EnableAdmin {
|
|
#[arg(long)]
|
|
name: String,
|
|
},
|
|
|
|
/// Delete an admin account permanently
|
|
DeleteAdmin {
|
|
#[arg(long)]
|
|
name: String,
|
|
},
|
|
|
|
/// Map an OIDC group to a role (admin, poweruser, operator, viewer)
|
|
MapGroup {
|
|
#[arg(long)]
|
|
group: String,
|
|
#[arg(long)]
|
|
role: String,
|
|
/// Re-map a group that already has a mapping, changing its role.
|
|
/// Without this a group with an existing mapping is left untouched.
|
|
#[arg(long)]
|
|
force: bool,
|
|
},
|
|
|
|
/// Rotate an admin's API key (generates new key, invalidates old)
|
|
RotateKey {
|
|
#[arg(long)]
|
|
name: String,
|
|
},
|
|
|
|
/// Generate a self-signed TLS certificate for development/testing
|
|
GenerateCert {
|
|
/// Hostname for the certificate (e.g. "rustguac.example.com")
|
|
#[arg(long)]
|
|
hostname: String,
|
|
/// Output directory for cert.pem and key.pem
|
|
#[arg(long, default_value = ".")]
|
|
out_dir: String,
|
|
/// Additional Subject Alternative Names (hostnames or IPs). localhost and 127.0.0.1 are always included.
|
|
#[arg(long = "san")]
|
|
extra_sans: Vec<String>,
|
|
},
|
|
|
|
/// List all OIDC users
|
|
ListUsers,
|
|
|
|
/// Add an OIDC user ahead of their first login (required to let anyone
|
|
/// in when `[oidc] auto_create_users = false`)
|
|
AddUser {
|
|
/// User email, exactly as the identity provider sends it
|
|
#[arg(long)]
|
|
email: String,
|
|
/// Role: admin, poweruser, operator, or viewer
|
|
#[arg(long)]
|
|
role: String,
|
|
/// Display name (replaced by the provider's name on login)
|
|
#[arg(long, default_value = "")]
|
|
name: String,
|
|
},
|
|
|
|
/// Forget the identity provider account a user is bound to, so their next
|
|
/// OIDC login binds to whichever account it comes from. For moving to a
|
|
/// different identity provider, where every user's subject changes.
|
|
ResetOidcSubject {
|
|
/// User email
|
|
#[arg(long)]
|
|
email: String,
|
|
},
|
|
|
|
/// Set a user's role
|
|
SetRole {
|
|
/// User email
|
|
#[arg(long)]
|
|
email: String,
|
|
/// Role: admin, poweruser, operator, or viewer
|
|
#[arg(long)]
|
|
role: String,
|
|
},
|
|
|
|
/// Disable an OIDC user
|
|
DisableUser {
|
|
#[arg(long)]
|
|
email: String,
|
|
},
|
|
|
|
/// Delete an OIDC user
|
|
DeleteUser {
|
|
#[arg(long)]
|
|
email: String,
|
|
},
|
|
|
|
/// Import connections from an Apache Guacamole MySQL dump into the Vault address book
|
|
ImportGuacamole {
|
|
/// Path to the mysqldump SQL file
|
|
#[arg(long)]
|
|
file: String,
|
|
/// Target folder in the address book
|
|
#[arg(long, default_value = "imported")]
|
|
folder: String,
|
|
/// Scope: "shared" or "instance"
|
|
#[arg(long, default_value = "shared")]
|
|
scope: String,
|
|
/// OIDC groups allowed to see the imported tree (comma-separated).
|
|
/// Applied to the root folder; subfolders default to inherit, so the
|
|
/// whole tree picks up the same ACL without per-folder writes.
|
|
#[arg(long, value_delimiter = ',')]
|
|
allowed_groups: Vec<String>,
|
|
/// Preview without writing to Vault
|
|
#[arg(long)]
|
|
dry_run: bool,
|
|
/// Rewrite substrings in the credential fields (username, password,
|
|
/// domain, private_key) during import. Repeatable; format FROM=TO.
|
|
/// Maps Apache Guacamole passthrough tokens to rustguac credential
|
|
/// variables, e.g. --map '${GUAC_USERNAME}=$corp_username'
|
|
/// --map '${GUAC_PASSWORD}=$corp_password'
|
|
#[arg(long = "map")]
|
|
map: Vec<String>,
|
|
},
|
|
|
|
/// Copy an address-book scope subtree between configured Vault backends
|
|
/// (for the multi-Vault DR split). Copies entries and every folder's
|
|
/// `.config`; run with --dry-run first.
|
|
VaultMigrate {
|
|
/// Scope to migrate: "shared" or "instance"
|
|
#[arg(long)]
|
|
scope: String,
|
|
/// Source backend: "vault", "vault_shared", or "vault_local"
|
|
#[arg(long)]
|
|
from: String,
|
|
/// Destination backend: "vault", "vault_shared", or "vault_local"
|
|
#[arg(long)]
|
|
to: String,
|
|
/// Also copy ALL per-user credential secrets (users/*). This makes
|
|
/// those credentials shared; normally you toggle per-credential in the
|
|
/// My Credentials UI instead.
|
|
#[arg(long)]
|
|
users: bool,
|
|
/// Overwrite entries that already exist in the destination
|
|
/// (default: skip existing).
|
|
#[arg(long)]
|
|
overwrite: bool,
|
|
/// Preview without writing to the destination
|
|
#[arg(long)]
|
|
dry_run: bool,
|
|
},
|
|
}
|
|
|
|
#[tokio::main]
|
|
async fn main() {
|
|
// Install rustls crypto provider before any TLS usage (reqwest, axum-server, etc.)
|
|
// Required when both ring and aws-lc-rs features are present.
|
|
rustls::crypto::ring::default_provider()
|
|
.install_default()
|
|
.expect("Failed to install rustls crypto provider");
|
|
|
|
let cli = Cli::parse();
|
|
|
|
// Load config
|
|
let config = Config::load(cli.config.as_deref());
|
|
|
|
// Open database
|
|
let database = db::init_db(&config.db_path).expect("Failed to open database");
|
|
|
|
match cli.command {
|
|
None | Some(Command::Serve) => {
|
|
// Only the server talks to the IdP, so only the server insists on
|
|
// an OIDC client secret. Checked here rather than in Config::load
|
|
// so `add-admin` & co. work from a shell where /opt/rustguac/env
|
|
// (systemd's EnvironmentFile) has not been sourced.
|
|
if let Err(msg) = config.validate_oidc_secret() {
|
|
eprintln!("[config] ERROR: {}", msg);
|
|
std::process::exit(1);
|
|
}
|
|
run_server(config, database).await
|
|
}
|
|
Some(Command::AddAdmin {
|
|
name,
|
|
allowed_ips,
|
|
expires,
|
|
if_none,
|
|
}) => {
|
|
if if_none {
|
|
match db::list_admins(&database) {
|
|
Ok(admins) if !admins.is_empty() => {
|
|
println!("Admin accounts already exist; not creating '{}'.", name);
|
|
return;
|
|
}
|
|
Ok(_) => {}
|
|
Err(e) => {
|
|
eprintln!("Error reading admins: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
cmd_add_admin(&database, &name, allowed_ips.as_deref(), expires.as_deref());
|
|
}
|
|
Some(Command::ListAdmins) => cmd_list_admins(&database),
|
|
Some(Command::DisableAdmin { name }) => cmd_disable_admin(&database, &name),
|
|
Some(Command::EnableAdmin { name }) => cmd_enable_admin(&database, &name),
|
|
Some(Command::DeleteAdmin { name }) => cmd_delete_admin(&database, &name),
|
|
Some(Command::MapGroup { group, role, force }) => {
|
|
cmd_map_group(&database, &group, &role, force)
|
|
}
|
|
Some(Command::RotateKey { name }) => cmd_rotate_key(&database, &name),
|
|
Some(Command::GenerateCert {
|
|
hostname,
|
|
out_dir,
|
|
extra_sans,
|
|
}) => {
|
|
cmd_generate_cert(&hostname, &out_dir, &extra_sans);
|
|
}
|
|
Some(Command::ListUsers) => cmd_list_users(&database),
|
|
Some(Command::AddUser { email, role, name }) => {
|
|
cmd_add_user(&database, &email, &role, &name)
|
|
}
|
|
Some(Command::ResetOidcSubject { email }) => cmd_reset_oidc_subject(&database, &email),
|
|
Some(Command::SetRole { email, role }) => cmd_set_role(&database, &email, &role),
|
|
Some(Command::DisableUser { email }) => cmd_disable_user(&database, &email),
|
|
Some(Command::DeleteUser { email }) => cmd_delete_user(&database, &email),
|
|
Some(Command::ImportGuacamole {
|
|
file,
|
|
folder,
|
|
scope,
|
|
allowed_groups,
|
|
dry_run,
|
|
map,
|
|
}) => {
|
|
import::cmd_import_guacamole(
|
|
&config,
|
|
&file,
|
|
&folder,
|
|
&scope,
|
|
&allowed_groups,
|
|
dry_run,
|
|
&map,
|
|
)
|
|
.await;
|
|
}
|
|
Some(Command::VaultMigrate {
|
|
scope,
|
|
from,
|
|
to,
|
|
users,
|
|
overwrite,
|
|
dry_run,
|
|
}) => {
|
|
migrate::cmd_vault_migrate(&config, &scope, &from, &to, users, overwrite, dry_run)
|
|
.await;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Best-effort identity of whoever invoked the CLI, for the audit trail.
|
|
/// `SUDO_USER` is checked first so an action taken through sudo is attributed to
|
|
/// the person who ran it rather than to root.
|
|
fn cli_actor() -> String {
|
|
std::env::var("SUDO_USER")
|
|
.or_else(|_| std::env::var("USER"))
|
|
.or_else(|_| std::env::var("LOGNAME"))
|
|
.unwrap_or_else(|_| "unknown".to_string())
|
|
}
|
|
|
|
/// Record a successful CLI administrative action.
|
|
///
|
|
/// A failure to write the audit row is reported but deliberately does not fail
|
|
/// the command: the action has already been applied, and exiting non-zero here
|
|
/// would misreport it as having not happened.
|
|
fn audit_cli(database: &Db, action: &str, target: &str, details: Option<&str>) {
|
|
if let Err(e) = db::log_cli_event(database, &cli_actor(), action, Some(target), details) {
|
|
eprintln!(
|
|
"Warning: action succeeded but audit log write failed: {}",
|
|
e
|
|
);
|
|
}
|
|
}
|
|
|
|
fn cmd_add_admin(database: &Db, name: &str, allowed_ips: Option<&str>, expires: Option<&str>) {
|
|
match db::add_admin(database, name, allowed_ips, expires) {
|
|
Ok(key) => {
|
|
println!("Admin '{}' created.", name);
|
|
println!("API Key: {}", key);
|
|
println!();
|
|
println!("Store this key securely — it cannot be retrieved again.");
|
|
// Headline only: whether the optional restrictions were applied,
|
|
// never the key itself.
|
|
let details = format!(
|
|
"ip_allowlist={} expiry={}",
|
|
allowed_ips.is_some(),
|
|
expires.is_some()
|
|
);
|
|
audit_cli(database, "add_admin", name, Some(&details));
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error creating admin: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_list_admins(database: &Db) {
|
|
match db::list_admins(database) {
|
|
Ok(admins) => {
|
|
if admins.is_empty() {
|
|
println!("No admins configured.");
|
|
return;
|
|
}
|
|
println!(
|
|
"{:<4} {:<20} {:<10} {:<24} {:<24} Allowed IPs",
|
|
"ID", "Name", "Status", "Expires", "Last Used",
|
|
);
|
|
println!("{}", "-".repeat(100));
|
|
for a in admins {
|
|
let status = if a.disabled { "disabled" } else { "active" };
|
|
let expires = a.expires_at.as_deref().unwrap_or("never");
|
|
let last_used = a.last_used_at.as_deref().unwrap_or("never");
|
|
let ips = a.allowed_ips.as_deref().unwrap_or("any");
|
|
println!(
|
|
"{:<4} {:<20} {:<10} {:<24} {:<24} {}",
|
|
a.id, a.name, status, expires, last_used, ips
|
|
);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error listing admins: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_disable_admin(database: &Db, name: &str) {
|
|
match db::disable_admin(database, name) {
|
|
Ok(true) => {
|
|
println!("Admin '{}' disabled.", name);
|
|
audit_cli(database, "disable_admin", name, None);
|
|
}
|
|
Ok(false) => {
|
|
eprintln!("Admin '{}' not found.", name);
|
|
std::process::exit(1);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_enable_admin(database: &Db, name: &str) {
|
|
match db::enable_admin(database, name) {
|
|
Ok(true) => {
|
|
println!("Admin '{}' enabled.", name);
|
|
audit_cli(database, "enable_admin", name, None);
|
|
}
|
|
Ok(false) => {
|
|
eprintln!("Admin '{}' not found.", name);
|
|
std::process::exit(1);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_delete_admin(database: &Db, name: &str) {
|
|
match db::delete_admin(database, name) {
|
|
Ok(true) => {
|
|
println!("Admin '{}' deleted.", name);
|
|
audit_cli(database, "delete_admin", name, None);
|
|
}
|
|
Ok(false) => {
|
|
eprintln!("Admin '{}' not found.", name);
|
|
std::process::exit(1);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_rotate_key(database: &Db, name: &str) {
|
|
match db::rotate_key(database, name) {
|
|
Ok(Some(key)) => {
|
|
println!("API key rotated for '{}'.", name);
|
|
println!("New API Key: {}", key);
|
|
println!();
|
|
println!("Store this key securely — it cannot be retrieved again.");
|
|
audit_cli(database, "rotate_key", name, None);
|
|
}
|
|
Ok(None) => {
|
|
eprintln!("Admin '{}' not found.", name);
|
|
std::process::exit(1);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_generate_cert(hostname: &str, out_dir: &str, extra_sans: &[String]) {
|
|
use rcgen::{generate_simple_self_signed, CertifiedKey};
|
|
|
|
let mut sans = vec![
|
|
hostname.to_string(),
|
|
"localhost".to_string(),
|
|
"127.0.0.1".to_string(),
|
|
];
|
|
for san in extra_sans {
|
|
if !sans.contains(san) {
|
|
sans.push(san.clone());
|
|
}
|
|
}
|
|
|
|
let CertifiedKey { cert, signing_key } =
|
|
generate_simple_self_signed(sans.clone()).expect("Failed to generate certificate");
|
|
|
|
let cert_path = std::path::Path::new(out_dir).join("cert.pem");
|
|
let key_path = std::path::Path::new(out_dir).join("key.pem");
|
|
|
|
std::fs::write(&cert_path, cert.pem()).expect("Failed to write cert.pem");
|
|
std::fs::write(&key_path, signing_key.serialize_pem()).expect("Failed to write key.pem");
|
|
|
|
println!("Generated self-signed certificate:");
|
|
println!(" Certificate: {}", cert_path.display());
|
|
println!(" Private key: {}", key_path.display());
|
|
println!(" SANs: {}", sans.join(", "));
|
|
println!();
|
|
println!("Add to config.toml:");
|
|
println!(" [tls]");
|
|
println!(" cert_path = \"{}\"", cert_path.display());
|
|
println!(" key_path = \"{}\"", key_path.display());
|
|
}
|
|
|
|
fn cmd_list_users(database: &Db) {
|
|
match db::list_users(database) {
|
|
Ok(users) => {
|
|
if users.is_empty() {
|
|
println!("No OIDC users.");
|
|
return;
|
|
}
|
|
println!(
|
|
"{:<4} {:<30} {:<20} {:<10} {:<10} {:<24}",
|
|
"ID", "Email", "Name", "Role", "Status", "Last Login"
|
|
);
|
|
println!("{}", "-".repeat(100));
|
|
for u in users {
|
|
let status = if u.disabled { "disabled" } else { "active" };
|
|
let last_login = u.last_login_at.as_deref().unwrap_or("never");
|
|
println!(
|
|
"{:<4} {:<30} {:<20} {:<10} {:<10} {:<24}",
|
|
u.id, u.email, u.name, u.role, status, last_login
|
|
);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error listing users: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_add_user(database: &Db, email: &str, role: &str, name: &str) {
|
|
let email = email.trim();
|
|
let name = name.trim();
|
|
if !["admin", "poweruser", "operator", "viewer"].contains(&role) {
|
|
eprintln!("Role must be admin, poweruser, operator, or viewer.");
|
|
std::process::exit(1);
|
|
}
|
|
if let Err(msg) = db::check_user_email(email).and_then(|_| db::check_user_name(name)) {
|
|
eprintln!("Invalid user: {msg}.");
|
|
std::process::exit(1);
|
|
}
|
|
match db::create_user(database, email, name, role) {
|
|
Ok(_) => {
|
|
println!("User '{}' added with role '{}'.", email, role);
|
|
audit_cli(database, "add_user", email, Some(&format!("role={}", role)));
|
|
}
|
|
Err(db::CreateUserError::Exists) => {
|
|
eprintln!(
|
|
"A user with email '{}' already exists (emails match ignoring case).",
|
|
email
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_reset_oidc_subject(database: &Db, email: &str) {
|
|
match db::reset_oidc_subject(database, email.trim()) {
|
|
Ok(Some(stored)) => {
|
|
println!(
|
|
"OIDC subject for '{}' cleared. Their next login binds to the identity provider account it comes from.",
|
|
stored
|
|
);
|
|
audit_cli(database, "reset_oidc_subject", &stored, None);
|
|
}
|
|
Ok(None) => {
|
|
eprintln!("No single user with email '{}'.", email.trim());
|
|
std::process::exit(1);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_set_role(database: &Db, email: &str, role: &str) {
|
|
if !["admin", "poweruser", "operator", "viewer"].contains(&role) {
|
|
eprintln!("Role must be admin, poweruser, operator, or viewer.");
|
|
std::process::exit(1);
|
|
}
|
|
match db::set_user_role(database, email, role) {
|
|
Ok(true) => {
|
|
println!("User '{}' role set to '{}'.", email, role);
|
|
audit_cli(
|
|
database,
|
|
"set_user_role",
|
|
email,
|
|
Some(&format!("role={}", role)),
|
|
);
|
|
}
|
|
Ok(false) => {
|
|
eprintln!("User '{}' not found.", email);
|
|
std::process::exit(1);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_disable_user(database: &Db, email: &str) {
|
|
match db::disable_user(database, email) {
|
|
Ok(true) => {
|
|
println!("User '{}' disabled.", email);
|
|
audit_cli(database, "disable_user", email, None);
|
|
}
|
|
Ok(false) => {
|
|
eprintln!("User '{}' not found.", email);
|
|
std::process::exit(1);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_delete_user(database: &Db, email: &str) {
|
|
match db::delete_user(database, email) {
|
|
Ok(true) => {
|
|
println!("User '{}' deleted.", email);
|
|
audit_cli(database, "delete_user", email, None);
|
|
}
|
|
Ok(false) => {
|
|
eprintln!("User '{}' not found.", email);
|
|
std::process::exit(1);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn cmd_map_group(database: &Db, group: &str, role: &str, force: bool) {
|
|
if !["admin", "poweruser", "operator", "viewer"].contains(&role) {
|
|
eprintln!("Role must be admin, poweruser, operator, or viewer.");
|
|
std::process::exit(1);
|
|
}
|
|
|
|
// `oidc_group` is UNIQUE, so a second mapping for the same group fails on
|
|
// the constraint. Look first so an existing mapping produces an actionable
|
|
// message (and an explicit --force re-map) rather than a raw SQL error.
|
|
// This mirrors the API, which answers 409 rather than 500 on a duplicate.
|
|
let existing = match db::list_group_mappings(database) {
|
|
Ok(mappings) => mappings.into_iter().find(|m| m.oidc_group == group),
|
|
Err(e) => {
|
|
eprintln!("Error: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
};
|
|
|
|
if let Some(current) = existing {
|
|
if current.role == role {
|
|
println!("Group '{}' is already mapped to role '{}'.", group, role);
|
|
return;
|
|
}
|
|
if !force {
|
|
eprintln!(
|
|
"Group '{}' is already mapped to role '{}'. \
|
|
Re-run with --force to change it to '{}'.",
|
|
group, current.role, role
|
|
);
|
|
std::process::exit(1);
|
|
}
|
|
match db::update_group_mapping(database, current.id, group, role) {
|
|
Ok(true) => {
|
|
println!(
|
|
"Group '{}' re-mapped from role '{}' to '{}'.",
|
|
group, current.role, role
|
|
);
|
|
audit_cli(
|
|
database,
|
|
"remap_group",
|
|
group,
|
|
Some(&format!("from={} to={}", current.role, role)),
|
|
);
|
|
}
|
|
Ok(false) => {
|
|
eprintln!("Mapping for group '{}' disappeared before update.", group);
|
|
std::process::exit(1);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
|
|
match db::create_group_mapping(database, group, role) {
|
|
Ok(_) => {
|
|
println!("Group '{}' mapped to role '{}'.", group, role);
|
|
audit_cli(
|
|
database,
|
|
"map_group",
|
|
group,
|
|
Some(&format!("role={}", role)),
|
|
);
|
|
}
|
|
Err(e) => {
|
|
eprintln!("Error: {}", e);
|
|
std::process::exit(1);
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether TLS is enabled (used by security headers middleware).
|
|
#[derive(Clone)]
|
|
struct TlsEnabled(bool);
|
|
|
|
/// Content-Security-Policy sent with every response.
|
|
///
|
|
/// `img-src` must allow `data:` and `blob:`. The Guacamole client draws each
|
|
/// `img` stream by loading a `data:image/...;base64,...` URI into an `Image`
|
|
/// (the `DataURIReader` path in `Display.drawStream`) on browsers without
|
|
/// WebCodecs `ImageDecoder`, which is Safari and Firefox. Without `img-src`
|
|
/// the `default-src 'self'` fallback refuses those loads, the client silently
|
|
/// skips the draw, and SSH/RDP sessions render black with only rect fills
|
|
/// showing (#249). Chromium decodes straight from the stream bytes and never
|
|
/// fetches a URL, so it hid the problem. The remote mouse cursor
|
|
/// (`cursor: url(data:...)`) and blob: image URLs need the same allowance.
|
|
///
|
|
/// No page may be framed (`frame-ancestors 'none'`, `X-Frame-Options: DENY`):
|
|
/// a framed rustguac page, above all a live session, would take the clicks
|
|
/// and keystrokes of whoever is fooled into using it. The one exception is
|
|
/// `FRAMEABLE_CONTENT_SECURITY_POLICY` below.
|
|
const CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \
|
|
script-src 'self' 'unsafe-inline'; \
|
|
style-src 'self' 'unsafe-inline'; \
|
|
connect-src 'self' wss: ws:; \
|
|
img-src 'self' data: blob:; \
|
|
frame-ancestors 'none'";
|
|
|
|
/// The policy for the one page that may be framed, and only by our own
|
|
/// origin: the session client of a session opened from an entry with
|
|
/// fullscreen-on-connect, which the Connections page loads in a same-origin
|
|
/// iframe so the tab can go fullscreen on the Connect click (#257). See
|
|
/// `ClientFrameable` and docs/security.md.
|
|
const FRAMEABLE_CONTENT_SECURITY_POLICY: &str = "default-src 'self'; \
|
|
script-src 'self' 'unsafe-inline'; \
|
|
style-src 'self' 'unsafe-inline'; \
|
|
connect-src 'self' wss: ws:; \
|
|
img-src 'self' data: blob:; \
|
|
frame-ancestors 'self'";
|
|
|
|
/// Response marker set by `serve_client_page` when that session's client may
|
|
/// be framed by our own origin; `security_headers` then sends the framing
|
|
/// headers that allow it instead of the deny-all defaults.
|
|
#[derive(Clone, Copy)]
|
|
struct ClientFrameable;
|
|
|
|
async fn security_headers(
|
|
tls: Extension<TlsEnabled>,
|
|
request: Request,
|
|
next: middleware::Next,
|
|
) -> Response {
|
|
let mut response = next.run(request).await;
|
|
let frameable = response.extensions().get::<ClientFrameable>().is_some();
|
|
let (frame_options, csp) = if frameable {
|
|
("SAMEORIGIN", FRAMEABLE_CONTENT_SECURITY_POLICY)
|
|
} else {
|
|
("DENY", CONTENT_SECURITY_POLICY)
|
|
};
|
|
let headers = response.headers_mut();
|
|
headers.insert("X-Content-Type-Options", "nosniff".parse().unwrap());
|
|
headers.insert("X-Frame-Options", frame_options.parse().unwrap());
|
|
headers.insert(
|
|
"Referrer-Policy",
|
|
"strict-origin-when-cross-origin".parse().unwrap(),
|
|
);
|
|
headers.insert(
|
|
"Permissions-Policy",
|
|
"camera=(), microphone=(), geolocation=()".parse().unwrap(),
|
|
);
|
|
headers.insert("Content-Security-Policy", csp.parse().unwrap());
|
|
if tls.0 .0 {
|
|
headers.insert(
|
|
"Strict-Transport-Security",
|
|
"max-age=31536000; includeSubDomains".parse().unwrap(),
|
|
);
|
|
}
|
|
response
|
|
}
|
|
|
|
/// Connect a single Vault backend into `cell`. On a failed initial connect,
|
|
/// spawns a background 30s retry loop; the cell stays `None` (and that scope's
|
|
/// address book stays unavailable) until a connect succeeds. `luks_drive` is
|
|
/// `Some` only for the default backend, so the LUKS volume mounts as soon as
|
|
/// that backend comes up on a retry (the initial-boot mount happens in the
|
|
/// drive-init block once the awaited connect below has populated the cell).
|
|
async fn connect_vault_backend(
|
|
label: &'static str,
|
|
cell: VaultCell,
|
|
config: crate::config::VaultConfig,
|
|
secret_id: String,
|
|
luks_drive: Option<crate::config::DriveConfig>,
|
|
) {
|
|
match vault::VaultClient::new(&config, &secret_id).await {
|
|
Ok(client) => {
|
|
let client = Arc::new(client);
|
|
client.spawn_renewal_task();
|
|
tracing::info!("Vault backend '{}' initialized: {}", label, config.addr);
|
|
*cell.write().await = Some(client);
|
|
}
|
|
Err(e) => {
|
|
tracing::error!(
|
|
"Vault backend '{}' connect to {} failed: {} \
|
|
— that scope's address book is unavailable; retrying every 30s",
|
|
label,
|
|
config.addr,
|
|
e
|
|
);
|
|
tokio::spawn(async move {
|
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(30));
|
|
interval.tick().await; // skip immediate tick
|
|
loop {
|
|
interval.tick().await;
|
|
tracing::debug!(
|
|
"Retrying Vault backend '{}' connect to {}...",
|
|
label,
|
|
config.addr
|
|
);
|
|
match vault::VaultClient::new(&config, &secret_id).await {
|
|
Ok(client) => {
|
|
let client = Arc::new(client);
|
|
client.spawn_renewal_task();
|
|
tracing::info!(
|
|
"Vault backend '{}' connected (retry succeeded): {}",
|
|
label,
|
|
config.addr
|
|
);
|
|
*cell.write().await = Some(client.clone());
|
|
|
|
// Mount LUKS now that the default backend is available.
|
|
if let Some(ref dc) = luks_drive {
|
|
if dc.enabled && drive::luks_configured(dc) {
|
|
match drive::mount_luks(dc, &client).await {
|
|
Ok(_) => {
|
|
tracing::info!("LUKS drive volume mounted (deferred)")
|
|
}
|
|
Err(e) => tracing::error!(
|
|
"Failed to mount LUKS drive volume: {}",
|
|
e
|
|
),
|
|
}
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
Err(e) => tracing::warn!(
|
|
"Vault backend '{}' retry failed: {} — will retry in 30s",
|
|
label,
|
|
e
|
|
),
|
|
}
|
|
}
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn run_server(config: Config, database: Db) {
|
|
// Initialize logging
|
|
tracing_subscriber::fmt()
|
|
.with_env_filter(
|
|
EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")),
|
|
)
|
|
.init();
|
|
|
|
let listen_addr = config.listen_addr.clone();
|
|
let static_path = config.static_path.clone();
|
|
let tls_config = config.tls.clone();
|
|
|
|
// Initialize OIDC if configured
|
|
let oidc_state = if let Some(ref oidc_config) = config.oidc {
|
|
match oidc::init_oidc(oidc_config, config.auth_session_ttl_secs).await {
|
|
Ok(state) => {
|
|
tracing::info!("OIDC configured with issuer: {}", oidc_config.issuer_url);
|
|
Some(state)
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("Failed to initialize OIDC: {}", e);
|
|
tracing::warn!("Continuing without OIDC — only API key auth will work");
|
|
None
|
|
}
|
|
}
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// Initialize Vault backend(s) if configured.
|
|
//
|
|
// `[vault]` is the default/primary backend and the home of unscoped secrets
|
|
// (the LUKS key). Optional `[vault_shared]` / `[vault_local]` route the
|
|
// shared / instance address-book scopes to dedicated Vaults so one being
|
|
// down cannot take the others with it. Each backend gets its own connection
|
|
// cell, background retry, and token renewal. A bare `[vault]` behaves
|
|
// exactly as a single-Vault deployment: shared and local alias the default
|
|
// cell, so every scope resolves to the one connection.
|
|
let default_cell: VaultCell = Arc::new(tokio::sync::RwLock::new(None));
|
|
let mut shared_cell = default_cell.clone();
|
|
let mut local_cell = default_cell.clone();
|
|
|
|
if let Some(ref vault_config) = config.vault {
|
|
match vault_config.login_secret("VAULT_SECRET_ID") {
|
|
Ok(sid) => {
|
|
connect_vault_backend(
|
|
"default",
|
|
default_cell.clone(),
|
|
vault_config.clone(),
|
|
sid,
|
|
config.drive.clone(),
|
|
)
|
|
.await;
|
|
}
|
|
Err(e) => {
|
|
tracing::error!("[vault]: {e}");
|
|
tracing::error!("Address book and drive features will be unavailable");
|
|
}
|
|
}
|
|
}
|
|
|
|
if let Some(ref vc) = config.vault_shared {
|
|
match vc.login_secret("VAULT_SHARED_SECRET_ID") {
|
|
Ok(sid) => {
|
|
let cell: VaultCell = Arc::new(tokio::sync::RwLock::new(None));
|
|
connect_vault_backend("shared", cell.clone(), vc.clone(), sid, None).await;
|
|
shared_cell = cell;
|
|
}
|
|
Err(e) => tracing::error!("[vault_shared]: {e}; shared-scope connections unavailable"),
|
|
}
|
|
}
|
|
|
|
if let Some(ref vc) = config.vault_local {
|
|
match vc.login_secret("VAULT_LOCAL_SECRET_ID") {
|
|
Ok(sid) => {
|
|
let cell: VaultCell = Arc::new(tokio::sync::RwLock::new(None));
|
|
connect_vault_backend("local", cell.clone(), vc.clone(), sid, None).await;
|
|
local_cell = cell;
|
|
}
|
|
Err(e) => tracing::error!("[vault_local]: {e}; instance-scope connections unavailable"),
|
|
}
|
|
}
|
|
|
|
// Initialize drive / LUKS if configured (and Vault is already available)
|
|
if let Some(ref drive_config) = config.drive {
|
|
if drive_config.enabled {
|
|
// Mount LUKS volume if configured and Vault is available now
|
|
if drive::luks_configured(drive_config) {
|
|
let vc = default_cell.read().await;
|
|
if let Some(ref client) = *vc {
|
|
match drive::mount_luks(drive_config, client).await {
|
|
Ok(_) => tracing::info!("LUKS drive volume mounted"),
|
|
Err(e) => {
|
|
tracing::error!("Failed to mount LUKS drive volume: {}", e);
|
|
}
|
|
}
|
|
} else {
|
|
tracing::warn!("LUKS configured but Vault not yet available — will mount when Vault connects");
|
|
}
|
|
}
|
|
// Ensure base drive directory exists
|
|
if let Err(e) = drive::ensure_base_dir(drive_config) {
|
|
tracing::warn!("Failed to create drive base directory: {}", e);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Assemble the shared VaultBackends state. `shared`/`local` alias the
|
|
// default cell unless a dedicated backend was configured above.
|
|
let vault_client: VaultState = Arc::new(VaultBackends {
|
|
default: default_cell,
|
|
shared: shared_cell,
|
|
local: local_cell,
|
|
});
|
|
|
|
let oidc_enabled = OidcEnabled(oidc_state.is_some());
|
|
let vault_configured = VaultConfigured(config.vault.is_some());
|
|
let ab_changes = api::AddressBookChanges::default();
|
|
let credential_default_scope =
|
|
CredentialDefaultScope(config.user_credentials_default_scope.clone());
|
|
let drive_configured = DriveConfigured(config.drive.is_some());
|
|
let site_title = SiteTitle(config.site_title.clone());
|
|
let theme_data = {
|
|
// Load built-in themes merged with any user themes from
|
|
// <static_path>/themes/*.toml (see config::load_themes). When [theme]
|
|
// is absent, resolve_with defaults preset to "aurora" — same outcome
|
|
// as "[theme]\n" (empty section).
|
|
let themes = crate::config::load_themes(&static_path);
|
|
let (admin_preset, admin_colors) = config
|
|
.theme
|
|
.clone()
|
|
.unwrap_or_default()
|
|
.resolve_with(&themes);
|
|
let logo_url = config.theme.as_ref().and_then(|t| t.logo_url.clone());
|
|
let presets: std::collections::HashMap<String, crate::config::ThemeColors> =
|
|
themes.into_iter().collect();
|
|
ThemeData {
|
|
admin_preset,
|
|
admin_colors,
|
|
logo_url,
|
|
presets,
|
|
}
|
|
};
|
|
let trusted_proxies = auth::TrustedProxies(config.trusted_proxies.clone());
|
|
|
|
// Pre-process HTML pages with branding (site title, logo) for flash-free rendering
|
|
let branded_pages = {
|
|
let logo = config.theme.as_ref().and_then(|t| t.logo_url.as_deref());
|
|
let title = &config.site_title;
|
|
let mut pages = std::collections::HashMap::new();
|
|
// One hash per distinct asset, not per reference: every page links
|
|
// the same stylesheet and client.html alone carries 36 scripts.
|
|
let mut hashes = std::collections::HashMap::new();
|
|
let version_assets_enabled = std::env::var_os("RUSTGUAC_NO_ASSET_VERSIONING").is_none();
|
|
let mut brand = |html: &str| {
|
|
let html = rewrite_branding(html, title, logo);
|
|
if version_assets_enabled {
|
|
version_assets(&html, &static_path, &mut hashes)
|
|
} else {
|
|
html
|
|
}
|
|
};
|
|
// Embedded client.html
|
|
pages.insert(
|
|
"client.html".to_string(),
|
|
BrandedPage::new(brand(include_str!("../static/client.html"))),
|
|
);
|
|
// Disk-served HTML pages
|
|
for name in &[
|
|
"index.html",
|
|
"connections.html",
|
|
"sessions.html",
|
|
"recordings.html",
|
|
"reports.html",
|
|
"admin.html",
|
|
"tokens.html",
|
|
"docs.html",
|
|
] {
|
|
let path = std::path::Path::new(&static_path).join(name);
|
|
if let Ok(html) = std::fs::read_to_string(&path) {
|
|
pages.insert(name.to_string(), BrandedPage::new(brand(&html)));
|
|
}
|
|
}
|
|
let versioned = hashes.values().filter(|h| h.is_some()).count();
|
|
if version_assets_enabled {
|
|
tracing::info!("Content-hashed {} static asset URLs", versioned);
|
|
} else {
|
|
tracing::info!("Asset versioning disabled (RUSTGUAC_NO_ASSET_VERSIONING)");
|
|
}
|
|
Arc::new(pages)
|
|
};
|
|
|
|
// Periodically clean up expired auth sessions from the database
|
|
let history_retention_days = config.session_history_retention_days;
|
|
let cleanup_db = database.clone();
|
|
tokio::spawn(async move {
|
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600));
|
|
interval.tick().await; // first tick is immediate, skip it
|
|
loop {
|
|
interval.tick().await;
|
|
match db::cleanup_expired_sessions(&cleanup_db) {
|
|
Ok(n) if n > 0 => tracing::info!("Cleaned up {} expired auth sessions", n),
|
|
Err(e) => tracing::warn!("Failed to clean up expired sessions: {}", e),
|
|
_ => {}
|
|
}
|
|
match db::cleanup_expired_user_tokens(&cleanup_db) {
|
|
Ok(n) if n > 0 => tracing::info!("Cleaned up {} expired user API tokens", n),
|
|
Err(e) => tracing::warn!("Failed to clean up expired tokens: {}", e),
|
|
_ => {}
|
|
}
|
|
match db::cleanup_old_audit_log(&cleanup_db, 90) {
|
|
Ok(n) if n > 0 => tracing::info!("Cleaned up {} old audit log entries", n),
|
|
Err(e) => tracing::warn!("Failed to clean up audit log: {}", e),
|
|
_ => {}
|
|
}
|
|
match db::cleanup_session_history(&cleanup_db, history_retention_days) {
|
|
Ok(n) if n > 0 => tracing::info!("Cleaned up {} old session history entries", n),
|
|
Err(e) => tracing::warn!("Failed to clean up session history: {}", e),
|
|
_ => {}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Log session max duration setting
|
|
let max_dur_hours = config.session_max_duration_secs as f64 / 3600.0;
|
|
tracing::info!(
|
|
"Session max duration: {:.1}h ({}s)",
|
|
max_dur_hours,
|
|
config.session_max_duration_secs
|
|
);
|
|
|
|
// Store drive config for shutdown cleanup (before config is moved)
|
|
let shutdown_drive_config = config.drive.clone();
|
|
|
|
// Build TLS connector for guacd if configured
|
|
let guacd_tls = build_guacd_tls(&config);
|
|
let rate_limit_enabled = config.rate_limit;
|
|
let trusted_proxy_cidrs = config.trusted_proxies.clone();
|
|
|
|
// Create session manager
|
|
let manager: AppState = Arc::new(SessionManager::new_with_db(
|
|
config,
|
|
guacd_tls,
|
|
database.clone(),
|
|
));
|
|
|
|
// Spawn background task to reap sessions that exceed max duration
|
|
{
|
|
let reaper_manager = manager.clone();
|
|
let check_interval = std::cmp::max(manager.session_max_duration_secs() / 4, 60);
|
|
tokio::spawn(async move {
|
|
let mut interval =
|
|
tokio::time::interval(std::time::Duration::from_secs(check_interval));
|
|
interval.tick().await; // skip immediate first tick
|
|
loop {
|
|
interval.tick().await;
|
|
let reaped = reaper_manager.reap_expired_sessions().await;
|
|
if reaped > 0 {
|
|
tracing::info!("Reaped {} expired sessions", reaped);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Spawn background task to clean completed sessions from memory
|
|
{
|
|
let cleanup_manager = manager.clone();
|
|
let cleanup_interval =
|
|
std::cmp::max(cleanup_manager.config().session_cleanup_delay_secs / 2, 30);
|
|
tokio::spawn(async move {
|
|
let mut interval =
|
|
tokio::time::interval(std::time::Duration::from_secs(cleanup_interval));
|
|
interval.tick().await; // skip immediate first tick
|
|
loop {
|
|
interval.tick().await;
|
|
let cleaned = cleanup_manager.reap_completed_sessions().await;
|
|
if cleaned > 0 {
|
|
tracing::debug!("Cleaned up {} completed sessions from memory", cleaned);
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
// Spawn VDI container reaper (cleans up idle containers)
|
|
if let Some(ref vdi_cfg) = manager.config().vdi {
|
|
if vdi_cfg.enabled {
|
|
let default_idle_timeout =
|
|
std::time::Duration::from_secs(vdi_cfg.idle_timeout_mins * 60);
|
|
let reaper_manager = manager.clone();
|
|
tokio::spawn(async move {
|
|
// Track last-seen-active times for containers
|
|
let mut last_active: std::collections::HashMap<String, tokio::time::Instant> =
|
|
std::collections::HashMap::new();
|
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(60));
|
|
interval.tick().await; // skip immediate first tick
|
|
loop {
|
|
interval.tick().await;
|
|
let Some(vdi) = reaper_manager.vdi_driver() else {
|
|
continue;
|
|
};
|
|
let containers = match vdi.list_managed_containers_detail().await {
|
|
Ok(c) => c,
|
|
Err(e) => {
|
|
tracing::warn!("VDI reaper: failed to list containers: {}", e);
|
|
continue;
|
|
}
|
|
};
|
|
|
|
let now = tokio::time::Instant::now();
|
|
let cids: Vec<String> =
|
|
containers.iter().map(|c| c.container_id.clone()).collect();
|
|
for c in &containers {
|
|
if reaper_manager.has_active_vdi_session(&c.container_id).await {
|
|
last_active.insert(c.container_id.clone(), now);
|
|
} else if let Some(&last) = last_active.get(&c.container_id) {
|
|
// Per-container timeout from label, or global default
|
|
let timeout = c
|
|
.idle_timeout_mins
|
|
.map(|m| std::time::Duration::from_secs(m * 60))
|
|
.unwrap_or(default_idle_timeout);
|
|
if now.duration_since(last) > timeout {
|
|
tracing::info!(
|
|
container = %c.container_name,
|
|
"VDI reaper: removing idle container"
|
|
);
|
|
if let Err(e) = vdi.stop_container(&c.container_id).await {
|
|
tracing::warn!(
|
|
container = %c.container_name,
|
|
"VDI reaper: failed to remove container: {}", e
|
|
);
|
|
}
|
|
last_active.remove(&c.container_id);
|
|
// Clean up VDI thumbnail
|
|
let vdi_thumb =
|
|
reaper_manager.vdi_thumbnail_path(&c.container_name);
|
|
let _ = tokio::fs::remove_file(&vdi_thumb).await;
|
|
}
|
|
} else {
|
|
last_active.insert(c.container_id.clone(), now);
|
|
}
|
|
}
|
|
last_active.retain(|cid, _| cids.contains(cid));
|
|
}
|
|
});
|
|
tracing::info!(
|
|
idle_timeout_mins = vdi_cfg.idle_timeout_mins,
|
|
"VDI container reaper started"
|
|
);
|
|
}
|
|
}
|
|
|
|
// MP4 downloads of recordings (#241): guacenc on request, cached.
|
|
let mp4_converter = Arc::new(recording_mp4::Mp4Converter::new(
|
|
&manager.recording_config(),
|
|
manager.recording_path(),
|
|
));
|
|
{
|
|
// Clear out what an earlier run left (work directories, expired and
|
|
// orphaned MP4s) now, then every hour.
|
|
let conv = mp4_converter.clone();
|
|
tokio::spawn(async move {
|
|
let mut interval = tokio::time::interval(std::time::Duration::from_secs(3600));
|
|
loop {
|
|
interval.tick().await;
|
|
let c = conv.clone();
|
|
let _ = tokio::task::spawn_blocking(move || c.purge(false)).await;
|
|
}
|
|
});
|
|
}
|
|
|
|
// Spawn recording rotation background task
|
|
{
|
|
let rec_config = manager.recording_config();
|
|
if rec_config.max_disk_percent > 0 || rec_config.max_recordings > 0 {
|
|
let interval_secs = rec_config.rotation_interval_secs.max(30);
|
|
tracing::info!(
|
|
"Recording rotation enabled (max_disk={}%, max_count={}, interval={}s)",
|
|
rec_config.max_disk_percent,
|
|
rec_config.max_recordings,
|
|
interval_secs
|
|
);
|
|
let rot_manager = manager.clone();
|
|
let rot_conv = mp4_converter.clone();
|
|
tokio::spawn(async move {
|
|
let mut interval =
|
|
tokio::time::interval(std::time::Duration::from_secs(interval_secs));
|
|
interval.tick().await; // skip immediate first tick
|
|
loop {
|
|
interval.tick().await;
|
|
let cfg = rec_config.clone();
|
|
// Snapshot recordings of still-live sessions so rotation
|
|
// never deletes an in-progress (open) recording.
|
|
let active = rot_manager.active_recording_paths().await;
|
|
let conv = rot_conv.clone();
|
|
let _ = tokio::task::spawn_blocking(move || {
|
|
// Converted MP4s are copies, so they go before any
|
|
// recording does when the disk is too full.
|
|
if cfg.max_disk_percent > 0
|
|
&& recording::disk_usage_percent(&cfg.path)
|
|
.is_ok_and(|p| p > cfg.max_disk_percent as f64)
|
|
{
|
|
conv.purge(true);
|
|
}
|
|
recording::rotate(&cfg, &active)
|
|
})
|
|
.await;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// Rate limiting (disabled by default — handle upstream in reverse proxy)
|
|
if rate_limit_enabled {
|
|
tracing::info!("API rate limiting enabled");
|
|
}
|
|
// Every limiter keys on the client address resolved through
|
|
// trusted_proxies, never on forwarded headers from arbitrary peers.
|
|
let rate_key = auth::ClientIpKeyExtractor::new(&trusted_proxy_cidrs);
|
|
for cidr in &trusted_proxy_cidrs {
|
|
if cidr.parse::<ipnetwork::IpNetwork>().is_err() {
|
|
tracing::warn!(entry = %cidr, "trusted_proxies entry is not a valid IP or CIDR and is ignored");
|
|
}
|
|
}
|
|
|
|
// WebSocket ticket store (single-use tokens to keep API keys out of WS URLs)
|
|
let ws_ticket_store = auth::WsTicketStore::new();
|
|
|
|
// Session creation routes (rate-limited only when enabled). Ad-hoc
|
|
// connections from the Connections page create sessions too, so they
|
|
// share the limit.
|
|
let mut session_create_route = Router::new()
|
|
.route("/api/sessions", post(api::create_session))
|
|
.route(
|
|
"/api/addressbook/adhoc/connect",
|
|
post(api::ab_adhoc_connect),
|
|
)
|
|
.with_state(manager.clone());
|
|
if rate_limit_enabled {
|
|
let conf = GovernorConfigBuilder::default()
|
|
.per_second(2)
|
|
.burst_size(10)
|
|
.key_extractor(rate_key.clone())
|
|
.finish()
|
|
.expect("Failed to build session creation rate limit config");
|
|
session_create_route = session_create_route.layer(GovernorLayer::new(conf));
|
|
}
|
|
|
|
// API routes that require authentication
|
|
let mut api_routes = Router::new()
|
|
.route("/api/sessions", get(api::list_sessions))
|
|
.route("/api/sessions/{id}", get(api::get_session))
|
|
.route("/api/sessions/{id}", delete(api::delete_session))
|
|
.route("/api/vdi/containers", get(api::list_vdi_containers))
|
|
.route(
|
|
"/api/sessions/{id}/thumbnail",
|
|
put(api::put_session_thumbnail).get(api::get_session_thumbnail),
|
|
)
|
|
.route("/api/sessions/{id}/shadow", post(api::shadow_session))
|
|
.route(
|
|
"/api/vdi/containers/{name}/thumbnail",
|
|
get(api::get_vdi_container_thumbnail),
|
|
)
|
|
.route("/api/recordings", get(api::list_recordings))
|
|
.route("/api/recordings/{name}", get(api::serve_recording))
|
|
.route("/api/recordings/{name}", delete(api::delete_recording))
|
|
.route(
|
|
"/api/recordings/{name}/mp4",
|
|
post(api::start_recording_mp4).get(api::serve_recording_mp4),
|
|
)
|
|
.route(
|
|
"/api/recordings/{name}/mp4/status",
|
|
get(api::recording_mp4_status),
|
|
)
|
|
// #159: typescript register is list-only by design (no serve/delete).
|
|
.route("/api/typescripts", get(api::list_typescripts))
|
|
.route("/api/reports/sessions", get(api::report_sessions))
|
|
.route("/api/reports/sessions/csv", get(api::report_sessions_csv))
|
|
.route(
|
|
"/api/reports/top-connections",
|
|
get(api::report_top_connections),
|
|
)
|
|
.route("/api/reports/top-users", get(api::report_top_users))
|
|
.route("/api/reports/summary", get(api::report_summary))
|
|
.route("/api/system/status", get(api::system_status))
|
|
.route("/api/users", get(api::list_users).post(api::create_user))
|
|
.route("/api/users/{email}/role", put(api::set_user_role))
|
|
.route(
|
|
"/api/users/{email}/sessions",
|
|
delete(api::delete_user_sessions),
|
|
)
|
|
.route("/api/users/{email}", delete(api::delete_user))
|
|
.route("/api/users/{email}/disable", post(api::disable_user))
|
|
.route("/api/users/{email}/enable", post(api::enable_user))
|
|
.route("/api/admin/group-mappings", get(api::list_group_mappings))
|
|
.route("/api/admin/group-mappings", post(api::create_group_mapping))
|
|
.route("/api/auth/known-groups", get(api::list_known_groups))
|
|
.route(
|
|
"/api/admin/group-mappings/{id}",
|
|
put(api::update_group_mapping),
|
|
)
|
|
.route(
|
|
"/api/admin/group-mappings/{id}",
|
|
delete(api::delete_group_mapping),
|
|
)
|
|
.route("/api/me", get(api::me))
|
|
// User API token self-service
|
|
.route("/api/me/tokens", get(api::list_my_tokens))
|
|
.route("/api/me/tokens", post(api::create_my_token))
|
|
.route("/api/me/tokens/{id}", delete(api::revoke_my_token))
|
|
// User credential variables
|
|
.route("/api/me/credentials", get(api::get_my_credentials))
|
|
.route("/api/me/credentials", put(api::put_my_credentials))
|
|
.route(
|
|
"/api/credential-variables",
|
|
get(api::list_credential_variables),
|
|
)
|
|
// Admin token management
|
|
.route("/api/admin/user-tokens", get(api::admin_list_user_tokens))
|
|
.route("/api/admin/user-tokens", post(api::admin_create_user_token))
|
|
.route(
|
|
"/api/admin/user-tokens/{id}",
|
|
delete(api::admin_revoke_user_token),
|
|
)
|
|
.route("/api/admin/token-audit", get(api::admin_token_audit))
|
|
.route(
|
|
"/api/admin/addressbook-audit",
|
|
get(api::admin_addressbook_audit),
|
|
)
|
|
// Login scripts listing
|
|
.route("/api/login-scripts", get(api::list_login_scripts))
|
|
.route("/api/ws-ticket", post(api::create_ws_ticket))
|
|
// Address book routes
|
|
.route("/api/addressbook", get(api::ab_list_all))
|
|
.route("/api/addressbook/version", get(api::ab_version))
|
|
.route("/api/addressbook/search-index", get(api::ab_search_index))
|
|
.route("/api/addressbook/folders", get(api::ab_list_folders))
|
|
.route("/api/addressbook/folders", post(api::ab_create_folder))
|
|
.route("/api/addressbook/folder-paths", get(api::ab_folder_paths))
|
|
// Up to 1000 entries is ~100KB of JSON, over the global 64KB body
|
|
// cap. Raised for this route alone; it is admin-only and the auth
|
|
// middleware rejects a request before its body is read.
|
|
.route(
|
|
"/api/addressbook/bulk",
|
|
post(api::ab_bulk).layer(DefaultBodyLimit::max(256 * 1024)),
|
|
)
|
|
.route(
|
|
"/api/addressbook/folders/{scope}/{folder}",
|
|
put(api::ab_update_folder),
|
|
)
|
|
.route(
|
|
"/api/addressbook/folders/{scope}/{folder}",
|
|
delete(api::ab_delete_folder),
|
|
)
|
|
.route(
|
|
"/api/addressbook/folders/{scope}/{folder}/config",
|
|
get(api::ab_get_folder_config),
|
|
)
|
|
.route(
|
|
"/api/addressbook/folders/{scope}/{folder}/subfolders",
|
|
get(api::ab_list_subfolders),
|
|
)
|
|
.route(
|
|
"/api/addressbook/folders/{scope}/{folder}/defaults",
|
|
get(api::ab_get_folder_defaults),
|
|
)
|
|
.route(
|
|
"/api/addressbook/folders/{scope}/{folder}/defaults",
|
|
put(api::ab_put_folder_defaults),
|
|
)
|
|
.route(
|
|
"/api/addressbook/folders/{scope}/{folder}/defaults",
|
|
delete(api::ab_delete_folder_defaults),
|
|
)
|
|
.route(
|
|
"/api/addressbook/folders/{scope}/{folder}/entries",
|
|
get(api::ab_list_entries),
|
|
)
|
|
.route(
|
|
"/api/addressbook/folders/{scope}/{folder}/entries",
|
|
post(api::ab_create_entry),
|
|
)
|
|
.route(
|
|
"/api/addressbook/folders/{scope}/{folder}/entries/{entry}",
|
|
put(api::ab_update_entry),
|
|
)
|
|
.route(
|
|
"/api/addressbook/folders/{scope}/{folder}/entries/{entry}",
|
|
delete(api::ab_delete_entry),
|
|
)
|
|
.route(
|
|
"/api/addressbook/folders/{scope}/{folder}/entries/{entry}/move",
|
|
post(api::ab_move_entry),
|
|
)
|
|
.route(
|
|
"/api/addressbook/folders/{scope}/{folder}/entries/{entry}/copy",
|
|
post(api::ab_copy_entry),
|
|
)
|
|
.route(
|
|
"/api/addressbook/folders/{scope}/{folder}/entries/{entry}/connect",
|
|
post(api::ab_connect_entry),
|
|
)
|
|
.route("/api/ssh/probe-host-key", post(api::ssh_probe_host_key))
|
|
.merge(session_create_route)
|
|
.with_state(manager.clone())
|
|
.layer(middleware::from_fn(auth::require_auth))
|
|
// Added after require_auth, so it runs outside it and also sees
|
|
// refused requests. Those fail, and only successful changes (or a
|
|
// failed one whose handler reports what it changed anyway) are
|
|
// logged. The log itself is layered below so this sees it.
|
|
.layer(middleware::from_fn(api::ab_change_track))
|
|
.layer(Extension(ws_ticket_store.clone()))
|
|
.layer(Extension(vault_client.clone()))
|
|
.layer(Extension(vault_configured.clone()))
|
|
.layer(Extension(credential_default_scope.clone()))
|
|
.layer(Extension(database.clone()))
|
|
.layer(Extension(ab_changes.clone()))
|
|
.layer(Extension(mp4_converter.clone()));
|
|
// Applied OUTSIDE require_auth so requests with bad credentials are
|
|
// counted too: a flood of bogus keys is throttled before each one
|
|
// queues on the database lock to be looked up.
|
|
if rate_limit_enabled {
|
|
let conf = GovernorConfigBuilder::default()
|
|
.per_second(20)
|
|
.burst_size(100)
|
|
.key_extractor(rate_key.clone())
|
|
.finish()
|
|
.expect("Failed to build API rate limit config");
|
|
api_routes = api_routes.layer(GovernorLayer::new(conf));
|
|
}
|
|
|
|
// WebSocket route with optional auth
|
|
let mut ws_route = Router::new()
|
|
.route("/ws/{session_id}", get(websocket::ws_handler))
|
|
.with_state(manager.clone())
|
|
.layer(middleware::from_fn(auth::optional_auth))
|
|
.layer(Extension(ws_ticket_store.clone()))
|
|
.layer(Extension(database.clone()));
|
|
// Outside optional_auth, as for the API routes above.
|
|
if rate_limit_enabled {
|
|
let conf = GovernorConfigBuilder::default()
|
|
.per_second(5)
|
|
.burst_size(50)
|
|
.key_extractor(rate_key.clone())
|
|
.finish()
|
|
.expect("Failed to build WebSocket rate limit config");
|
|
ws_route = ws_route.layer(GovernorLayer::new(conf));
|
|
}
|
|
|
|
// Quick-connect route with optional auth (handles its own redirect-to-login)
|
|
let connect_route = Router::new()
|
|
.route("/api/connect", get(api::quick_connect))
|
|
.with_state(manager.clone())
|
|
.layer(middleware::from_fn(auth::optional_auth))
|
|
.layer(Extension(vault_client.clone()))
|
|
.layer(Extension(oidc_enabled.clone()))
|
|
.layer(Extension(database.clone()));
|
|
|
|
// Unauthenticated stateful routes
|
|
let unauth_routes = Router::new()
|
|
.route("/api/health", get(api::health))
|
|
.route("/api/docs", get(api::get_docs))
|
|
.route("/api/sessions/{id}/banner", get(api::get_session_banner))
|
|
.route("/client/{session_id}", get(serve_client_page))
|
|
.with_state(manager);
|
|
|
|
// Branded HTML page routes (served from memory with site_title/logo baked in)
|
|
let html_routes = Router::new()
|
|
.route("/", get(serve_branded_page))
|
|
.route("/index.html", get(serve_branded_page))
|
|
.route("/connections.html", get(serve_branded_page))
|
|
// Legacy path — the page was renamed from Address Book → Connections.
|
|
// Permanent redirect so bookmarks keep working.
|
|
.route(
|
|
"/addressbook.html",
|
|
get(|| async { axum::response::Redirect::permanent("/connections.html") }),
|
|
)
|
|
.route("/sessions.html", get(serve_branded_page))
|
|
.route("/recordings.html", get(serve_branded_page))
|
|
.route("/reports.html", get(serve_branded_page))
|
|
.route("/admin.html", get(serve_branded_page))
|
|
.route("/tokens.html", get(serve_branded_page))
|
|
.route("/docs.html", get(serve_branded_page))
|
|
// Optional auth only: an anonymous request still gets the page (the
|
|
// page itself sends the user to log in), but a logged-in one gets
|
|
// the variant with its navigation already right. No WsTicketStore
|
|
// here: without it optional_auth skips tickets, so a page URL
|
|
// carrying ?ticket= cannot use up a single-use WebSocket ticket.
|
|
.layer(middleware::from_fn(auth::optional_auth))
|
|
.layer(Extension(database.clone()));
|
|
|
|
// Build full router (all Router<()> at this point)
|
|
let mut app: Router<()> = Router::new()
|
|
.route("/api/auth/status", get(api::auth_status))
|
|
.merge(api_routes)
|
|
.merge(ws_route)
|
|
.merge(connect_route)
|
|
.merge(unauth_routes)
|
|
.merge(html_routes);
|
|
|
|
// Add OIDC routes if configured (always rate-limited to prevent brute-force)
|
|
if let Some(ref oidc_st) = oidc_state {
|
|
// Burst sized for an office of users behind one NAT address
|
|
// logging in at once (each login is two requests).
|
|
let auth_rate_conf = GovernorConfigBuilder::default()
|
|
.per_second(1)
|
|
.burst_size(20)
|
|
.key_extractor(rate_key.clone())
|
|
.finish()
|
|
.expect("Failed to build auth rate limit config");
|
|
let oidc_routes = Router::new()
|
|
.route("/auth/login", get(oidc::login))
|
|
.route("/auth/callback", get(oidc::callback))
|
|
.with_state(oidc_st.clone())
|
|
.layer(Extension(database.clone()))
|
|
.layer(GovernorLayer::new(auth_rate_conf));
|
|
|
|
let logout_route = Router::new()
|
|
.route("/auth/logout", get(oidc::logout))
|
|
.layer(Extension(database.clone()));
|
|
|
|
app = app.merge(oidc_routes).merge(logout_route);
|
|
}
|
|
|
|
// Add shared layers
|
|
// Server HTTPS requires both cert_path and key_path in [tls]
|
|
let server_tls = tls_config.as_ref().and_then(|tls| {
|
|
match (&tls.cert_path, &tls.key_path) {
|
|
(Some(cert), Some(key)) => Some((cert.clone(), key.clone())),
|
|
(Some(_), None) | (None, Some(_)) => {
|
|
tracing::warn!("[tls] has only one of cert_path/key_path — both are required for HTTPS serving; starting HTTP");
|
|
None
|
|
}
|
|
(None, None) => None,
|
|
}
|
|
});
|
|
let tls_enabled = TlsEnabled(server_tls.is_some());
|
|
app = app
|
|
.layer(DefaultBodyLimit::max(64 * 1024)) // 64 KB max request body
|
|
.layer(middleware::from_fn(security_headers))
|
|
.layer(Extension(tls_enabled))
|
|
.layer(Extension(oidc_enabled))
|
|
.layer(Extension(OidcHandle(oidc_state.clone())))
|
|
.layer(Extension(drive_configured))
|
|
.layer(Extension(site_title))
|
|
.layer(Extension(theme_data))
|
|
.layer(Extension(trusted_proxies))
|
|
.layer(Extension(branded_pages))
|
|
// Static assets carry Last-Modified and an ETag from ServeDir but no
|
|
// freshness directive, so Chrome caches them heuristically and never
|
|
// revalidates -- a rebuild stays invisible until the cache ages out.
|
|
// asset_cache_control supplies the directive, per request.
|
|
.fallback_service(axum::middleware::from_fn(asset_cache_control).layer(
|
|
// ServeDir answers with its own body type; from_fn needs an
|
|
// axum Response, and the turbofish is what pins down which
|
|
// request type the mapped service is being taken over.
|
|
ServiceExt::<axum::http::Request<axum::body::Body>>::map_response(
|
|
ServeDir::new(&static_path),
|
|
|r| {
|
|
use axum::response::IntoResponse;
|
|
r.into_response()
|
|
},
|
|
),
|
|
));
|
|
|
|
let scheme = if server_tls.is_some() {
|
|
"https"
|
|
} else {
|
|
"http"
|
|
};
|
|
tracing::info!("rustguac starting on {}://{}", scheme, listen_addr);
|
|
tracing::info!("Static files served from {:?}", static_path);
|
|
|
|
// TCP keepalive on the listener. Linux inherits SO_KEEPALIVE and the
|
|
// associated TCP_KEEPIDLE/INTVL/CNT options on accept(), so accepted
|
|
// browser sockets pick up keepalive without a per-accept hook. Pairs with
|
|
// the Guacamole protocol-level ping echo in src/websocket.rs to keep
|
|
// long-idle WebSocket sessions alive across NAT/firewall path changes.
|
|
let keepalive = socket2::TcpKeepalive::new()
|
|
.with_time(std::time::Duration::from_secs(30))
|
|
.with_interval(std::time::Duration::from_secs(10))
|
|
.with_retries(3);
|
|
|
|
if let Some((cert_path, key_path)) = server_tls {
|
|
use axum_server::tls_rustls::RustlsConfig;
|
|
|
|
let rustls_config = RustlsConfig::from_pem_file(&cert_path, &key_path)
|
|
.await
|
|
.expect("Failed to load TLS certificates");
|
|
|
|
let std_listener =
|
|
std::net::TcpListener::bind(&listen_addr).expect("Failed to bind listener");
|
|
std_listener
|
|
.set_nonblocking(true)
|
|
.expect("Failed to set listener non-blocking");
|
|
if let Err(e) = socket2::SockRef::from(&std_listener).set_tcp_keepalive(&keepalive) {
|
|
tracing::warn!(error = %e, "failed to enable TCP keepalive on TLS listener");
|
|
}
|
|
// Disable Nagle so display frames and sync acks flow to the browser
|
|
// without coalescing. Linux propagates this to accepted sockets.
|
|
if let Err(e) = socket2::SockRef::from(&std_listener).set_tcp_nodelay(true) {
|
|
tracing::warn!(error = %e, "failed to set TCP_NODELAY on TLS listener");
|
|
}
|
|
|
|
axum_server::from_tcp_rustls(std_listener, rustls_config)
|
|
.expect("Failed to wrap listener")
|
|
.serve(app.into_make_service_with_connect_info::<SocketAddr>())
|
|
.await
|
|
.expect("Server error");
|
|
} else {
|
|
let listener = tokio::net::TcpListener::bind(&listen_addr)
|
|
.await
|
|
.expect("Failed to bind listener");
|
|
if let Err(e) = socket2::SockRef::from(&listener).set_tcp_keepalive(&keepalive) {
|
|
tracing::warn!(error = %e, "failed to enable TCP keepalive on listener");
|
|
}
|
|
// Disable Nagle so display frames and sync acks flow to the browser
|
|
// without coalescing. Linux propagates this to accepted sockets.
|
|
if let Err(e) = socket2::SockRef::from(&listener).set_tcp_nodelay(true) {
|
|
tracing::warn!(error = %e, "failed to set TCP_NODELAY on listener");
|
|
}
|
|
|
|
axum::serve(
|
|
listener,
|
|
app.into_make_service_with_connect_info::<SocketAddr>(),
|
|
)
|
|
.with_graceful_shutdown(async {
|
|
let _ = tokio::signal::ctrl_c().await;
|
|
tracing::info!("Shutdown signal received");
|
|
})
|
|
.await
|
|
.expect("Server error");
|
|
}
|
|
|
|
// Cleanup LUKS on shutdown
|
|
if let Some(ref drive_config) = shutdown_drive_config {
|
|
if drive_config.enabled && drive::luks_configured(drive_config) {
|
|
tracing::info!("Unmounting LUKS drive volume...");
|
|
if let Err(e) = drive::unmount_luks(drive_config).await {
|
|
tracing::warn!("Failed to unmount LUKS volume on shutdown: {}", e);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Build a TLS connector for the guacd connection, if `[tls] guacd_cert_path` is configured.
|
|
/// This is independent of server HTTPS — you can use guacd TLS without cert_path/key_path.
|
|
fn build_guacd_tls(config: &Config) -> Option<tokio_rustls::TlsConnector> {
|
|
let cert_path = config.tls.as_ref()?.guacd_cert_path.as_ref()?;
|
|
|
|
let pem_data = std::fs::read(cert_path)
|
|
.unwrap_or_else(|e| panic!("Failed to read guacd cert {}: {}", cert_path.display(), e));
|
|
|
|
let mut root_store = tokio_rustls::rustls::RootCertStore::empty();
|
|
let certs: Vec<_> = rustls_pemfile::certs(&mut &pem_data[..])
|
|
.collect::<Result<Vec<_>, _>>()
|
|
.unwrap_or_else(|e| panic!("Failed to parse guacd cert PEM: {}", e));
|
|
|
|
for cert in certs {
|
|
root_store
|
|
.add(cert)
|
|
.unwrap_or_else(|e| panic!("Failed to add guacd cert to root store: {}", e));
|
|
}
|
|
|
|
let tls_config = tokio_rustls::rustls::ClientConfig::builder()
|
|
.with_root_certificates(root_store)
|
|
.with_no_client_auth();
|
|
|
|
tracing::info!(
|
|
"guacd TLS enabled, trusting cert from {}",
|
|
cert_path.display()
|
|
);
|
|
Some(tokio_rustls::TlsConnector::from(Arc::new(tls_config)))
|
|
}
|
|
|
|
/// One pre-branded HTML page, in one variant per role level, each with the
|
|
/// ETag its bytes hash to.
|
|
///
|
|
/// The pages are branded once at startup and cannot change until a restart,
|
|
/// so the tags are computed there too -- serving one is a string compare.
|
|
///
|
|
/// The variants differ only in the `data-role-level` attribute written on
|
|
/// `<html>`: the stylesheet gates navigation links and admin-only controls on
|
|
/// it, so the header is right on first paint for whoever the page is served
|
|
/// to, instead of hiding everything gated and showing it after `/api/me`
|
|
/// answers, which flashed and reflowed on every navigation.
|
|
struct BrandedPage {
|
|
/// Indexed by role level, 0 (anonymous) to 4 (admin).
|
|
variants: Vec<BrandedVariant>,
|
|
}
|
|
|
|
struct BrandedVariant {
|
|
html: String,
|
|
etag: String,
|
|
}
|
|
|
|
/// Highest role level, the index of the last page variant.
|
|
const MAX_ROLE_LEVEL: u8 = 4;
|
|
|
|
impl BrandedPage {
|
|
fn new(html: String) -> Self {
|
|
use sha2::{Digest, Sha256};
|
|
let variants = (0..=MAX_ROLE_LEVEL)
|
|
.map(|level| {
|
|
let html = role_variant_html(&html, level);
|
|
// Quoted, because an ETag is a quoted-string on the wire. A
|
|
// bare hash tends to be ignored rather than rejected, which
|
|
// would look exactly like the stale-cache bug this exists to
|
|
// fix.
|
|
let etag = format!("\"{}\"", hex::encode(Sha256::digest(html.as_bytes())));
|
|
BrandedVariant { html, etag }
|
|
})
|
|
.collect();
|
|
Self { variants }
|
|
}
|
|
|
|
fn variant(&self, level: u8) -> &BrandedVariant {
|
|
&self.variants[level.min(MAX_ROLE_LEVEL) as usize]
|
|
}
|
|
}
|
|
|
|
/// Write the caller's role level onto the page's `<html>` tag. A page that
|
|
/// has no bare `<html>` start tag is returned unchanged. Extracted for test.
|
|
fn role_variant_html(html: &str, level: u8) -> String {
|
|
match html.find("<html>") {
|
|
Some(i) => format!(
|
|
"{}<html data-role-level=\"{}\">{}",
|
|
&html[..i],
|
|
level,
|
|
&html[i + "<html>".len()..]
|
|
),
|
|
None => html.to_string(),
|
|
}
|
|
}
|
|
|
|
type BrandedPages = Arc<std::collections::HashMap<String, BrandedPage>>;
|
|
|
|
/// Whether an `If-None-Match` value covers `etag`.
|
|
///
|
|
/// The comparison for `If-None-Match` is the weak one, so a tag a proxy has
|
|
/// weakened to `W/"..."` still matches.
|
|
fn etag_matches(if_none_match: &str, etag: &str) -> bool {
|
|
if_none_match.split(',').any(|candidate| {
|
|
let candidate = candidate.trim();
|
|
candidate == "*"
|
|
|| candidate == etag
|
|
|| candidate.strip_prefix("W/").map(str::trim) == Some(etag)
|
|
})
|
|
}
|
|
|
|
/// Answer with `page`, or 304 if the client already holds this exact version.
|
|
///
|
|
/// `Cache-Control: no-cache` means revalidate, not do-not-store: the browser
|
|
/// keeps the body and asks whether it is still current, so an unchanged page
|
|
/// costs a 304 and a rebuilt one lands on the next load. Without it these
|
|
/// responses carry no freshness information at all and Chrome caches them
|
|
/// heuristically, serving a stale page long after a restart.
|
|
fn page_response(
|
|
page: &BrandedPage,
|
|
level: u8,
|
|
headers: &axum::http::HeaderMap,
|
|
) -> axum::response::Response {
|
|
use axum::http::{header, StatusCode};
|
|
use axum::response::IntoResponse;
|
|
|
|
let page = page.variant(level);
|
|
// The variant depends on who is logged in, so a cache must not hand one
|
|
// user's copy to another: it keys on the session cookie.
|
|
let cache_headers = [
|
|
(header::CACHE_CONTROL, "no-cache"),
|
|
(header::ETAG, page.etag.as_str()),
|
|
(header::VARY, "Cookie"),
|
|
];
|
|
|
|
let known = headers
|
|
.get(header::IF_NONE_MATCH)
|
|
.and_then(|v| v.to_str().ok())
|
|
.is_some_and(|v| etag_matches(v, &page.etag));
|
|
|
|
if known {
|
|
(StatusCode::NOT_MODIFIED, cache_headers).into_response()
|
|
} else {
|
|
(cache_headers, Html(page.html.clone())).into_response()
|
|
}
|
|
}
|
|
|
|
/// How long a content-addressed asset may be cached: a year, the longest
|
|
/// span RFC 9111 attaches meaning to, plus `immutable` so that a reload does
|
|
/// not revalidate it either.
|
|
const IMMUTABLE_CACHE: &str = "public, max-age=31536000, immutable";
|
|
|
|
/// Choose a static asset's `Cache-Control` from the request URL.
|
|
///
|
|
/// A `?v=` query means the URL is content-addressed: the hash was written
|
|
/// into the HTML from the bytes on disk at startup, so this exact URL can
|
|
/// never come to mean different bytes and the browser need never ask again.
|
|
/// A bare URL keeps the revalidating default -- an HTML page cached from
|
|
/// before this change still asks for one, as does anything linked by hand.
|
|
async fn asset_cache_control(
|
|
request: axum::extract::Request,
|
|
next: axum::middleware::Next,
|
|
) -> axum::response::Response {
|
|
let versioned = request
|
|
.uri()
|
|
.query()
|
|
.is_some_and(|q| q.split('&').any(|param| param.starts_with("v=")));
|
|
let mut response = next.run(request).await;
|
|
// Only a response that carries the asset may be kept for a year. A 404
|
|
// under a versioned URL is a deployment that has gone wrong, and pinning
|
|
// it in the cache until next year would make it outlive its cause.
|
|
let status = response.status();
|
|
let cacheable = status.is_success() || status == axum::http::StatusCode::NOT_MODIFIED;
|
|
response.headers_mut().insert(
|
|
axum::http::header::CACHE_CONTROL,
|
|
axum::http::HeaderValue::from_static(if versioned && cacheable {
|
|
// Set on the 304 as well as the 200 it stands in for: a
|
|
// revalidation answering without the directive would re-teach the
|
|
// browser the old, absent freshness every time.
|
|
IMMUTABLE_CACHE
|
|
} else {
|
|
"no-cache"
|
|
}),
|
|
);
|
|
response
|
|
}
|
|
|
|
/// Append a content hash to the local asset URLs in `html`.
|
|
///
|
|
/// Turns `src="/guac/Client.js"` into `src="/guac/Client.js?v=4c93eac7`, which
|
|
/// lets those responses be cached for a year instead of revalidated on every
|
|
/// page load -- 37 conditional requests that a WAN client pays a round trip
|
|
/// for and that almost always answer 304.
|
|
///
|
|
/// The hash is taken at startup, in the same pass that brands the page, so
|
|
/// the URL and the bytes it names are produced together and cannot disagree.
|
|
/// The corollary is that **editing an asset now needs a restart to be seen**,
|
|
/// where before a reload was enough; `RUSTGUAC_NO_ASSET_VERSIONING` turns
|
|
/// this off for that reason.
|
|
///
|
|
/// An asset that cannot be read is left alone: a missing file is a 404 either
|
|
/// way, and a URL bearing a hash of nothing would be worse than a bare one.
|
|
fn version_assets(
|
|
html: &str,
|
|
static_path: &std::path::Path,
|
|
hashes: &mut std::collections::HashMap<String, Option<String>>,
|
|
) -> String {
|
|
const ATTRS: [&str; 2] = ["src=\"", "href=\""];
|
|
|
|
let mut out = String::with_capacity(html.len() + 1024);
|
|
let mut rest = html;
|
|
// Whichever attribute comes first each time, so neither kind is skipped.
|
|
while let Some((at, attr)) = ATTRS
|
|
.iter()
|
|
.filter_map(|attr| rest.find(attr).map(|at| (at, *attr)))
|
|
.min_by_key(|(at, _)| *at)
|
|
{
|
|
let value_at = at + attr.len();
|
|
let Some(len) = rest[value_at..].find('"') else {
|
|
break;
|
|
};
|
|
let url = &rest[value_at..value_at + len];
|
|
out.push_str(&rest[..value_at]);
|
|
out.push_str(url);
|
|
if let Some(hash) = asset_hash(url, static_path, hashes) {
|
|
out.push_str("?v=");
|
|
out.push_str(&hash);
|
|
}
|
|
rest = &rest[value_at + len..];
|
|
}
|
|
out.push_str(rest);
|
|
out
|
|
}
|
|
|
|
/// The short content hash for a local asset URL, or None if it is not one we
|
|
/// version.
|
|
fn asset_hash(
|
|
url: &str,
|
|
static_path: &std::path::Path,
|
|
hashes: &mut std::collections::HashMap<String, Option<String>>,
|
|
) -> Option<String> {
|
|
// Root-relative, unadorned, and one of the two kinds that go stale. An
|
|
// absolute URL belongs to someone else, and a query or fragment means the
|
|
// page is already saying something about this URL.
|
|
if !url.starts_with('/') || url.starts_with("//") || url.contains('?') || url.contains('#') {
|
|
return None;
|
|
}
|
|
if !(url.ends_with(".js")
|
|
|| url.ends_with(".css")
|
|
|| url.ends_with(".svg")
|
|
|| url.ends_with(".png")
|
|
|| url.ends_with(".ico"))
|
|
{
|
|
return None;
|
|
}
|
|
let rel = url.trim_start_matches('/');
|
|
// These paths are ours, but they arrive as text out of an HTML file, so
|
|
// they are treated as untrusted rather than as ours.
|
|
if rel
|
|
.split('/')
|
|
.any(|seg| seg.is_empty() || seg == "." || seg == "..")
|
|
{
|
|
return None;
|
|
}
|
|
if let Some(known) = hashes.get(url) {
|
|
return known.clone();
|
|
}
|
|
let hash = std::fs::read(static_path.join(rel)).ok().map(|bytes| {
|
|
use sha2::{Digest, Sha256};
|
|
// Eight hex digits: the tag only has to tell one build of a file from
|
|
// the next, and the full 64 across 37 references is page weight spent
|
|
// on nothing.
|
|
hex::encode(Sha256::digest(&bytes))[..8].to_string()
|
|
});
|
|
hashes.insert(url.to_string(), hash.clone());
|
|
hash
|
|
}
|
|
|
|
/// Serve a branded HTML page from the pre-processed in-memory map.
|
|
async fn serve_branded_page(
|
|
Extension(pages): Extension<BrandedPages>,
|
|
identity: Option<Extension<auth::AuthIdentity>>,
|
|
request: axum::extract::Request,
|
|
) -> axum::response::Response {
|
|
use axum::http::StatusCode;
|
|
use axum::response::IntoResponse;
|
|
let path = request.uri().path().trim_start_matches('/');
|
|
let key = if path.is_empty() { "index.html" } else { path };
|
|
// Whoever the page is for decides which navigation it carries. An API-key
|
|
// user sends no credential on the page GET and gets the anonymous
|
|
// variant; the page reconciles that from /api/me.
|
|
let level = identity
|
|
.map(|Extension(id)| auth::role_level(id.role()))
|
|
.unwrap_or(0);
|
|
match pages.get(key) {
|
|
Some(page) => page_response(page, level, request.headers()),
|
|
None => StatusCode::NOT_FOUND.into_response(),
|
|
}
|
|
}
|
|
|
|
/// Serve the client HTML page for SSH sessions.
|
|
/// The session_id is extracted by the JS on the page, not by this handler.
|
|
async fn serve_client_page(
|
|
axum::extract::State(manager): axum::extract::State<crate::api::AppState>,
|
|
axum::extract::Path(session_id): axum::extract::Path<String>,
|
|
Extension(pages): Extension<BrandedPages>,
|
|
headers: axum::http::HeaderMap,
|
|
) -> axum::response::Response {
|
|
use axum::response::IntoResponse;
|
|
let mut response = match pages.get("client.html") {
|
|
Some(page) => page_response(page, 0, &headers),
|
|
// Unreachable: the map always carries the embedded copy.
|
|
None => Html(include_str!("../static/client.html")).into_response(),
|
|
};
|
|
let session = match uuid::Uuid::parse_str(&session_id) {
|
|
Ok(id) => manager.get_session(id).await,
|
|
Err(_) => None,
|
|
};
|
|
if session
|
|
.as_ref()
|
|
.is_some_and(|s| client_frameable(s.fullscreen_on_connect, s.address_book_entry.is_some()))
|
|
{
|
|
response.extensions_mut().insert(ClientFrameable);
|
|
}
|
|
response
|
|
}
|
|
|
|
/// Whether a session's client page may be framed by our own origin: only a
|
|
/// session opened from a saved entry whose admin turned fullscreen-on-connect
|
|
/// on. That is the only case the Connections page hosts the client in an
|
|
/// iframe; an ad-hoc session, whatever its request said, and an unknown or
|
|
/// ended session are never frameable.
|
|
fn client_frameable(fullscreen_on_connect: bool, from_entry: bool) -> bool {
|
|
fullscreen_on_connect && from_entry
|
|
}
|
|
|
|
/// HTML-escape a string to prevent XSS when injecting config values into HTML.
|
|
fn html_escape(s: &str) -> String {
|
|
s.replace('&', "&")
|
|
.replace('<', "<")
|
|
.replace('>', ">")
|
|
.replace('"', """)
|
|
.replace('\'', "'")
|
|
}
|
|
|
|
/// Rewrite branding in HTML: replace default "rustguac" site title and logo.
|
|
fn rewrite_branding(html: &str, site_title: &str, logo_url: Option<&str>) -> String {
|
|
let mut out = html.to_string();
|
|
if site_title != "rustguac" {
|
|
let safe_title = html_escape(site_title);
|
|
// <title>rustguac</title> and <title>rustguac - Sessions</title> etc.
|
|
out = out.replace(
|
|
"<title>rustguac</title>",
|
|
&format!("<title>{}</title>", safe_title),
|
|
);
|
|
out = out.replace("<title>rustguac - ", &format!("<title>{} - ", safe_title));
|
|
// <h1>rustguac</h1> (with or without inline style)
|
|
out = out.replace(">rustguac</h1>", &format!(">{}</h1>", safe_title));
|
|
}
|
|
if let Some(url) = logo_url {
|
|
let safe_url = html_escape(url);
|
|
out = out.replace("src=\"/logo.svg\"", &format!("src=\"{}\"", safe_url));
|
|
}
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn role_variant_writes_level_on_html_tag_only() {
|
|
let page = "<!DOCTYPE html>\n<html>\n<head><title>x</title></head><body><html>text</html></body></html>";
|
|
let v = role_variant_html(page, 4);
|
|
assert!(v.starts_with("<!DOCTYPE html>\n<html data-role-level=\"4\">\n<head>"));
|
|
// only the first bare start tag is touched; the literal later on is content
|
|
assert_eq!(v.matches("data-role-level").count(), 1);
|
|
assert_eq!(
|
|
role_variant_html("<p>no html tag</p>", 2),
|
|
"<p>no html tag</p>"
|
|
);
|
|
let branded = BrandedPage::new(page.to_string());
|
|
assert_eq!(branded.variants.len(), 5);
|
|
assert_ne!(branded.variant(0).etag, branded.variant(4).etag);
|
|
assert_eq!(
|
|
branded.variant(9).etag,
|
|
branded.variant(4).etag,
|
|
"levels clamp to admin"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn framing_is_denied_except_for_the_frameable_client() {
|
|
// Clickjacking: no page may be framed, by anyone. The one exception,
|
|
// the client of a fullscreen-on-connect entry's session (#257), may
|
|
// be framed by our own origin and nothing wider. The two policies
|
|
// must otherwise be identical, so the exception loosens framing and
|
|
// nothing else.
|
|
let ancestors = |csp: &str| -> Vec<String> {
|
|
let d = csp
|
|
.split(';')
|
|
.map(str::trim)
|
|
.find(|d| d.starts_with("frame-ancestors "))
|
|
.expect("CSP must carry a frame-ancestors directive");
|
|
d.split_whitespace().skip(1).map(String::from).collect()
|
|
};
|
|
assert_eq!(ancestors(CONTENT_SECURITY_POLICY), vec!["'none'"]);
|
|
assert_eq!(ancestors(FRAMEABLE_CONTENT_SECURITY_POLICY), vec!["'self'"]);
|
|
let rest = |csp: &str| -> Vec<String> {
|
|
csp.split(';')
|
|
.map(str::trim)
|
|
.filter(|d| !d.starts_with("frame-ancestors "))
|
|
.map(String::from)
|
|
.collect()
|
|
};
|
|
assert_eq!(
|
|
rest(CONTENT_SECURITY_POLICY),
|
|
rest(FRAMEABLE_CONTENT_SECURITY_POLICY)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn only_fullscreen_entry_sessions_are_frameable() {
|
|
assert!(client_frameable(true, true));
|
|
assert!(!client_frameable(false, true), "a normal entry");
|
|
assert!(
|
|
!client_frameable(true, false),
|
|
"an ad-hoc session cannot opt itself in"
|
|
);
|
|
assert!(!client_frameable(false, false));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn security_headers_deny_framing_unless_the_page_is_marked() {
|
|
use axum::body::Body;
|
|
use axum::http::Request as HttpRequest;
|
|
use axum::response::IntoResponse;
|
|
let app = Router::new()
|
|
.route("/plain", get(|| async { "x" }))
|
|
.route(
|
|
"/client",
|
|
get(|| async {
|
|
let mut r = "x".into_response();
|
|
r.extensions_mut().insert(ClientFrameable);
|
|
r
|
|
}),
|
|
)
|
|
.layer(middleware::from_fn(security_headers))
|
|
.layer(Extension(TlsEnabled(false)));
|
|
let get_headers = |path: &'static str| {
|
|
let app = app.clone();
|
|
async move {
|
|
app.oneshot(HttpRequest::get(path).body(Body::empty()).unwrap())
|
|
.await
|
|
.unwrap()
|
|
.headers()
|
|
.clone()
|
|
}
|
|
};
|
|
let plain = get_headers("/plain").await;
|
|
assert_eq!(plain["x-frame-options"], "DENY");
|
|
assert!(plain["content-security-policy"]
|
|
.to_str()
|
|
.unwrap()
|
|
.contains("frame-ancestors 'none'"));
|
|
let client = get_headers("/client").await;
|
|
assert_eq!(client["x-frame-options"], "SAMEORIGIN");
|
|
assert!(client["content-security-policy"]
|
|
.to_str()
|
|
.unwrap()
|
|
.contains("frame-ancestors 'self'"));
|
|
}
|
|
|
|
#[test]
|
|
fn test_csp_allows_data_and_blob_images() {
|
|
// Safari and Firefox draw Guacamole image streams through data: URIs
|
|
// (#249). Chromium uses ImageDecoder and would not catch a regression.
|
|
let directives: Vec<&str> = CONTENT_SECURITY_POLICY.split(';').map(str::trim).collect();
|
|
let img_src = directives
|
|
.iter()
|
|
.find(|d| d.starts_with("img-src "))
|
|
.expect("CSP must carry an explicit img-src directive");
|
|
let sources: Vec<&str> = img_src.split_whitespace().skip(1).collect();
|
|
assert!(sources.contains(&"'self'"), "img-src must allow 'self'");
|
|
assert!(sources.contains(&"data:"), "img-src must allow data: URIs");
|
|
assert!(sources.contains(&"blob:"), "img-src must allow blob: URLs");
|
|
// The string continuation must not leave stray whitespace runs.
|
|
assert!(!CONTENT_SECURITY_POLICY.contains(" "));
|
|
assert!(!CONTENT_SECURITY_POLICY.contains('\n'));
|
|
}
|
|
|
|
#[test]
|
|
fn test_version_assets_rewrites_local_js_and_css() {
|
|
// Tests run from the crate root, so this is the real static tree.
|
|
let static_path = std::path::Path::new("static");
|
|
let mut hashes = std::collections::HashMap::new();
|
|
let html = concat!(
|
|
"<link rel=\"stylesheet\" href=\"/rustguac.css\">",
|
|
"<script src=\"/guac/Client.js\"></script>",
|
|
);
|
|
let out = version_assets(html, static_path, &mut hashes);
|
|
assert!(out.contains("/rustguac.css?v="), "{out}");
|
|
assert!(out.contains("/guac/Client.js?v="), "{out}");
|
|
// The rest of the markup survives untouched.
|
|
assert!(out.starts_with("<link rel=\"stylesheet\""));
|
|
assert!(out.ends_with("></script>"));
|
|
}
|
|
|
|
#[test]
|
|
fn page_response_varies_on_the_cookie() {
|
|
// The variant served depends on the session cookie, so a cache must
|
|
// never hand an admin's copy to an anonymous visitor or the reverse.
|
|
use axum::http::{header, HeaderMap, StatusCode};
|
|
let page = BrandedPage::new("<html>x</html>".to_string());
|
|
let res = page_response(&page, 4, &HeaderMap::new());
|
|
assert_eq!(res.status(), StatusCode::OK);
|
|
assert_eq!(res.headers()[header::VARY], "Cookie");
|
|
let mut h = HeaderMap::new();
|
|
h.insert(header::IF_NONE_MATCH, page.variant(4).etag.parse().unwrap());
|
|
let res = page_response(&page, 4, &h);
|
|
assert_eq!(res.status(), StatusCode::NOT_MODIFIED);
|
|
assert_eq!(res.headers()[header::VARY], "Cookie");
|
|
// Another role's tag is not this variant: full page.
|
|
assert_eq!(page_response(&page, 1, &h).status(), StatusCode::OK);
|
|
}
|
|
|
|
#[test]
|
|
fn test_version_assets_versions_the_logo() {
|
|
// The logo used to be revalidated on every page load, which blanked
|
|
// it for a moment on each navigation in some browsers. It is content
|
|
// addressed like the scripts now.
|
|
let static_path = std::path::Path::new("static");
|
|
let mut hashes = std::collections::HashMap::new();
|
|
let out = version_assets(
|
|
"<img id=\"site-logo\" src=\"/logo.svg\" alt=\"\">",
|
|
static_path,
|
|
&mut hashes,
|
|
);
|
|
assert!(out.contains("src=\"/logo.svg?v="), "{out}");
|
|
}
|
|
|
|
#[test]
|
|
fn test_version_assets_leaves_everything_else_alone() {
|
|
let static_path = std::path::Path::new("static");
|
|
let mut hashes = std::collections::HashMap::new();
|
|
for html in [
|
|
// Not ours to version.
|
|
"<script src=\"https://cdn.example/x.js\"></script>",
|
|
"<script src=\"//cdn.example/x.js\"></script>",
|
|
// Already says something about this URL.
|
|
"<script src=\"/guac/Client.js?debug=1\"></script>",
|
|
// Not a kind we version (a page, a download).
|
|
"<a href=\"/docs.html\">",
|
|
// No such file: a hash of nothing is worse than none.
|
|
"<script src=\"/guac/NoSuchFile.js\"></script>",
|
|
// Traversal, which these paths have no business doing.
|
|
"<script src=\"/../Cargo.toml.js\"></script>",
|
|
] {
|
|
assert_eq!(
|
|
version_assets(html, static_path, &mut hashes),
|
|
html,
|
|
"{html}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn test_asset_hash_is_content_derived_and_cached() {
|
|
let static_path = std::path::Path::new("static");
|
|
let mut hashes = std::collections::HashMap::new();
|
|
let css = asset_hash("/rustguac.css", static_path, &mut hashes).expect("css hashes");
|
|
let js = asset_hash("/guac/Client.js", static_path, &mut hashes).expect("js hashes");
|
|
assert_ne!(css, js);
|
|
assert_eq!(css.len(), 8);
|
|
assert!(css.chars().all(|c| c.is_ascii_hexdigit()));
|
|
// One entry per distinct URL, hit or miss -- a miss is cached too, so
|
|
// a page full of broken links does not re-stat the disk per reference.
|
|
assert_eq!(
|
|
asset_hash("/rustguac.css", static_path, &mut hashes),
|
|
Some(css)
|
|
);
|
|
assert!(asset_hash("/guac/NoSuchFile.js", static_path, &mut hashes).is_none());
|
|
assert_eq!(hashes.len(), 3);
|
|
}
|
|
|
|
#[test]
|
|
fn test_etag_matches_exact_weak_and_wildcard() {
|
|
let etag = "\"abc\"";
|
|
assert!(etag_matches("\"abc\"", etag));
|
|
assert!(etag_matches("W/\"abc\"", etag));
|
|
assert!(etag_matches("*", etag));
|
|
// A list, as a browser holding several cached variants sends.
|
|
assert!(etag_matches("\"other\", \"abc\"", etag));
|
|
assert!(!etag_matches("\"other\"", etag));
|
|
// A rebuilt page: same length, different bytes.
|
|
assert!(!etag_matches("\"abd\"", etag));
|
|
}
|
|
|
|
#[test]
|
|
fn test_branded_page_etag_tracks_content() {
|
|
let a = BrandedPage::new("<html>one</html>".to_string());
|
|
let b = BrandedPage::new("<html>two</html>".to_string());
|
|
let (a, b) = (a.variant(0), b.variant(0));
|
|
assert_ne!(a.etag, b.etag);
|
|
// Quoted, or caches ignore it.
|
|
assert!(a.etag.starts_with('"') && a.etag.ends_with('"'));
|
|
assert!(etag_matches(&a.etag, &a.etag));
|
|
assert!(!etag_matches(&b.etag, &a.etag));
|
|
}
|
|
|
|
#[test]
|
|
fn test_rewrite_branding_title() {
|
|
let html = "<title>rustguac - Sessions</title>";
|
|
assert_eq!(
|
|
rewrite_branding(html, "MyGateway", None),
|
|
"<title>MyGateway - Sessions</title>"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rewrite_branding_h1() {
|
|
let html = r#"<h1 style="display:inline">rustguac</h1>"#;
|
|
assert_eq!(
|
|
rewrite_branding(html, "MyGateway", None),
|
|
r#"<h1 style="display:inline">MyGateway</h1>"#
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rewrite_branding_logo() {
|
|
let html = r#"<img id="site-logo" src="/logo.svg" style="max-height:40px">"#;
|
|
assert_eq!(
|
|
rewrite_branding(html, "rustguac", Some("https://example.com/logo.png")),
|
|
r#"<img id="site-logo" src="https://example.com/logo.png" style="max-height:40px">"#
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn test_rewrite_branding_noop() {
|
|
let html = "<title>rustguac</title><h1>rustguac</h1>";
|
|
assert_eq!(rewrite_branding(html, "rustguac", None), html);
|
|
}
|
|
|
|
// ── Rate-limit plumbing ─────────────────────────────────────────────
|
|
// Proves that the tower_governor layer is actually applied: bursts
|
|
// above the configured threshold return 429. Guards against a future
|
|
// refactor that drops `.layer(GovernorLayer::new(...))` from a route
|
|
// group without noticing.
|
|
|
|
fn probe_app(burst: u32) -> axum::Router {
|
|
use axum::routing::get;
|
|
let conf = GovernorConfigBuilder::default()
|
|
.per_second(1)
|
|
.burst_size(burst)
|
|
.key_extractor(auth::ClientIpKeyExtractor::new(&["10.0.0.0/8".into()]))
|
|
.finish()
|
|
.expect("governor config");
|
|
axum::Router::new()
|
|
.route("/probe", get(|| async { axum::http::StatusCode::OK }))
|
|
.layer(GovernorLayer::new(conf))
|
|
}
|
|
|
|
fn probe_req(peer: &str, xff: Option<&str>) -> axum::http::Request<axum::body::Body> {
|
|
let mut b = axum::http::Request::builder().uri("/probe");
|
|
if let Some(x) = xff {
|
|
b = b.header("x-forwarded-for", x);
|
|
}
|
|
let mut req = b.body(axum::body::Body::empty()).unwrap();
|
|
let addr: std::net::SocketAddr = format!("{peer}:40000").parse().unwrap();
|
|
req.extensions_mut()
|
|
.insert(axum::extract::ConnectInfo(addr));
|
|
req
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn rate_limit_layer_returns_429_on_burst() {
|
|
use tower::ServiceExt;
|
|
let app = probe_app(3);
|
|
let mut saw_ok = false;
|
|
let mut saw_429 = false;
|
|
for _ in 0..10 {
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(probe_req("203.0.113.99", None))
|
|
.await
|
|
.unwrap();
|
|
match resp.status().as_u16() {
|
|
200 => saw_ok = true,
|
|
429 => saw_429 = true,
|
|
other => panic!("unexpected status {other}: governor misconfigured"),
|
|
}
|
|
}
|
|
assert!(saw_ok, "no requests succeeded: layer misconfigured");
|
|
assert!(
|
|
saw_429,
|
|
"no requests were throttled: rate-limit not applied"
|
|
);
|
|
}
|
|
|
|
/// H1: a client that forges a different X-Forwarded-For on every
|
|
/// request must still share one bucket when it is not a trusted proxy.
|
|
#[tokio::test]
|
|
async fn rate_limit_ignores_forged_xff_from_untrusted_peer() {
|
|
use tower::ServiceExt;
|
|
let app = probe_app(3);
|
|
let mut throttled = 0;
|
|
for i in 0..10 {
|
|
let forged = format!("198.51.100.{i}");
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(probe_req("203.0.113.99", Some(&forged)))
|
|
.await
|
|
.unwrap();
|
|
if resp.status() == axum::http::StatusCode::TOO_MANY_REQUESTS {
|
|
throttled += 1;
|
|
}
|
|
}
|
|
assert!(
|
|
throttled >= 6,
|
|
"forged XFF escaped the limiter ({throttled} throttled)"
|
|
);
|
|
}
|
|
|
|
/// Behind a trusted proxy, clients get their own buckets.
|
|
#[tokio::test]
|
|
async fn rate_limit_keys_on_client_behind_trusted_proxy() {
|
|
use tower::ServiceExt;
|
|
let app = probe_app(3);
|
|
for i in 0..10 {
|
|
let client = format!("198.51.100.{i}");
|
|
let resp = app
|
|
.clone()
|
|
.oneshot(probe_req("10.0.0.1", Some(&client)))
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(resp.status(), axum::http::StatusCode::OK);
|
|
}
|
|
}
|
|
}
|