This commit is contained in:
Mateusz Gruszczyński committed 2026-10-07 13:40:21 +02:00
1 parent 3a2eb4fe79
commit e0194fbaff
63 files changed
+1553 -379

No files matched your search

+1
View File
@@ -3,6 +3,7 @@
//! travel through argv, logs or API responses.
include!("acme/model.rs");
include!("acme/runtime_env.rs");
include!("acme/providers.rs");
include!("acme/jobs.rs");
#[cfg(test)]
+6 -85
View File
@@ -6,13 +6,8 @@ use base64::Engine as _;
use http::StatusCode;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::{
path::PathBuf,
process::Stdio,
sync::{Arc, Mutex},
time::Duration,
};
use tokio::{io::AsyncWriteExt, process::Command, sync::Semaphore};
use std::sync::{Arc, Mutex};
use tokio::sync::Semaphore;
use zeroize::{Zeroize, Zeroizing};
#[derive(Debug, Clone, Serialize)]
@@ -229,6 +224,8 @@ fn provider_credential_bytes(
pub struct Manager {
slot: Arc<Semaphore>,
runtime_slot: Arc<Semaphore>,
runtime: Mutex<RuntimeStatus>,
job: Mutex<Option<Job>>,
renewals: Mutex<std::collections::BTreeMap<String, RenewalState>>,
}
@@ -236,6 +233,8 @@ impl Manager {
pub fn new() -> Arc<Self> {
Arc::new(Self {
slot: Arc::new(Semaphore::new(1)),
runtime_slot: Arc::new(Semaphore::new(1)),
runtime: Mutex::new(RuntimeStatus::default()),
job: Mutex::new(None),
renewals: Mutex::new(Default::default()),
})
@@ -418,81 +417,3 @@ struct Output {
cert_file: String,
key_file: String,
}
/// Kill the entire private process group if execution is cancelled/abandoned.
/// Certbot inherits the group, so it cannot outlive a stopped RustProxy worker.
struct ProcessGroup(Option<u32>);
impl Drop for ProcessGroup {
fn drop(&mut self) {
#[cfg(unix)]
if let Some(id) = self.0 {
// SAFETY: this is the process group created solely for this child.
unsafe {
libc::kill(-(id as i32), libc::SIGKILL);
}
}
}
}
async fn worker(state: &App, directory: PathBuf, raw: Zeroizing<String>) -> Result<Value, String> {
let mut command = Command::new("python3");
command
.arg("-I")
.arg("-c")
.arg(include_str!("../../scripts/acme_provider.py"))
.arg("--cert-dir")
.arg(&directory)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true);
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.as_std_mut().process_group(0);
}
let mut child = command
.spawn()
.map_err(|_| "acme.python_not_available".to_owned())?;
let mut group = ProcessGroup(child.id());
let stdin = child
.stdin
.take()
.ok_or_else(|| "acme.worker_io".to_owned())?;
let work = async move {
let mut stdin = stdin;
stdin
.write_all(raw.as_bytes())
.await
.map_err(|_| "acme.worker_io".to_owned())?;
stdin
.shutdown()
.await
.map_err(|_| "acme.worker_io".to_owned())?;
drop(stdin);
drop(raw);
child
.wait_with_output()
.await
.map_err(|_| "acme.worker_io".to_owned())
};
let output = tokio::select! {
_ = state.shutdown.cancelled() => return Err("acme.cancelled".into()),
result = tokio::time::timeout(Duration::from_secs(2160), work) => result.map_err(|_| "acme.provider_timeout".to_owned())??,
};
group.0 = None; // the process has been reaped, never kill a reused PID
if output.stdout.len() > 16384 {
return Err("acme.invalid_worker_response".into());
}
let value: Value = serde_json::from_slice(&output.stdout)
.map_err(|_| "acme.invalid_worker_response".to_owned())?;
if !output.status.success() {
let reason = value.get("error").and_then(Value::as_str).filter(|s| {
s.starts_with("acme.")
&& s.len() < 96
&& s.bytes()
.all(|c| c.is_ascii_lowercase() || c == b'_' || c == b'.')
});
return Err(reason.unwrap_or("acme.worker_failed").to_owned());
}
Ok(value)
}
+491
View File
@@ -0,0 +1,491 @@
use std::{
env,
ffi::OsString,
path::{Path, PathBuf},
process::Stdio,
sync::Weak,
time::Duration,
};
use tokio::{io::AsyncWriteExt, process::Command};
const CERTBOT_PACKAGES: &[&str] = &[
"certbot==5.8.0",
"certbot-dns-cloudflare==5.8.0",
"certbot-dns-ovh==5.8.0",
"certbot-dns-rfc2136==5.8.0",
];
#[derive(Debug, Clone, Serialize)]
pub struct RuntimeStatus {
pub status: &'static str,
pub mode: &'static str,
pub message: &'static str,
pub managed_directory: Option<String>,
pub python: bool,
pub pip: bool,
pub certbot: bool,
}
impl Default for RuntimeStatus {
fn default() -> Self {
Self {
status: "checking",
mode: "none",
message: "acme.runtime_checking",
managed_directory: None,
python: false,
pip: false,
certbot: false,
}
}
}
impl Manager {
pub fn runtime_view(&self) -> RuntimeStatus {
self.runtime
.lock()
.unwrap_or_else(|error| error.into_inner())
.clone()
}
fn set_runtime(&self, value: RuntimeStatus) {
*self
.runtime
.lock()
.unwrap_or_else(|error| error.into_inner()) = value;
}
}
#[derive(Debug)]
struct RuntimeLaunch {
python: PathBuf,
prepend_path: Option<PathBuf>,
}
fn find_on_path(name: &str) -> Option<PathBuf> {
let candidate = Path::new(name);
if candidate.components().count() > 1 {
return candidate.is_file().then(|| candidate.to_owned());
}
env::var_os("PATH")
.into_iter()
.flat_map(|value| env::split_paths(&value).collect::<Vec<_>>())
.map(|directory| directory.join(name))
.find(|path| path.is_file())
}
fn system_python() -> Option<PathBuf> {
find_on_path("python3").or_else(|| find_on_path("python"))
}
#[cfg(windows)]
fn venv_bin(directory: &Path) -> PathBuf {
directory.join("Scripts")
}
#[cfg(not(windows))]
fn venv_bin(directory: &Path) -> PathBuf {
directory.join("bin")
}
#[cfg(windows)]
fn venv_python(directory: &Path) -> PathBuf {
venv_bin(directory).join("python.exe")
}
#[cfg(not(windows))]
fn venv_python(directory: &Path) -> PathBuf {
venv_bin(directory).join("python")
}
#[cfg(windows)]
fn venv_certbot(directory: &Path) -> PathBuf {
venv_bin(directory).join("certbot.exe")
}
#[cfg(not(windows))]
fn venv_certbot(directory: &Path) -> PathBuf {
venv_bin(directory).join("certbot")
}
async fn command_success(
mut command: Command,
timeout: Duration,
shutdown: &tokio_util::sync::CancellationToken,
) -> bool {
command
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.kill_on_drop(true);
let Ok(mut child) = command.spawn() else {
return false;
};
tokio::select! {
_ = shutdown.cancelled() => false,
result = tokio::time::timeout(timeout, child.wait()) => {
matches!(result, Ok(Ok(status)) if status.success())
}
}
}
async fn managed_environment_valid(
directory: &Path,
shutdown: &tokio_util::sync::CancellationToken,
) -> bool {
let python = venv_python(directory);
let certbot = venv_certbot(directory);
if !python.is_file() || !certbot.is_file() {
return false;
}
let mut imports = Command::new(&python);
imports.args([
"-I",
"-c",
"import certbot, certbot_dns_cloudflare, certbot_dns_ovh, certbot_dns_rfc2136",
]);
if !command_success(imports, Duration::from_secs(20), shutdown).await {
return false;
}
let mut version = Command::new(&certbot);
version.arg("--version");
command_success(version, Duration::from_secs(20), shutdown).await
}
async fn system_has_pip(
python: &Path,
shutdown: &tokio_util::sync::CancellationToken,
) -> bool {
let mut command = Command::new(python);
command.args(["-m", "pip", "--version"]);
command_success(command, Duration::from_secs(20), shutdown).await
}
async fn create_virtual_environment(
python: &Path,
staging: &Path,
system_pip: bool,
shutdown: &tokio_util::sync::CancellationToken,
) -> bool {
let mut create = Command::new(python);
create.arg("-m").arg("venv").arg(staging);
if command_success(create, Duration::from_secs(180), shutdown).await {
return true;
}
if !system_pip {
return false;
}
// Some distributions split the stdlib venv module into a separate package.
// Bootstrap virtualenv with the system pip into the RustProxy data directory
// instead of modifying site-packages owned by the distribution.
let bootstrap = staging.with_extension("virtualenv-bootstrap");
let _ = tokio::fs::remove_dir_all(&bootstrap).await;
let mut install = Command::new(python);
install.args([
"-m",
"pip",
"install",
"--disable-pip-version-check",
"--no-input",
"--no-cache-dir",
"--retries",
"1",
"--timeout",
"15",
"--target",
]);
install.arg(&bootstrap).arg("virtualenv>=20,<21");
if !command_success(install, Duration::from_secs(300), shutdown).await {
let _ = tokio::fs::remove_dir_all(&bootstrap).await;
return false;
}
let mut create = Command::new(python);
create
.arg("-c")
.arg("import runpy,sys; sys.path.insert(0,sys.argv[1]); sys.argv=['virtualenv',sys.argv[2]]; runpy.run_module('virtualenv',run_name='__main__')")
.arg(&bootstrap)
.arg(staging);
let created = command_success(create, Duration::from_secs(180), shutdown).await;
let _ = tokio::fs::remove_dir_all(&bootstrap).await;
created
}
#[derive(Clone, Copy, PartialEq, Eq)]
enum BuildFailure {
Bootstrap,
Install,
}
async fn build_managed_environment(
python: &Path,
target: &Path,
system_pip: bool,
shutdown: &tokio_util::sync::CancellationToken,
) -> Result<(), BuildFailure> {
let staging = target.with_extension("installing");
let _ = tokio::fs::remove_dir_all(&staging).await;
if !create_virtual_environment(python, &staging, system_pip, shutdown).await {
let _ = tokio::fs::remove_dir_all(&staging).await;
return Err(BuildFailure::Bootstrap);
}
let staging_python = venv_python(&staging);
let mut pip_check = Command::new(&staging_python);
pip_check.args(["-m", "pip", "--version"]);
if !command_success(pip_check, Duration::from_secs(30), shutdown).await {
let mut ensurepip = Command::new(&staging_python);
ensurepip.args(["-m", "ensurepip", "--upgrade"]);
if !command_success(ensurepip, Duration::from_secs(120), shutdown).await {
let _ = tokio::fs::remove_dir_all(&staging).await;
return Err(BuildFailure::Bootstrap);
}
}
let mut pip = Command::new(&staging_python);
pip.args([
"-m",
"pip",
"install",
"--disable-pip-version-check",
"--no-input",
"--no-cache-dir",
"--retries",
"1",
"--timeout",
"15",
]);
pip.args(CERTBOT_PACKAGES.iter().copied());
if !command_success(pip, Duration::from_secs(300), shutdown).await
|| !managed_environment_valid(&staging, shutdown).await
{
let _ = tokio::fs::remove_dir_all(&staging).await;
return Err(BuildFailure::Install);
}
if target.exists() && tokio::fs::remove_dir_all(target).await.is_err() {
let _ = tokio::fs::remove_dir_all(&staging).await;
return Err(BuildFailure::Install);
}
if tokio::fs::rename(&staging, target).await.is_err() {
let _ = tokio::fs::remove_dir_all(&staging).await;
return Err(BuildFailure::Install);
}
Ok(())
}
async fn ensure_runtime(state: &App) -> Result<RuntimeLaunch, String> {
let _permit = state
.acme
.runtime_slot
.clone()
.acquire_owned()
.await
.map_err(|_| "acme.runtime_setup_failed".to_owned())?;
let Some(data_dir) = state.policy.data_dir.as_deref() else {
state.acme.set_runtime(RuntimeStatus {
status: "unavailable",
mode: "none",
message: "acme.runtime_data_dir_missing",
managed_directory: None,
python: false,
pip: false,
certbot: false,
});
return Err("acme.runtime_data_dir_missing".into());
};
let managed = data_dir.join("acme-python");
let managed_directory = Some(managed.to_string_lossy().into_owned());
if managed_environment_valid(&managed, &state.shutdown).await {
state.acme.set_runtime(RuntimeStatus {
status: "ready",
mode: "managed",
message: "acme.runtime_ready",
managed_directory,
python: true,
pip: true,
certbot: true,
});
return Ok(RuntimeLaunch {
python: venv_python(&managed),
prepend_path: Some(venv_bin(&managed)),
});
}
let distro_certbot = find_on_path("certbot");
let distro_certbot_available = if let Some(certbot) = distro_certbot.as_deref() {
let mut version = Command::new(certbot);
version.arg("--version");
command_success(version, Duration::from_secs(20), &state.shutdown).await
} else {
false
};
let Some(python) = system_python() else {
state.acme.set_runtime(RuntimeStatus {
status: "unavailable",
mode: "none",
message: "acme.runtime_python_missing",
managed_directory,
python: false,
pip: false,
certbot: distro_certbot_available,
});
return Err("acme.runtime_python_missing".into());
};
let system_pip = system_has_pip(&python, &state.shutdown).await;
state.acme.set_runtime(RuntimeStatus {
status: "initializing",
mode: "managed",
message: "acme.runtime_initializing",
managed_directory: managed_directory.clone(),
python: true,
pip: system_pip,
certbot: distro_certbot_available,
});
let build = build_managed_environment(&python, &managed, system_pip, &state.shutdown).await;
if build.is_ok() {
state.acme.set_runtime(RuntimeStatus {
status: "ready",
mode: "managed",
message: "acme.runtime_ready",
managed_directory,
python: true,
pip: true,
certbot: true,
});
return Ok(RuntimeLaunch {
python: venv_python(&managed),
prepend_path: Some(venv_bin(&managed)),
});
}
if distro_certbot_available {
state.acme.set_runtime(RuntimeStatus {
status: "ready",
mode: "system",
message: "acme.runtime_fallback",
managed_directory,
python: true,
pip: system_pip,
certbot: true,
});
return Ok(RuntimeLaunch {
python,
prepend_path: None,
});
}
let message = if build == Err(BuildFailure::Bootstrap) && !system_pip {
"acme.runtime_pip_missing"
} else {
"acme.runtime_setup_failed"
};
state.acme.set_runtime(RuntimeStatus {
status: "unavailable",
mode: "none",
message,
managed_directory,
python: true,
pip: system_pip,
certbot: false,
});
Err(message.into())
}
pub async fn runtime_setup_once(state: Weak<App>) {
let Some(state) = state.upgrade() else {
return;
};
let _ = ensure_runtime(&state).await;
}
/// Kill the entire private process group if execution is cancelled/abandoned.
/// Certbot inherits the group, so it cannot outlive a stopped RustProxy worker.
struct ProcessGroup(Option<u32>);
impl Drop for ProcessGroup {
fn drop(&mut self) {
#[cfg(unix)]
if let Some(id) = self.0 {
// SAFETY: this is the process group created solely for this child.
unsafe {
libc::kill(-(id as i32), libc::SIGKILL);
}
}
}
}
fn merged_path(prepend: &Path) -> Result<OsString, String> {
let mut values = vec![prepend.to_owned()];
if let Some(current) = env::var_os("PATH") {
values.extend(env::split_paths(&current));
}
env::join_paths(values).map_err(|_| "acme.runtime_setup_failed".to_owned())
}
async fn worker(state: &App, directory: PathBuf, raw: Zeroizing<String>) -> Result<Value, String> {
let launch = ensure_runtime(state).await?;
let mut command = Command::new(&launch.python);
command
.arg("-I")
.arg("-c")
.arg(include_str!("../../scripts/acme_provider.py"))
.arg("--cert-dir")
.arg(&directory)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.kill_on_drop(true);
if let Some(prepend) = launch.prepend_path.as_deref() {
command.env("PATH", merged_path(prepend)?);
}
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
command.as_std_mut().process_group(0);
}
let mut child = command
.spawn()
.map_err(|_| "acme.python_not_available".to_owned())?;
let mut group = ProcessGroup(child.id());
let stdin = child
.stdin
.take()
.ok_or_else(|| "acme.worker_io".to_owned())?;
let work = async move {
let mut stdin = stdin;
stdin
.write_all(raw.as_bytes())
.await
.map_err(|_| "acme.worker_io".to_owned())?;
stdin
.shutdown()
.await
.map_err(|_| "acme.worker_io".to_owned())?;
drop(stdin);
drop(raw);
child
.wait_with_output()
.await
.map_err(|_| "acme.worker_io".to_owned())
};
let output = tokio::select! {
_ = state.shutdown.cancelled() => return Err("acme.cancelled".into()),
result = tokio::time::timeout(Duration::from_secs(2160), work) => result.map_err(|_| "acme.provider_timeout".to_owned())??,
};
group.0 = None;
if output.stdout.len() > 16384 {
return Err("acme.invalid_worker_response".into());
}
let value: Value = serde_json::from_slice(&output.stdout)
.map_err(|_| "acme.invalid_worker_response".to_owned())?;
if !output.status.success() {
let reason = value.get("error").and_then(Value::as_str).filter(|value| {
value.starts_with("acme.")
&& value.len() < 96
&& value
.bytes()
.all(|byte| byte.is_ascii_lowercase() || byte == b'_' || byte == b'.')
});
return Err(reason.unwrap_or("acme.worker_failed").to_owned());
}
Ok(value)
}
+1
View File
@@ -374,6 +374,7 @@ async fn acme_status(State(state): State<Arc<App>>) -> Json<Value> {
.unwrap_or_default();
Json(json!({
"job":state.acme.view(),
"runtime":state.acme.runtime_view(),
"providers":["http01","cloudflare","ovh","rfc2136"],
"automatic_renewal":true,
"interval_seconds":43200,
+1
View File
@@ -409,6 +409,7 @@ pub(super) static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
"/docs/V0.25.1_AUDIT.md"
)),
),
reader_doc!("docs/V0.27.1_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.27.0_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.26.7_IMPLEMENTATION.md", "technical"),
reader_doc!("docs/V0.26.6_IMPLEMENTATION.md", "technical"),
+113 -3
View File
@@ -2,7 +2,7 @@
"openapi": "3.0.3",
"info": {
"title": "RustProxy Administration API",
"version": "0.27.0",
"version": "0.27.1",
"description": "Administration API for the active panel origin. Sign in through the panel; the same-origin HttpOnly cookie authenticates all private operations. Mutations also require the exact Origin, X-RustProxy-Request: 1 and a session-bound X-CSRF-Token. Loopback HTTP is the default; Settings can enable HTTPS before using a non-loopback administration bind. This document contains no live configuration or credentials. Object schemas marked extensible are summaries: full configuration validation is authoritative in Rust."
},
"servers": [
@@ -3170,6 +3170,101 @@
"type": "boolean",
"default": false,
"description": "Account-scoped sort direction for the Backends diagnostics table."
},
"vhost_list_sort": {
"type": "string",
"enum": [
"id",
"hostname",
"status",
"listeners",
"certificate",
"backends"
],
"default": "id",
"description": "Account-scoped sort key for the Vhosts list."
},
"vhost_list_descending": {
"type": "boolean",
"default": false,
"description": "Account-scoped sort direction for the Vhosts list."
},
"docker_vhost_sort": {
"type": "string",
"enum": [
"service",
"hostname",
"status",
"backend",
"scheme",
"published"
],
"default": "service",
"description": "Account-scoped sort key for Docker vhost service rows."
},
"docker_vhost_descending": {
"type": "boolean",
"default": false,
"description": "Account-scoped sort direction for Docker vhost service rows."
},
"redirect_list_sort": {
"type": "string",
"enum": [
"id",
"name",
"hostname",
"status",
"target",
"rules",
"listeners",
"certificate"
],
"default": "id",
"description": "Account-scoped sort key for the Redirect hosts list."
},
"redirect_list_descending": {
"type": "boolean",
"default": false,
"description": "Account-scoped sort direction for the Redirect hosts list."
},
"notfound_list_sort": {
"type": "string",
"enum": [
"id",
"name",
"hostname",
"status",
"mode",
"listeners",
"certificate"
],
"default": "id",
"description": "Account-scoped sort key for the 404 hosts list."
},
"notfound_list_descending": {
"type": "boolean",
"default": false,
"description": "Account-scoped sort direction for the 404 hosts list."
},
"stream_list_sort": {
"type": "string",
"enum": [
"name",
"id",
"protocol",
"listen",
"upstream",
"status",
"maxConnections",
"idleTimeout"
],
"default": "name",
"description": "Account-scoped sort key for the Streams list."
},
"stream_list_descending": {
"type": "boolean",
"default": false,
"description": "Account-scoped sort direction for the Streams list."
}
},
"required": [
@@ -4372,13 +4467,28 @@
"database_path": {
"type": "string",
"nullable": true
},
"certificate_runtime": {
"type": "object",
"description": "Python/pip/Certbot capability used by managed certificate automation.",
"properties": {
"status": { "type": "string", "enum": ["checking", "initializing", "ready", "unavailable"] },
"mode": { "type": "string", "enum": ["none", "managed", "system"] },
"message": { "type": "string" },
"managed_directory": { "type": "string", "nullable": true },
"python": { "type": "boolean" },
"pip": { "type": "boolean" },
"certbot": { "type": "boolean" }
},
"required": ["status", "mode", "message", "managed_directory", "python", "pip", "certbot"]
}
},
"required": [
"platform",
"statistics_source",
"data_directory",
"database_path"
"database_path",
"certificate_runtime"
]
},
"CertificateApiCredential": {
@@ -5528,7 +5638,7 @@
}
}
},
"description": "Sets an HttpOnly SameSite=Strict session cookie. Browser sessions use the configured 7\u201330 day absolute lifetime. Use the panel sign-in page. No bearer token or URL token is accepted.",
"description": "Sets an HttpOnly SameSite=Strict session cookie. Browser sessions use the configured 7–30 day absolute lifetime. Use the panel sign-in page. No bearer token or URL token is accepted.",
"security": [],
"parameters": [
{
+121
View File
@@ -30,6 +30,51 @@ const BACKEND_TABLE_COLUMNS: &[&str] = &[
"lastFailure",
"lastRecovery",
];
const VHOST_LIST_SORT_COLUMNS: &[&str] = &[
"id",
"hostname",
"status",
"listeners",
"certificate",
"backends",
];
const DOCKER_VHOST_SORT_COLUMNS: &[&str] = &[
"service",
"hostname",
"status",
"backend",
"scheme",
"published",
];
const REDIRECT_LIST_SORT_COLUMNS: &[&str] = &[
"id",
"name",
"hostname",
"status",
"target",
"rules",
"listeners",
"certificate",
];
const NOTFOUND_LIST_SORT_COLUMNS: &[&str] = &[
"id",
"name",
"hostname",
"status",
"mode",
"listeners",
"certificate",
];
const STREAM_LIST_SORT_COLUMNS: &[&str] = &[
"name",
"id",
"protocol",
"listen",
"upstream",
"status",
"maxConnections",
"idleTimeout",
];
const DASHBOARD_WIDGETS: &[&str] = &[
"requests",
"active",
@@ -98,6 +143,21 @@ fn default_backend_table_columns() -> Vec<String> {
fn default_backend_table_sort() -> String {
"vhost".to_owned()
}
fn default_vhost_list_sort() -> String {
"id".to_owned()
}
fn default_docker_vhost_sort() -> String {
"service".to_owned()
}
fn default_redirect_list_sort() -> String {
"id".to_owned()
}
fn default_notfound_list_sort() -> String {
"id".to_owned()
}
fn default_stream_list_sort() -> String {
"name".to_owned()
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
@@ -131,6 +191,26 @@ struct AdminPreferences {
backend_table_sort: String,
#[serde(default)]
backend_table_descending: bool,
#[serde(default = "default_vhost_list_sort")]
vhost_list_sort: String,
#[serde(default)]
vhost_list_descending: bool,
#[serde(default = "default_docker_vhost_sort")]
docker_vhost_sort: String,
#[serde(default)]
docker_vhost_descending: bool,
#[serde(default = "default_redirect_list_sort")]
redirect_list_sort: String,
#[serde(default)]
redirect_list_descending: bool,
#[serde(default = "default_notfound_list_sort")]
notfound_list_sort: String,
#[serde(default)]
notfound_list_descending: bool,
#[serde(default = "default_stream_list_sort")]
stream_list_sort: String,
#[serde(default)]
stream_list_descending: bool,
}
impl Default for AdminPreferences {
@@ -154,6 +234,16 @@ impl Default for AdminPreferences {
backend_table_columns: default_backend_table_columns(),
backend_table_sort: default_backend_table_sort(),
backend_table_descending: false,
vhost_list_sort: default_vhost_list_sort(),
vhost_list_descending: false,
docker_vhost_sort: default_docker_vhost_sort(),
docker_vhost_descending: false,
redirect_list_sort: default_redirect_list_sort(),
redirect_list_descending: false,
notfound_list_sort: default_notfound_list_sort(),
notfound_list_descending: false,
stream_list_sort: default_stream_list_sort(),
stream_list_descending: false,
}
}
}
@@ -323,6 +413,37 @@ fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiE
"invalid backend table sort preference".into(),
));
}
for (value, allowed, message) in [
(
&preferences.vhost_list_sort,
VHOST_LIST_SORT_COLUMNS,
"invalid vhost list sort preference",
),
(
&preferences.docker_vhost_sort,
DOCKER_VHOST_SORT_COLUMNS,
"invalid Docker vhost sort preference",
),
(
&preferences.redirect_list_sort,
REDIRECT_LIST_SORT_COLUMNS,
"invalid redirect list sort preference",
),
(
&preferences.notfound_list_sort,
NOTFOUND_LIST_SORT_COLUMNS,
"invalid 404-host sort preference",
),
(
&preferences.stream_list_sort,
STREAM_LIST_SORT_COLUMNS,
"invalid stream list sort preference",
),
] {
if !allowed.contains(&value.as_str()) {
return Err(ApiError(StatusCode::UNPROCESSABLE_ENTITY, message.into()));
}
}
Ok(())
}
+2 -1
View File
@@ -182,7 +182,8 @@ fn status_payload(state: &App) -> Value {
"platform": system.platform,
"statistics_source": system.source,
"data_directory": state.policy.data_dir.as_ref().map(|path| path.display().to_string()),
"database_path": state.policy.data_dir.as_ref().map(|path| path.join("config.sqlite3").display().to_string())
"database_path": state.policy.data_dir.as_ref().map(|path| path.join("config.sqlite3").display().to_string()),
"certificate_runtime": state.acme.runtime_view()
});
payload["system"] = json!(system);
payload["backend_pools"] = json!(snapshot.pools.iter().map(|p| p.view()).collect::<Vec<_>>());
+25 -8
View File
@@ -1,6 +1,6 @@
"use strict";
(() => {
let job = null, pending = false, loading = false, renewalLogs = {}, resetIssueFormJob = null;
let job = null, pending = false, loading = false, renewalLogs = {}, resetIssueFormJob = null, runtimeAvailable = true;
const picker = $("certificate-provider");
const secrets = ["acme-secret", "acme-cf-token", "acme-ovh-application-key", "acme-ovh-application-secret", "acme-ovh-consumer-key"];
const tabs = [...document.querySelectorAll("[data-certificate-tab]")];
@@ -44,6 +44,9 @@
clearSecrets();
syncProvider();
}
function syncSubmitState() {
$("acme-submit").disabled = pending || job?.status === "running" || !runtimeAvailable;
}
function syncProvider() {
const provider = picker.value, acme = !["import", "existing"].includes(provider);
$("certificate-acme-editor").hidden = !acme;
@@ -56,6 +59,7 @@
for (const input of node.querySelectorAll("input,select")) input.disabled = !visible;
}
$("acme-domains").placeholder = provider === "http01" ? "example.org, www.example.org" : "example.org, *.example.org";
syncSubmitState();
}
function open(provider = "http01") {
selectTab("generate"); picker.value = provider;
@@ -67,6 +71,19 @@
$("certificate-new").addEventListener("click", () => open());
const stateKey = value => ({ running: "certificateStateRunning", ready: "certificateStateReady", complete: "certificateStateComplete", failed: "certificateStateFailed" }[value?.status] || "certificateStateIdle");
function renderRuntime(value) {
const status = value?.status || "checking", mode = value?.mode || "none";
runtimeAvailable = status !== "unavailable";
$("acme-runtime-card").dataset.state = status;
$("acme-runtime-message").textContent = t(value?.message || "acme.runtime_checking");
$("acme-runtime-state").textContent = t(status === "unavailable" ? "certificateRuntimeUnavailable" : status === "ready" && mode === "managed" ? "certificateRuntimeManaged" : status === "ready" && mode === "system" ? "certificateRuntimeSystem" : "certificateRuntimePreparing");
const path = value?.managed_directory;
$("acme-runtime-path").hidden = !path;
$("acme-runtime-path").textContent = path ? `${t("certificateRuntimePath")}: ${path}` : "";
const availability = flag => t(flag === true ? "runtimeAvailable" : flag === false ? "runtimeUnavailable" : "certificateRuntimePreparing");
$("acme-runtime-components").textContent = `${t("runtimePython")}: ${availability(value?.python)} · ${t("runtimePip")}: ${availability(value?.pip)} · ${t("runtimeCertbot")}: ${availability(value?.certbot)}`;
syncSubmitState();
}
function renderOperation(cardId, messageId, stateId, logId, value, idleKey) {
const card = $(cardId), message = $(messageId), state = $(stateId), log = $(logId);
card.dataset.state = value?.status || "idle";
@@ -83,7 +100,7 @@
if (value.status === "ready") { resetIssueForm(); resetIssueFormJob = null; }
else if (value.status === "failed") resetIssueFormJob = null;
}
$("acme-submit").disabled = pending || value?.status === "running";
syncSubmitState();
const issue = value?.kind === "issue" ? value : null;
const renewal = value?.kind?.startsWith("renewal") ? value : null;
renderOperation("acme-issue-status-card", "acme-job", "acme-issue-state", "acme-log-path", issue, "acmeIdle");
@@ -131,7 +148,7 @@
if (epoch === authEpoch) toast(error.message, true);
} finally { node.disabled = false; }
});
node.disabled = job?.status === "running";
node.disabled = job?.status === "running" || !runtimeAvailable;
}
entry.append(controls); list.append(entry);
}
@@ -142,14 +159,14 @@
if (!authenticated || currentPanel !== "tls" || loading) return; loading = true; const epoch = authEpoch;
try {
const data = await api("/api/acme");
if (authenticated && epoch === authEpoch) { render(data.job); renderRenewals(data.renewals, data.log_directories); }
if (authenticated && epoch === authEpoch) { renderRuntime(data.runtime); render(data.job); renderRenewals(data.renewals, data.log_directories); }
} catch (error) {
if (epoch === authEpoch) { toast(error.message, true); if (error.status === 401) lock(); }
} finally { loading = false; }
}
$("acme-environment").addEventListener("change", () => { $("acme-confirm-production").required = $("acme-environment").value === "production"; });
$("acme-form").addEventListener("submit", async event => {
event.preventDefault(); if (!authenticated || pending || job?.status === "running") return;
event.preventDefault(); if (!authenticated || pending || job?.status === "running" || !runtimeAvailable) return;
const provider = picker.value, input = {
provider, certificate_id: $("acme-name").value.trim(), email: $("acme-email").value.trim(), domains: $("acme-domains").value.split(/[,\n]/).map(v => v.trim()).filter(Boolean), environment: $("acme-environment").value,
agree_tos: $("acme-tos").checked, confirm_production: $("acme-confirm-production").checked
@@ -168,7 +185,7 @@
} catch (error) { if (epoch === authEpoch) toast(error.message, true); }
finally {
for (const key of ["key_secret", "api_token", "application_key", "application_secret", "consumer_key"]) delete input[key];
clearSecrets(); pending = false; $("acme-submit").disabled = job?.status === "running";
clearSecrets(); pending = false; syncSubmitState();
}
});
$("acme-refresh").addEventListener("click", () => void refresh());
@@ -187,8 +204,8 @@
syncLocation,
current: () => activeTab,
reset() {
job = null; renewalLogs = {}; resetIssueFormJob = null; $("renewal-list").replaceChildren(); resetIssueForm();
render(null); selectTab("installed", false, false);
job = null; renewalLogs = {}; resetIssueFormJob = null; runtimeAvailable = true; $("renewal-list").replaceChildren(); resetIssueForm();
renderRuntime(null); render(null); selectTab("installed", false, false);
}
};
window.rustProxyCertificates = { open, close, select: selectTab, reset() { clearSecrets(); picker.value = "http01"; syncProvider(); selectTab("installed", false, false); } };
+16 -3
View File
@@ -192,8 +192,21 @@ function enhanceEnabledControl(input, hintKey = "entityEnableHint") {
}
window.rustProxyEnableControl = enhanceEnabledControl;
function action(label, fn) { const b = el("button", label, "quiet"); b.type = "button"; b.addEventListener("click", fn); return b; }
function renderAboutRuntime(status = latestStatus) {
const value = status?.runtime?.certificate_runtime || {};
const availability = flag => flag === true ? t("runtimeAvailable") : flag === false ? t("runtimeUnavailable") : t("unavailable");
const mode = value.status === "unavailable" ? t("certificateRuntimeUnavailable") : value.status === "ready" && value.mode === "managed" ? t("certificateRuntimeManaged") : value.status === "ready" && value.mode === "system" ? t("certificateRuntimeSystem") : t("certificateRuntimePreparing");
const modeNode = $("about-acme-mode"), runtimeNode = $("about-python-runtime"), details = $("about-python-details");
if (modeNode) modeNode.textContent = mode;
if (runtimeNode) runtimeNode.textContent = `${t("runtimePython")}: ${availability(value.python)} · ${t("runtimePip")}: ${availability(value.pip)} · ${t("runtimeCertbot")}: ${availability(value.certbot)}`;
if (details) {
const parts = [value.message ? t(value.message) : ""];
if (value.managed_directory) parts.push(`${t("certificateRuntimePath")}: ${value.managed_directory}`);
details.textContent = parts.filter(Boolean).join(" · ");
}
}
function renderStatus(s) {
const firstStatus = latestStatus === null; latestStatus = s; window.rustProxySettings?.render(); if (firstStatus) renderSettings();
const firstStatus = latestStatus === null; latestStatus = s; renderAboutRuntime(s); window.rustProxySettings?.render(); if (firstStatus) renderSettings();
connected = true; $("connection-label").textContent = t("connected"); $("signal").classList.remove("status-error"); const m = s.metrics;
$("metric-ws").textContent = m.websocket_active || 0;
$("metric-ws-h1").textContent = m.websocket_h1_active || 0;
@@ -334,7 +347,7 @@ function lock(notify = true) {
if (navigating) { document.documentElement.classList.add("app-loading"); location.replace(destination); }
}
async function activate(info) { pendingAuthDestination = null; authMessage(""); $("api-docs").hidden = false; $("logs-menu").hidden = false; $("logs-open").hidden = false; $("file-logs-open").hidden = false; authEpoch++; sessionInfo = info; csrf = info.csrf_token; authenticated = true; const preferences = await loadAdminPreferences(); window.rustProxyLogs?.applyPreferences(preferences); window.rustProxyFileLogs?.applyPreferences(preferences); window.rustProxyVhosts?.applyPreferences(preferences); await loadConfig(); $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("login").hidden = true; $("workspace").hidden = false; $("logout").hidden = false; renderSession(); await refresh(true); startLive(); document.documentElement.classList.remove("app-loading"); }
async function activate(info) { pendingAuthDestination = null; authMessage(""); $("api-docs").hidden = false; $("logs-menu").hidden = false; $("logs-open").hidden = false; $("file-logs-open").hidden = false; authEpoch++; sessionInfo = info; csrf = info.csrf_token; authenticated = true; const preferences = await loadAdminPreferences(); window.rustProxyLogs?.applyPreferences(preferences); window.rustProxyFileLogs?.applyPreferences(preferences); window.rustProxyVhosts?.applyPreferences(preferences); window.rustProxyDocker?.applyPreferences(preferences); window.rustProxyRedirects?.applyPreferences(preferences); window.rustProxyNotFound?.applyPreferences(preferences); window.rustProxyStreams?.applyPreferences(preferences); await loadConfig(); $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("login").hidden = true; $("workspace").hidden = false; $("logout").hidden = false; renderSession(); await refresh(true); startLive(); document.documentElement.classList.remove("app-loading"); }
async function loadAudit() { if (!authenticated) return; const epoch = authEpoch; try { const data = await api("/api/audit"); if (!authenticated || epoch !== authEpoch) return; const rows = data.entries.map(item => { const tr = el("tr"); for (const value of [formatDateTime(item.time * 1000), item.actor, item.action, item.detail]) { const td = el("td", value); td.title = value; tr.append(td); } return tr; }); $("audit-rows").replaceChildren(...rows); $("empty-audit").hidden = rows.length > 0; } catch (e) { toast(e.message, true); } }
let tunnelRefreshTimer = null;
function tunnelMessage(value = "", error = false) { const node = $("tunnels-status"); if (!node) return; node.textContent = value; node.classList.toggle("error", !!error); }
@@ -371,7 +384,7 @@ function stopTunnelRefresh() { if (tunnelRefreshTimer) clearInterval(tunnelRefre
function startTunnelRefresh() { stopTunnelRefresh(); tunnelRefreshTimer = setInterval(() => { if ($("tunnels-dialog")?.open) void loadTunnels(); }, 2000); }
$("login-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; authMessage(""); try { await withFormSubmitState(form, event.submitter, async () => { const username = $("username").value.trim(); const password = $("password").value; if (setupRequired) { if (password !== $("password-confirm").value) throw fieldError("password-confirm", t("passwordMismatch")); await api("/api/auth/setup", { method: "POST", body: JSON.stringify({ token: $("setup-token").value.trim(), username, password }) }); setupRequired = false; syncAuthForm(); } const info = await api("/api/auth/login", { method: "POST", body: JSON.stringify({ username, password }) }); await activate(info); clearTimeout(toastTimer); hideToast(); }); } catch (e) { if (e.code === "auth.already_initialized") { setupRequired = false; syncAuthForm(); } $("password").value = ""; $("password-confirm").value = ""; authMessage(e.message); } });
$("logout").addEventListener("click", async () => { try { await api("/api/auth/logout", { method: "POST" }); lock(); toast(t("locked")); } catch (e) { if (e.status === 401) lock(); toast(e.message, true); } });
$("about-open").addEventListener("click", () => { const dialog = $("about-dialog"); if (!dialog.open) dialog.showModal(); });
$("about-open").addEventListener("click", () => { renderAboutRuntime(); const dialog = $("about-dialog"); if (!dialog.open) dialog.showModal(); });
$("about-close").addEventListener("click", () => $("about-dialog").close());
const mobileMenuToggle = $("mobile-menu-toggle"), dashboardSidebar = document.querySelector(".sidebar");
function setMobileMenu(open) {
+4
View File
@@ -136,6 +136,10 @@ function applyAdminPreferences(value, cache = true) {
};
adminPreferences = normalized;
window.rustProxyVhosts?.applyPreferences(normalized);
window.rustProxyDocker?.applyPreferences(normalized);
window.rustProxyRedirects?.applyPreferences(normalized);
window.rustProxyNotFound?.applyPreferences(normalized);
window.rustProxyStreams?.applyPreferences(normalized);
if (languagePacks.size) translate();
refreshAppearanceUi(); return normalized;
}
+23 -3
View File
@@ -1,6 +1,11 @@
"use strict";
(() => {
let observations = [], pools = [], poolRoutes = [], poolIndex = new Map(), showHidden = false;
const sortColumns = ["service", "hostname", "status", "backend", "scheme", "published"];
let observations = [], pools = [], poolRoutes = [], poolIndex = new Map(), showHidden = false, listPrefs = { sort: "service", descending: false };
function normalizeListPrefs(preferences) { return { sort: sortColumns.includes(preferences?.docker_vhost_sort) ? preferences.docker_vhost_sort : "service", descending: preferences?.docker_vhost_descending === true }; }
function syncSortDirection() { const control = $("docker-vhost-sort-direction"); if (!control) return; control.textContent = listPrefs.descending ? "↓" : "↑"; control.setAttribute("aria-label", t(listPrefs.descending ? "backendSortDescending" : "backendSortAscending")); }
function applyPreferences(preferences) { listPrefs = normalizeListPrefs(preferences); if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); applyViewFilters(); }
async function saveListPrefs() { if (!authenticated) return; try { listPrefs = normalizeListPrefs(await window.rustProxyPreferences.update({ docker_vhost_sort: listPrefs.sort, docker_vhost_descending: listPrefs.descending })); if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); applyViewFilters(); } catch (error) { if (error.status !== 401) toast(error.message, true); } }
const LIVE_KEY = "rustproxy.docker.live-view";
const HIDDEN_KEY = "rustproxy.docker.hidden-vhosts";
const cfg = () => envelope?.config?.docker_agents || { agents: [] };
@@ -269,11 +274,17 @@
function assignmentRow(agent, inventory, service, assignment, stale = false) {
const key = assignment?.service_key || service.key, row = el("article", undefined, "docker-service-row"); row.dataset.key = key; row.dataset.viewKey = viewKey(agent.id, key);
const runtime = runtimeFor(agent.id, key), member = runtime?.members?.[0], technicalName = service?.technical_name || assignment?.service_key || "";
row.dockerSort = {
service: technicalName.toLowerCase(), hostname: String(legacyHostname(assignment) || technicalName).toLowerCase(),
status: stale ? 4 : assignment?.enabled === false || !assignment ? 3 : member?.status === "up" ? 0 : member?.status === "down" ? 1 : 2,
backend: String(assignment?.target_ip || agent.default_target_ip || inventory?.public_ip || "").toLowerCase(), scheme: String(assignment?.scheme || "http"), published: assignment && assignment.enabled !== false ? 0 : 1,
};
const top = el("div", undefined, "docker-service-head"), publish = input("", "checkbox", "docker-publish"); publish.checked = !!assignment && assignment.enabled !== false;
const publishLabel = el("label", undefined, "check docker-publish-field"); publishLabel.append(publish, el("span", t("dockerPublish")));
const meta = el("div", undefined, "docker-service-meta"), technical = service?.technical_name || assignment?.service_key || "-", bindings = serviceBindings(service).map(binding => `${binding.bind_ip || "*"}:${binding.public_port}`).join(", ");
meta.append(el("b", technical), el("span", stale ? t("dockerServiceMissing") : `${service.container_name} · ${service.image}`), el("span", stale ? assignment.service_key : `${service.private_port}/tcp → ${bindings || "-"}`));
const runtime = runtimeFor(agent.id, key), visibility = el("button", "", "quiet docker-view-toggle"); visibility.type = "button";
const visibility = el("button", "", "quiet docker-view-toggle"); visibility.type = "button";
const syncVisibility = () => { visibility.textContent = t(hiddenVhosts.has(row.dataset.viewKey) ? "dockerUnhideVhost" : "dockerHideVhost"); }; syncVisibility(); visibility.addEventListener("click", () => { const key = row.dataset.viewKey; if (hiddenVhosts.has(key)) hiddenVhosts.delete(key); else hiddenVhosts.add(key); saveHidden(); syncVisibility(); applyViewFilters(); });
const openApp = el("a", t("dockerOpenApp"), "outline docker-open-app"); openApp.target = "_blank"; openApp.rel = "noopener noreferrer";
const syncOpenApp = hostname => { const url = proxiedUrl(agent, hostname); openApp.href = url || "#"; openApp.hidden = !publish.checked || !url; openApp.title = url || ""; };
@@ -326,9 +337,16 @@
}
applyViewFilters();
}
function sortServiceRows(card) {
const holder = card.querySelector(".docker-service-list"); if (!holder) return;
const rows = [...holder.querySelectorAll(":scope > .docker-service-row")];
rows.sort((left, right) => { const a = left.dockerSort?.[listPrefs.sort] ?? "", b = right.dockerSort?.[listPrefs.sort] ?? ""; const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" }); return listPrefs.descending ? -result : result; });
for (const row of rows) holder.append(row);
}
function applyViewFilters() {
const query = ($("docker-search")?.value || "").trim().toLowerCase();
for (const card of document.querySelectorAll("#docker-discovery-list .docker-agent-card")) {
sortServiceRows(card);
const agentMatch = !!query && (card.dataset.searchText || "").includes(query);
const rows = [...card.querySelectorAll(".docker-service-row")]; let visible = 0;
for (const row of rows) {
@@ -355,10 +373,12 @@
$("docker-live-refresh")?.addEventListener("change", event => { localStorage.setItem(LIVE_KEY, event.target.checked ? "1" : "0"); if (event.target.checked) void refresh(false); });
$("docker-settings-open")?.addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("docker-agents"); });
$("docker-search")?.addEventListener("input", applyViewFilters);
$("docker-vhost-sort")?.addEventListener("change", event => { listPrefs.sort = event.target.value; void saveListPrefs(); applyViewFilters(); });
$("docker-vhost-sort-direction")?.addEventListener("click", () => { listPrefs.descending = !listPrefs.descending; syncSortDirection(); void saveListPrefs(); applyViewFilters(); });
$("docker-show-hidden")?.addEventListener("click", () => { showHidden = !showHidden; applyViewFilters(); });
$("docker-agent-add")?.addEventListener("click", () => { const target = $("docker-agent-settings-list"); target.querySelector(".empty")?.remove(); target.append(agentSettingsRow()); setSettingsDirty("docker-agents-form"); });
$("docker-agents-form")?.addEventListener("input", () => setSettingsDirty("docker-agents-form")); $("docker-agents-form")?.addEventListener("change", () => setSettingsDirty("docker-agents-form"));
$("docker-agents-form")?.addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { if (await saveSection("docker-agents", collectSettings())) { setSettingsDirty("docker-agents-form", false); await refresh(true); } }); } catch (error) { toast(error.message, true); } });
setInterval(() => { if (authenticated && liveEnabled() && typeof currentPanel !== "undefined" && currentPanel === "docker-vhosts" && canLiveRender()) void refresh(false); }, 5000);
window.rustProxyDocker = { render() { syncLiveSwitch(); renderSettings(); renderDiscovery(); }, refresh, reset() { observations = []; pools = []; poolRoutes = []; poolIndex = new Map(); showHidden = false; hiddenVhosts = loadHidden(); if ($("docker-search")) $("docker-search").value = ""; $("docker-discovery-list")?.replaceChildren(); $("docker-agent-settings-list")?.replaceChildren(); syncLiveSwitch(); applyViewFilters(); } };
window.rustProxyDocker = { render() { syncLiveSwitch(); renderSettings(); renderDiscovery(); }, refresh, applyPreferences, reset() { observations = []; pools = []; poolRoutes = []; poolIndex = new Map(); showHidden = false; listPrefs = { sort: "service", descending: false }; hiddenVhosts = loadHidden(); if ($("docker-search")) $("docker-search").value = ""; if ($("docker-vhost-sort")) $("docker-vhost-sort").value = listPrefs.sort; syncSortDirection(); $("docker-discovery-list")?.replaceChildren(); $("docker-agent-settings-list")?.replaceChildren(); syncLiveSwitch(); applyViewFilters(); } };
})();
+10 -3
View File
@@ -2,7 +2,12 @@
(() => {
const form = $("notfound-form"), dialog = $("notfound-dialog"), list = $("notfound-list");
if (!form || !dialog || !list) return;
let editing = null, revision = null, dirty = false, readBindings = () => [], readHosts = () => [];
const sortColumns = ["id", "name", "hostname", "status", "mode", "listeners", "certificate"];
let editing = null, revision = null, dirty = false, readBindings = () => [], readHosts = () => [], listPrefs = { sort: "id", descending: false };
function normalizeListPrefs(preferences) { return { sort: sortColumns.includes(preferences?.notfound_list_sort) ? preferences.notfound_list_sort : "id", descending: preferences?.notfound_list_descending === true }; }
function syncSortDirection() { const control = $("notfound-sort-direction"); if (!control) return; control.textContent = listPrefs.descending ? "↓" : "↑"; control.setAttribute("aria-label", t(listPrefs.descending ? "backendSortDescending" : "backendSortAscending")); }
function applyPreferences(preferences) { listPrefs = normalizeListPrefs(preferences); if ($("notfound-sort")) $("notfound-sort").value = listPrefs.sort; syncSortDirection(); render(); }
async function saveListPrefs() { if (!authenticated) return; try { listPrefs = normalizeListPrefs(await window.rustProxyPreferences.update({ notfound_list_sort: listPrefs.sort, notfound_list_descending: listPrefs.descending })); if ($("notfound-sort")) $("notfound-sort").value = listPrefs.sort; syncSortDirection(); } catch (error) { if (error.status !== 401) toast(error.message, true); } }
const syncEnabledControl = window.rustProxyEnableControl($("notfound-enabled"));
const ERROR_STATUSES = [400, 401, 403, 404, 408, 413, 429, 500, 502, 503, 504];
const BUILTIN_STATUSES = [401, 403, 404, 429, 503, 504];
@@ -70,6 +75,8 @@
function render() {
if (!envelope) return; const query = ($("notfound-search").value || "").trim().toLowerCase();
const entries = (envelope.config.not_found_hosts || []).filter(item => !query || [item.id, item.name, ...(item.frontend?.hosts || [])].join(" ").toLowerCase().includes(query));
const sortValue = item => ({ id: String(item.id || "").toLowerCase(), name: String(item.name || item.id || "").toLowerCase(), hostname: String(item.frontend?.hosts?.[0] || "").toLowerCase(), status: Number(item.page?.status ?? 404), mode: String(item.page?.kind || "welcome"), listeners: (item.frontend?.listener_ids || []).join(" ").toLowerCase(), certificate: String(item.certificate_id || "").toLowerCase() }[listPrefs.sort]);
entries.sort((left, right) => { const a = sortValue(left), b = sortValue(right); const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" }); return listPrefs.descending ? -result : result; });
list.replaceChildren(...entries.map(item => {
const card = el("article", undefined, "vhost-card redirect-card"), head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity"), title = el("div", undefined, "vhost-title-line");
title.append(el("strong", item.name || item.id, "entity-title")); if (item.enabled === false) title.append(el("span", t("vhDisabled"), "pill")); identity.append(title, el("div", (item.frontend?.hosts || []).join(" · "), "vhost-hosts")); head.append(identity);
@@ -80,8 +87,8 @@
}
form.addEventListener("input", changed); form.addEventListener("change", changed); $("notfound-mode").addEventListener("change", syncMode); $("notfound-status").addEventListener("change", syncMode);
form.addEventListener("submit", async event => { event.preventDefault(); if (!envelope || busy) return; const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) { invalid.reportValidity(); return; } if (revision !== envelope.revision) { toast(t("stale"), true); return; } try { const item = collect(), next = structuredClone(envelope.config.not_found_hosts || []); if (!editing && next.some(value => value.id === item.id)) throw new Error(t("notfoundDuplicate")); const index = next.findIndex(value => value.id === editing); if (index < 0) next.push(item); else next[index] = item; if (await saveSection("not_found_hosts", next)) { dirty = false; editing = item.id; dialog.close(); render(); } } catch (error) { toast(error.message, true); } });
$("notfound-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); }); $("notfound-search").addEventListener("input", render); $("notfound-close").addEventListener("click", closeEditor); $("notfound-reset").addEventListener("click", async () => { if (await allowDiscard()) load(editing ? structuredClone((envelope?.config.not_found_hosts || []).find(item => item.id === editing) || defaults()) : defaults()); }); dialog.addEventListener("cancel", event => { event.preventDefault(); closeEditor(); });
$("notfound-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); }); $("notfound-search").addEventListener("input", render); $("notfound-sort")?.addEventListener("change", event => { listPrefs.sort = event.target.value; void saveListPrefs(); render(); }); $("notfound-sort-direction")?.addEventListener("click", () => { listPrefs.descending = !listPrefs.descending; syncSortDirection(); void saveListPrefs(); render(); }); $("notfound-close").addEventListener("click", closeEditor); $("notfound-reset").addEventListener("click", async () => { if (await allowDiscard()) load(editing ? structuredClone((envelope?.config.not_found_hosts || []).find(item => item.id === editing) || defaults()) : defaults()); }); dialog.addEventListener("cancel", event => { event.preventDefault(); closeEditor(); });
window.rustProxyDrafts.register("notfound", { labelKey: "notfound", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: () => { dirty = false; if (dialog.open) dialog.close(); } });
window.rustProxyNotFound = { render, reset() { dialog.close(); editing = null; revision = null; dirty = false; list.replaceChildren(); $("notfound-search").value = ""; } };
window.rustProxyNotFound = { render, applyPreferences, reset() { dialog.close(); editing = null; revision = null; dirty = false; listPrefs = { sort: "id", descending: false }; list.replaceChildren(); $("notfound-search").value = ""; if ($("notfound-sort")) $("notfound-sort").value = listPrefs.sort; syncSortDirection(); } };
if (envelope) render();
})();
+189 -49
View File
@@ -86,7 +86,9 @@
</div>
<div hidden="" id="setup-fields">
<p class="fine" data-i18n="setupHelp">Create the first administrator using a one-time local
capability.</p><code class="command">rust-proxy --data-dir DATA_DIR admin setup-token</code>
capability.</p><code class="command">rust-proxy --data-dir ./data admin setup-token</code>
<p class="fine" data-i18n="setupCommandExample">Standard service installation example:</p>
<code class="command">rust-proxy --data-dir /var/lib/rustproxy admin setup-token</code>
<label data-i18n="setupToken" for="setup-token">One-time setup token</label><input
autocomplete="off" id="setup-token" maxlength="64" minlength="64" spellcheck="false"
type="password" />
@@ -649,6 +651,17 @@
<label class="sr-only" data-i18n="vhSearch" for="vhost-search">Search vhosts</label>
<input autocomplete="off" data-i18n-aria="vhSearch"
data-i18n-placeholder="vhSearchPlaceholder" id="vhost-search" type="search" />
<label class="backend-sort-control"><span data-i18n="backendSortBy">Sort
by</span><select data-i18n-aria="backendSortBy" id="vhost-sort">
<option data-i18n="vhVhost" value="id">Vhost</option>
<option data-i18n="vhHosts" value="hostname">Hosts</option>
<option data-i18n="status" value="status">Status</option>
<option data-i18n="vhListeners" value="listeners">Listeners</option>
<option data-i18n="vhCertificate" value="certificate">Certificate</option>
<option data-i18n="vhBackendPool" value="backends">Backends</option>
</select></label>
<button class="quiet backend-sort-direction" data-i18n-aria="backendSortAscending"
id="vhost-sort-direction" type="button">↑</button>
<button class="outline" data-i18n="vhNew" id="vhost-new" type="button">New
vhost</button>
</div>
@@ -691,7 +704,18 @@
<div class="toolbar-actions"><label class="sr-only" data-i18n="dockerSearch"
for="docker-search">Search Docker vhosts</label><input autocomplete="off"
data-i18n-aria="dockerSearch" data-i18n-placeholder="dockerSearchPlaceholder"
id="docker-search" type="search" /><label class="check compact-check docker-live-switch"
id="docker-search" type="search" /><label class="backend-sort-control"><span
data-i18n="backendSortBy">Sort by</span><select data-i18n-aria="backendSortBy"
id="docker-vhost-sort">
<option data-i18n="dockerServices" value="service">Service</option>
<option data-i18n="dockerHostname" value="hostname">Hostname</option>
<option data-i18n="status" value="status">Status</option>
<option data-i18n="dockerBackendIp" value="backend">Backend host IP</option>
<option data-i18n="dockerBackendScheme" value="scheme">Backend protocol</option>
<option data-i18n="dockerPublish" value="published">Published</option>
</select></label><button class="quiet backend-sort-direction"
data-i18n-aria="backendSortAscending" id="docker-vhost-sort-direction"
type="button">↑</button><label class="check compact-check docker-live-switch"
title="Refresh this view from RustProxy memory every 5 seconds"><input
id="docker-live-refresh" type="checkbox" /><span data-i18n="dockerLiveView">Live
view</span></label><button class="outline" data-i18n="dockerRefresh"
@@ -710,15 +734,26 @@
<div class="vhost-layout redirect-layout">
<article class="card">
<div class="card-heading">
<div><span class="small-label" data-i18n="redirectLayer">HTTP REDIRECTION</span>
<h2 data-i18n="redirectList">Redirect hosts</h2>
</div>
<h2 data-i18n="redirectList">Redirect hosts</h2>
<div class="vhost-list-tools">
<label class="sr-only" data-i18n="redirectSearch" for="redirect-search">Search
redirects</label>
<input autocomplete="off" data-i18n-aria="redirectSearch"
data-i18n-placeholder="redirectSearchPlaceholder" id="redirect-search"
type="search" />
<label class="backend-sort-control"><span data-i18n="backendSortBy">Sort
by</span><select data-i18n-aria="backendSortBy" id="redirect-sort">
<option data-i18n="redirectId" value="id">Redirect ID</option>
<option data-i18n="redirectName" value="name">Display name</option>
<option data-i18n="redirectHosts" value="hostname">Source hostnames</option>
<option data-i18n="status" value="status">Status</option>
<option data-i18n="redirectTarget" value="target">Target</option>
<option data-i18n="redirectRules" value="rules">Rules</option>
<option data-i18n="redirectListeners" value="listeners">Listeners</option>
<option data-i18n="vhCertificate" value="certificate">Certificate</option>
</select></label>
<button class="quiet backend-sort-direction" data-i18n-aria="backendSortAscending"
id="redirect-sort-direction" type="button">↑</button>
<button class="outline" data-i18n="redirectNew" id="redirect-new" type="button">New
redirect</button>
</div>
@@ -734,15 +769,25 @@
<div class="vhost-layout redirect-layout">
<article class="card">
<div class="card-heading">
<div><span class="small-label" data-i18n="notfoundLayer">LOCAL RESPONSE</span>
<h2 data-i18n="notfoundList">404 hosts</h2>
</div>
<h2 data-i18n="notfoundList">404 hosts</h2>
<div class="vhost-list-tools">
<label class="sr-only" data-i18n="notfoundSearch" for="notfound-search">Search 404
hosts</label>
<input autocomplete="off" data-i18n-aria="notfoundSearch"
data-i18n-placeholder="notfoundSearchPlaceholder" id="notfound-search"
type="search" />
<label class="backend-sort-control"><span data-i18n="backendSortBy">Sort
by</span><select data-i18n-aria="backendSortBy" id="notfound-sort">
<option data-i18n="notfoundId" value="id">Host ID</option>
<option data-i18n="notfoundName" value="name">Display name</option>
<option data-i18n="notfoundHosts" value="hostname">Hostnames</option>
<option data-i18n="notfoundStatus" value="status">HTTP status</option>
<option data-i18n="notfoundMode" value="mode">Response type</option>
<option data-i18n="redirectListeners" value="listeners">Listeners</option>
<option data-i18n="vhCertificate" value="certificate">Certificate</option>
</select></label>
<button class="quiet backend-sort-direction" data-i18n-aria="backendSortAscending"
id="notfound-sort-direction" type="button">↑</button>
<button class="outline" data-i18n="notfoundNew" id="notfound-new" type="button">New
404 host</button>
</div>
@@ -768,6 +813,22 @@
<input autocomplete="off" data-i18n-aria="streamSearch"
data-i18n-placeholder="streamSearchPlaceholder" id="stream-search"
type="search" />
<label class="backend-sort-control"><span data-i18n="backendSortBy">Sort
by</span><select data-i18n-aria="backendSortBy" id="stream-sort">
<option data-i18n="streamName" value="name">Display name</option>
<option data-i18n="streamId" value="id">Stream ID</option>
<option data-i18n="streamProtocol" value="protocol">Protocol</option>
<option data-i18n="streamListen" value="listen">Listen address</option>
<option data-i18n="streamUpstream" value="upstream">Upstream address
</option>
<option data-i18n="status" value="status">Status</option>
<option data-i18n="streamMaxConnections" value="maxConnections">Max
connections</option>
<option data-i18n="streamIdleShort" value="idleTimeout">Idle timeout
</option>
</select></label>
<button class="quiet backend-sort-direction" data-i18n-aria="backendSortAscending"
id="stream-sort-direction" type="button">↑</button>
<button class="outline" data-i18n="streamNew" id="stream-new" type="button">New
stream</button>
</div>
@@ -811,11 +872,14 @@
</div>
<section class="acl-policy-builder" aria-labelledby="acl-policy-mode-label">
<div class="acl-policy-step-head">
<div><span class="acl-policy-step">1</span><div><strong id="acl-policy-mode-label"
data-i18n="aclProfilePolicyMode">Choose the base policy</strong><p
class="fine" data-i18n="aclProfilePolicyModeHelp">Start with a simple
<div><span class="acl-policy-step">1</span>
<div><strong id="acl-policy-mode-label" data-i18n="aclProfilePolicyMode">Choose
the base policy</strong>
<p class="fine" data-i18n="aclProfilePolicyModeHelp">Start with a simple
allow-list or block-list. Use custom mode only when you need mixed
allow and deny rules.</p></div></div>
allow and deny rules.</p>
</div>
</div>
</div>
<div class="acl-policy-modes" id="acl-profile-mode-options">
<label class="acl-policy-mode-card"><input checked name="acl-profile-mode"
@@ -843,28 +907,39 @@
<label><span data-i18n="vhCountryMode">Country access</span><select
id="acl-profile-country-mode">
<option data-i18n="vhOff" value="off">Off</option>
<option data-i18n="vhCountryBlock" value="block">Block listed countries</option>
<option data-i18n="vhCountryAllow" value="allow">Allow only listed countries</option>
<option data-i18n="vhCountryBlock" value="block">Block listed countries
</option>
<option data-i18n="vhCountryAllow" value="allow">Allow only listed countries
</option>
</select></label>
</div>
</section>
<section class="acl-policy-builder">
<div class="acl-policy-step-head"><div><span class="acl-policy-step">2</span><div><strong
data-i18n="aclProfileCountryExceptions">Country exceptions</strong><p
class="fine" id="acl-profile-country-help"></p></div></div><label
class="acl-policy-switch"><input id="acl-profile-country-enabled"
type="checkbox" /><span data-i18n="aclProfileUseCountries">Use countries</span></label></div>
<div class="acl-policy-step-head">
<div><span class="acl-policy-step">2</span>
<div><strong data-i18n="aclProfileCountryExceptions">Country exceptions</strong>
<p class="fine" id="acl-profile-country-help"></p>
</div>
</div><label class="acl-policy-switch"><input id="acl-profile-country-enabled"
type="checkbox" /><span data-i18n="aclProfileUseCountries">Use
countries</span></label>
</div>
<label class="acl-policy-country-list" id="acl-profile-country-field"><span
id="acl-profile-country-label" data-i18n="vhCountryList">Countries</span><textarea
id="acl-profile-countries" rows="4" maxlength="1024" spellcheck="false"
id="acl-profile-country-label"
data-i18n="vhCountryList">Countries</span><textarea id="acl-profile-countries"
rows="4" maxlength="1024" spellcheck="false"
placeholder="CN&#10;PL"></textarea></label>
</section>
<section class="acl-policy-builder">
<div class="acl-policy-step-head"><div><span class="acl-policy-step">3</span><div><strong
data-i18n="aclProfileIpExceptions">IP / CIDR exceptions</strong><p
class="fine" id="acl-profile-ip-help"></p></div></div><button class="quiet"
data-i18n="aclProfileAddIpException" id="acl-profile-rule-add" type="button">Add
IP exception</button></div>
<div class="acl-policy-step-head">
<div><span class="acl-policy-step">3</span>
<div><strong data-i18n="aclProfileIpExceptions">IP / CIDR exceptions</strong>
<p class="fine" id="acl-profile-ip-help"></p>
</div>
</div><button class="quiet" data-i18n="aclProfileAddIpException"
id="acl-profile-rule-add" type="button">Add
IP exception</button>
</div>
<div class="acl-profile-rule-rows" id="acl-profile-rule-rows"></div>
<details class="acl-profile-bulk" id="acl-profile-bulk">
<summary data-i18n="aclProfileBulkRules">Paste / import rules</summary>
@@ -878,8 +953,8 @@
</details>
</section>
<section class="acl-policy-preview" aria-live="polite">
<div class="acl-policy-preview-head"><span data-i18n="aclProfilePreview">Effective policy</span><b
id="acl-profile-preview-default"></b></div>
<div class="acl-policy-preview-head"><span data-i18n="aclProfilePreview">Effective
policy</span><b id="acl-profile-preview-default"></b></div>
<div class="acl-policy-preview-flow" id="acl-profile-preview-flow"></div>
<p class="fine" id="acl-profile-preview-note"></p>
</section>
@@ -913,6 +988,19 @@
data-certificate-tab="generate" data-i18n="certificateTabGenerate"
id="certificate-tab-generate" role="tab" tabindex="-1" type="button"></button>
</nav>
<article class="card certificate-operation-card certificate-runtime-card" id="acme-runtime-card">
<div class="certificate-operation-heading">
<div>
<span class="small-label" data-i18n="certificateRuntimeTitle">ACME runtime</span>
<strong data-i18n="acme.runtime_checking" id="acme-runtime-message"
role="status">Checking local Python environment…</strong>
</div>
<span class="pill" data-i18n="certificateRuntimePreparing"
id="acme-runtime-state">Preparing</span>
</div>
<p class="fine" hidden="" id="acme-runtime-path"></p>
<p class="fine" id="acme-runtime-components"></p>
</article>
<div class="certificate-pages">
<div aria-labelledby="certificate-tab-installed" class="certificate-page"
data-certificate-page="installed" id="certificate-page-installed" role="tabpanel">
@@ -1320,10 +1408,10 @@
</div>
</details>
<div class="stats-grid cache-stats">
<article class="stat"><span data-i18n="cacheHotObjects" id="cache-hot-objects-label">Hot objects</span><strong
id="cp-entries">-</strong><small id="cp-capacity">-</small><progress
aria-labelledby="cache-hot-objects-label" id="cp-hot-entries-meter" max="100"
value="0"></progress></article>
<article class="stat"><span data-i18n="cacheHotObjects" id="cache-hot-objects-label">Hot
objects</span><strong id="cp-entries">-</strong><small
id="cp-capacity">-</small><progress aria-labelledby="cache-hot-objects-label"
id="cp-hot-entries-meter" max="100" value="0"></progress></article>
<article class="stat"><span data-i18n="cacheHotMemory" id="cache-memory-label">Hot
memory</span><strong id="cp-bytes">-</strong><small
id="cp-memory-detail">-</small><progress aria-labelledby="cache-memory-label"
@@ -1331,13 +1419,14 @@
</article>
<article class="stat"><span data-i18n="cacheTransientMemory">Transient cache RAM</span><strong
id="cp-transient">-</strong><small id="cp-transient-detail">-</small></article>
<article class="stat"><span data-i18n="cacheColdObjects" id="cache-cold-objects-label">Cold objects</span><strong
id="cp-cold-entries">-</strong><small id="cp-cold-capacity">-</small><progress
aria-labelledby="cache-cold-objects-label" id="cp-cold-entries-meter" max="100"
value="0"></progress></article>
<article class="stat"><span data-i18n="cacheColdDisk" id="cache-cold-disk-label">Cold disk</span><strong
id="cp-cold-bytes">-</strong><small id="cp-cold-disk-detail">-</small><progress
aria-labelledby="cache-cold-disk-label" id="cp-cold-disk" max="100" value="0"></progress>
<article class="stat"><span data-i18n="cacheColdObjects" id="cache-cold-objects-label">Cold
objects</span><strong id="cp-cold-entries">-</strong><small
id="cp-cold-capacity">-</small><progress aria-labelledby="cache-cold-objects-label"
id="cp-cold-entries-meter" max="100" value="0"></progress></article>
<article class="stat"><span data-i18n="cacheColdDisk" id="cache-cold-disk-label">Cold
disk</span><strong id="cp-cold-bytes">-</strong><small
id="cp-cold-disk-detail">-</small><progress aria-labelledby="cache-cold-disk-label"
id="cp-cold-disk" max="100" value="0"></progress>
</article>
<article class="stat"><span data-i18n="cacheHitRatio">Hit ratio</span><strong
id="cp-ratio">-</strong><small id="cp-requests">-</small></article>
@@ -1921,7 +2010,8 @@
id="server-mode">
<option data-i18n="serverPreserve" value="preserve">Preserve origin value</option>
<option data-i18n="serverHide" value="hide">Hide completely</option>
<option data-i18n="serverCustom" selected value="custom">Replace / add custom value</option>
<option data-i18n="serverCustom" selected value="custom">Replace / add custom value
</option>
</select>
<label data-i18n="serverValue" for="server-value">Custom Server value</label><input
id="server-value" maxlength="256" placeholder="RustProxy" value="RustProxy"
@@ -1941,7 +2031,11 @@
<label data-i18n="compressionMin" for="compress-min">Minimum response size
(bytes)</label><input id="compress-min" max="1048576" min="32" required=""
type="number" />
<p class="fine" data-i18n="compressionHelp">Negotiates Accept-Encoding using client q-values and zstd → br → gzip → deflate as the server preference for equal quality. Cookies, Authorization, Set-Cookie, private and no-store responses may be compressed. Existing Content-Encoding, ranges, SSE, upgrades and no-transform are passed through.</p>
<p class="fine" data-i18n="compressionHelp">Negotiates Accept-Encoding using client
q-values and zstd → br → gzip → deflate as the server preference for equal quality.
Cookies, Authorization, Set-Cookie, private and no-store responses may be
compressed. Existing Content-Encoding, ranges, SSE, upgrades and no-transform are
passed through.</p>
<div class="form-actions"><button class="primary" data-i18n="saveResponse"
type="submit">Apply response settings</button></div>
</form>
@@ -2553,6 +2647,14 @@
id="runtime-data-directory">-</code></div>
<div><span class="small-label" data-i18n="databaseLocation">Database</span><code
id="runtime-database-path">-</code></div>
<div><span class="small-label" data-i18n="runtimeAcmeMode">ACME mode</span><code
id="runtime-acme-mode">-</code></div>
<div><span class="small-label" data-i18n="runtimePython">Python</span><code
id="runtime-python">-</code></div>
<div><span class="small-label" data-i18n="runtimePip">pip</span><code
id="runtime-pip">-</code></div>
<div><span class="small-label" data-i18n="runtimeCertbot">Certbot</span><code
id="runtime-certbot">-</code></div>
</div>
</article>
</div>
@@ -2721,7 +2823,7 @@
</section>
</div>
</section>
<footer class="page-footer"><span>RustProxy <b>0.27.0</b></span><span id="footer-status">API - /
<footer class="page-footer"><span>RustProxy <b>0.27.1</b></span><span id="footer-status">API - /
dropped: 0</span></footer>
</div>
</main>
@@ -3105,12 +3207,38 @@
this controls browser/proxy caching and does not create a second RustProxy object cache.</p>
</div>
<div class="vh-grid web-service-page" data-web-service-page="delivery" hidden>
<label><span data-i18n="webServiceCompression">Dynamic compression</span><select id="web-service-compression"><option value="" data-i18n="inherit">Inherit</option><option value="on" data-i18n="enabled">Enabled</option><option value="off" data-i18n="notEnabled">Disabled</option></select><small class="fine" data-i18n="webServiceCompressionHelp">Inherits the vhost/global policy by default. Codecs can be filtered per service; globally disabled codecs stay unavailable.</small></label>
<label><span data-i18n="webServiceCompression">Dynamic compression</span><select
id="web-service-compression">
<option value="" data-i18n="inherit">Inherit</option>
<option value="on" data-i18n="enabled">Enabled</option>
<option value="off" data-i18n="notEnabled">Disabled</option>
</select><small class="fine" data-i18n="webServiceCompressionHelp">Inherits the vhost/global
policy by default. Codecs can be filtered per service; globally disabled codecs stay
unavailable.</small></label>
<div class="vh-wide vh-grid" id="web-service-compression-options">
<label><span data-i18n="compressionZstd">Zstandard (zstd)</span><select id="web-service-compression-zstd"><option value="" data-i18n="inherit">Inherit</option><option value="on" data-i18n="enabled">Enabled</option><option value="off" data-i18n="notEnabled">Disabled</option></select></label>
<label><span data-i18n="compressionBr">Brotli (br)</span><select id="web-service-compression-br"><option value="" data-i18n="inherit">Inherit</option><option value="on" data-i18n="enabled">Enabled</option><option value="off" data-i18n="notEnabled">Disabled</option></select></label>
<label><span data-i18n="compressionGzip">gzip</span><select id="web-service-compression-gzip"><option value="" data-i18n="inherit">Inherit</option><option value="on" data-i18n="enabled">Enabled</option><option value="off" data-i18n="notEnabled">Disabled</option></select></label>
<label><span data-i18n="compressionDeflate">deflate</span><select id="web-service-compression-deflate"><option value="" data-i18n="inherit">Inherit</option><option value="on" data-i18n="enabled">Enabled</option><option value="off" data-i18n="notEnabled">Disabled</option></select></label>
<label><span data-i18n="compressionZstd">Zstandard (zstd)</span><select
id="web-service-compression-zstd">
<option value="" data-i18n="inherit">Inherit</option>
<option value="on" data-i18n="enabled">Enabled</option>
<option value="off" data-i18n="notEnabled">Disabled</option>
</select></label>
<label><span data-i18n="compressionBr">Brotli (br)</span><select
id="web-service-compression-br">
<option value="" data-i18n="inherit">Inherit</option>
<option value="on" data-i18n="enabled">Enabled</option>
<option value="off" data-i18n="notEnabled">Disabled</option>
</select></label>
<label><span data-i18n="compressionGzip">gzip</span><select id="web-service-compression-gzip">
<option value="" data-i18n="inherit">Inherit</option>
<option value="on" data-i18n="enabled">Enabled</option>
<option value="off" data-i18n="notEnabled">Disabled</option>
</select></label>
<label><span data-i18n="compressionDeflate">deflate</span><select
id="web-service-compression-deflate">
<option value="" data-i18n="inherit">Inherit</option>
<option value="on" data-i18n="enabled">Enabled</option>
<option value="off" data-i18n="notEnabled">Disabled</option>
</select></label>
</div>
<label class="check"><input id="web-service-precompressed" type="checkbox" /><span
data-i18n="webServicePrecompressed">Serve precompressed sidecars</span></label>
@@ -3439,7 +3567,7 @@ max_execution_time = 30</code></pre>
<article class="card about-card">
<div class="about-product">
<span aria-hidden="true" class="brand-mark">r<span>/</span>p</span>
<div><span class="small-label">RustProxy</span><strong>0.27.0</strong></div>
<div><span class="small-label">RustProxy</span><strong>0.27.1</strong></div>
</div>
<dl class="about-meta">
<div>
@@ -3450,12 +3578,24 @@ max_execution_time = 30</code></pre>
<dt data-i18n="aboutLicense">License</dt>
<dd data-i18n="aboutLicenseValue">Open Source</dd>
</div>
<div>
<dt data-i18n="runtimeAcmeMode">ACME mode</dt>
<dd id="about-acme-mode">-</dd>
</div>
<div>
<dt data-i18n="aboutPythonRuntime">Certificate Python runtime</dt>
<dd id="about-python-runtime">-</dd>
</div>
</dl>
<p data-i18n="aboutOpenSourceText">RustProxy is an open-source project. Its source code is publicly
available for inspection, development and community contribution.</p>
<p class="about-python-details" id="about-python-details"></p>
<a class="outline about-repository" href="https://git.linuxiarz.pl/gru/RustProxy" rel="noopener noreferrer"
target="_blank"><span data-i18n="aboutRepository">Source
target="_blank"><span data-i18n="aboutRepository">Official source
repository</span><span>git.linuxiarz.pl/gru/RustProxy</span></a>
<a class="outline about-repository" href="https://rustproxy.linuxiarz.pl/" rel="noopener noreferrer"
target="_blank"><span data-i18n="aboutPackagesRepository">Official package
repository</span><span>rustproxy.linuxiarz.pl</span></a>
</article>
</dialog>
<div aria-live="polite" class="toast" hidden="" id="toast" popover="manual" role="status"></div>
+2 -1
View File
@@ -20,8 +20,9 @@
"password",
"passwordMismatch",
"setupHelp",
"setupCommandExample",
"setupTitle",
"setupToken",
"signIn",
"username"
]
]
+3 -1
View File
@@ -23,7 +23,9 @@
</div>
<div hidden="" id="setup-fields">
<p class="fine" data-i18n="setupHelp">Create the first administrator using a one-time local
capability.</p><code class="command">rust-proxy --data-dir DATA_DIR admin setup-token</code>
capability.</p><code class="command">rust-proxy --data-dir ./data admin setup-token</code>
<p class="fine" data-i18n="setupCommandExample">Standard service installation example:</p>
<code class="command">rust-proxy --data-dir /var/lib/rustproxy admin setup-token</code>
<label data-i18n="setupToken" for="setup-token">One-time setup token</label><input
autocomplete="off" id="setup-token" maxlength="64" minlength="64" spellcheck="false"
type="password" />
+19 -3
View File
@@ -2,7 +2,12 @@
(() => {
const form = $("redirect-form"), dialog = $("redirect-dialog"), list = $("redirect-list"), rules = $("redirect-rules");
if (!form || !dialog || !list || !rules) return;
let editing = null, revision = null, dirty = false, readRedirectBindings = () => [], readRedirectHosts = () => [];
const sortColumns = ["id", "name", "hostname", "status", "target", "rules", "listeners", "certificate"];
let editing = null, revision = null, dirty = false, readRedirectBindings = () => [], readRedirectHosts = () => [], listPrefs = { sort: "id", descending: false };
function normalizeListPrefs(preferences) { return { sort: sortColumns.includes(preferences?.redirect_list_sort) ? preferences.redirect_list_sort : "id", descending: preferences?.redirect_list_descending === true }; }
function syncSortDirection() { const control = $("redirect-sort-direction"); if (!control) return; control.textContent = listPrefs.descending ? "↓" : "↑"; control.setAttribute("aria-label", t(listPrefs.descending ? "backendSortDescending" : "backendSortAscending")); }
function applyPreferences(preferences) { listPrefs = normalizeListPrefs(preferences); if ($("redirect-sort")) $("redirect-sort").value = listPrefs.sort; syncSortDirection(); render(); }
async function saveListPrefs() { if (!authenticated) return; try { listPrefs = normalizeListPrefs(await window.rustProxyPreferences.update({ redirect_list_sort: listPrefs.sort, redirect_list_descending: listPrefs.descending })); if ($("redirect-sort")) $("redirect-sort").value = listPrefs.sort; syncSortDirection(); } catch (error) { if (error.status !== 401) toast(error.message, true); } }
const syncEnabledControl = window.rustProxyEnableControl($("redirect-enabled"));
const yes = id => $(id).checked;
const value = id => $(id).value.trim();
@@ -100,6 +105,17 @@
function render() {
if (!envelope) return; const query = ($("redirect-search").value || "").trim().toLowerCase();
const entries = (envelope.config.redirects || []).filter(item => !query || [item.id, item.name, ...(item.frontend?.hosts || []), ...(item.rules || []).map(rule => rule.target)].join(" ").toLowerCase().includes(query));
const sortValue = item => ({
id: String(item.id || "").toLowerCase(),
name: String(item.name || item.id || "").toLowerCase(),
hostname: String(item.frontend?.hosts?.[0] || "").toLowerCase(),
status: item.enabled === false ? 1 : 0,
target: (item.rules || []).filter(rule => rule.enabled !== false).map(rule => rule.target || "").join(" ").toLowerCase(),
rules: Number(item.rules?.length || 0),
listeners: (item.frontend?.listener_ids || []).join(" ").toLowerCase(),
certificate: String(item.certificate_id || "").toLowerCase(),
}[listPrefs.sort]);
entries.sort((left, right) => { const a = sortValue(left), b = sortValue(right); const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" }); return listPrefs.descending ? -result : result; });
list.replaceChildren(...entries.map(item => {
const card = el("article", undefined, "vhost-card redirect-card"); const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity"), title = el("div", undefined, "vhost-title-line");
title.append(el("strong", item.name || item.id, "entity-title")); if (item.enabled === false) title.append(el("span", t("vhDisabled"), "pill")); identity.append(title, el("div", (item.frontend?.hosts || []).join(" · "), "vhost-hosts")); head.append(identity);
@@ -116,10 +132,10 @@
if (revision !== envelope.revision) { toast(t("stale"), true); return; }
try { const item = collect(), next = structuredClone(envelope.config.redirects || []); if (!editing && next.some(value => value.id === item.id)) throw new Error(t("redirectDuplicate")); const index = next.findIndex(value => value.id === editing); if (index < 0) next.push(item); else next[index] = item; if (await saveSection("redirects", next)) { dirty = false; editing = item.id; dialog.close(); render(); } } catch (error) { toast(error.message, true); }
});
$("redirect-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); }); $("redirect-search").addEventListener("input", render); $("redirect-rule-add").addEventListener("click", () => { addRule({ id: `rule-${rules.children.length + 1}` }); changed(); });
$("redirect-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); }); $("redirect-search").addEventListener("input", render); $("redirect-sort")?.addEventListener("change", event => { listPrefs.sort = event.target.value; void saveListPrefs(); render(); }); $("redirect-sort-direction")?.addEventListener("click", () => { listPrefs.descending = !listPrefs.descending; syncSortDirection(); void saveListPrefs(); render(); }); $("redirect-rule-add").addEventListener("click", () => { addRule({ id: `rule-${rules.children.length + 1}` }); changed(); });
$("redirect-close").addEventListener("click", closeEditor); $("redirect-reset").addEventListener("click", async () => { if (await allowDiscard()) load(editing ? structuredClone((envelope?.config.redirects || []).find(item => item.id === editing) || defaults()) : defaults()); });
dialog.addEventListener("cancel", event => { event.preventDefault(); closeEditor(); });
window.rustProxyDrafts.register("redirect", { labelKey: "redirects", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: () => { dirty = false; if (dialog.open) dialog.close(); } });
window.rustProxyRedirects = { render, reset() { dialog.close(); editing = null; revision = null; dirty = false; list.replaceChildren(); rules.replaceChildren(); $("redirect-search").value = ""; } };
window.rustProxyRedirects = { render, applyPreferences, reset() { dialog.close(); editing = null; revision = null; dirty = false; listPrefs = { sort: "id", descending: false }; list.replaceChildren(); rules.replaceChildren(); $("redirect-search").value = ""; if ($("redirect-sort")) $("redirect-sort").value = listPrefs.sort; syncSortDirection(); } };
if (envelope) render();
})();
+4
View File
@@ -173,6 +173,10 @@ curl --fail --show-error --request POST \
for (const [id, key] of [["runtime-platform", "platform"], ["runtime-statistics-source", "statistics_source"], ["runtime-data-directory", "data_directory"], ["runtime-database-path", "database_path"]]) {
const node = $(id); if (node) node.textContent = runtime[key] || t("unavailable");
}
const certificateRuntime = runtime.certificate_runtime || {};
const availability = flag => flag === true ? t("runtimeAvailable") : flag === false ? t("runtimeUnavailable") : t("unavailable");
const modeLabel = certificateRuntime.status === "unavailable" ? t("certificateRuntimeUnavailable") : certificateRuntime.status === "ready" && certificateRuntime.mode === "managed" ? t("certificateRuntimeManaged") : certificateRuntime.status === "ready" && certificateRuntime.mode === "system" ? t("certificateRuntimeSystem") : t("certificateRuntimePreparing");
for (const [id, text] of [["runtime-acme-mode", modeLabel], ["runtime-python", availability(certificateRuntime.python)], ["runtime-pip", availability(certificateRuntime.pip)], ["runtime-certbot", availability(certificateRuntime.certbot)]]) { const node = $(id); if (node) node.textContent = text; }
const policy = envelope.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] };
if (!settingsDirty.has("tls-policy-form")) { $("tls-policy-12").checked = policy.tls12 !== false; $("tls-policy-13").checked = policy.tls13 !== false; $("tls-policy-ciphers").value = (policy.cipher_suites || []).join("\n"); }
+15 -2
View File
@@ -2,8 +2,13 @@
// Layer-4 stream management mirrors the vhost workflow while keeping TCP/UDP
// listeners separate from the HTTP policy stack.
(() => {
let editing = null, revision = null, dirty = false, initialized = false;
const sortColumns = ["name", "id", "protocol", "listen", "upstream", "status", "maxConnections", "idleTimeout"];
let editing = null, revision = null, dirty = false, initialized = false, listPrefs = { sort: "name", descending: false };
const chartHistory = new Map();
function normalizeListPrefs(preferences) { return { sort: sortColumns.includes(preferences?.stream_list_sort) ? preferences.stream_list_sort : "name", descending: preferences?.stream_list_descending === true }; }
function syncSortDirection() { const control = $("stream-sort-direction"); if (!control) return; control.textContent = listPrefs.descending ? "↓" : "↑"; control.setAttribute("aria-label", t(listPrefs.descending ? "backendSortDescending" : "backendSortAscending")); }
function applyPreferences(preferences) { listPrefs = normalizeListPrefs(preferences); if ($("stream-sort")) $("stream-sort").value = listPrefs.sort; syncSortDirection(); render(); }
async function saveListPrefs() { if (!authenticated) return; try { listPrefs = normalizeListPrefs(await window.rustProxyPreferences.update({ stream_list_sort: listPrefs.sort, stream_list_descending: listPrefs.descending })); if ($("stream-sort")) $("stream-sort").value = listPrefs.sort; syncSortDirection(); } catch (error) { if (error.status !== 401) toast(error.message, true); } }
const dialog = $("stream-dialog"), form = $("stream-form"), container = $("stream-fields");
const text = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; };
const button = (key, callback, cls = "quiet") => { const node = text("button", key, cls); node.type = "button"; node.addEventListener("click", callback); return node; };
@@ -102,6 +107,12 @@
for (const id of chartHistory.keys()) if (!configuredIds.has(id)) chartHistory.delete(id);
const query = ($("stream-search")?.value || "").trim().toLowerCase();
const streams = configured.filter(stream => !query || [stream.id, stream.name, stream.protocol, stream.listen, stream.upstream].filter(Boolean).join(" ").toLowerCase().includes(query));
const active = new Map((latestStatus?.streams?.active || []).map(item => [item.id, item]));
const sortValue = stream => {
const live = active.get(stream.id), same = live && JSON.stringify(live) === JSON.stringify(stream);
return ({ name: String(stream.name || stream.id || "").toLowerCase(), id: String(stream.id || "").toLowerCase(), protocol: String(stream.protocol || "tcp"), listen: String(stream.listen || "").toLowerCase(), upstream: String(stream.upstream || "").toLowerCase(), status: stream.enabled === false ? 2 : same ? 0 : 1, maxConnections: Number(stream.max_connections || 0), idleTimeout: Number(stream.idle_timeout_seconds || 0) })[listPrefs.sort];
};
streams.sort((left, right) => { const a = sortValue(left), b = sortValue(right); const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" }); return listPrefs.descending ? -result : result; });
const cards = streams.map(stream => {
const box = el("article", undefined, "vhost-card stream-card"); box.dataset.streamCard = stream.id;
const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity"), title = el("div", undefined, "vhost-title-line");
@@ -178,10 +189,12 @@
$("stream-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); });
$("stream-listener-add").addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("listeners"); });
$("stream-search").addEventListener("input", render);
$("stream-sort")?.addEventListener("change", event => { listPrefs.sort = event.target.value; void saveListPrefs(); render(); });
$("stream-sort-direction")?.addEventListener("click", () => { listPrefs.descending = !listPrefs.descending; syncSortDirection(); void saveListPrefs(); render(); });
$("stream-close").addEventListener("click", closeEditor);
$("stream-reset").addEventListener("click", async () => { if (await allowDiscard()) load(editing ? structuredClone((envelope?.config.streams || []).find(item => item.id === editing) || defaults()) : defaults()); });
dialog.addEventListener("cancel", event => { event.preventDefault(); closeEditor(); });
window.rustProxyDrafts.register("stream", { labelKey: "streams", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: () => { dirty = false; if (dialog.open) dialog.close(); } });
window.rustProxyStreams = { render, renderStatus, reset() { dialog.close(); editing = null; revision = null; dirty = false; initialized = false; chartHistory.clear(); container.replaceChildren(); $("stream-search").value = ""; $("stream-list").replaceChildren(); } };
window.rustProxyStreams = { render, renderStatus, applyPreferences, reset() { dialog.close(); editing = null; revision = null; dirty = false; initialized = false; listPrefs = { sort: "name", descending: false }; chartHistory.clear(); container.replaceChildren(); $("stream-search").value = ""; $("stream-list").replaceChildren(); if ($("stream-sort")) $("stream-sort").value = listPrefs.sort; syncSortDirection(); } };
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);
})();
+21 -9
View File
@@ -4966,19 +4966,24 @@ dialog .window-heading {
}
#panel-vhosts .card-heading,
#panel-web-server .card-heading {
#panel-web-server .card-heading,
#panel-redirects .card-heading,
#panel-notfound .card-heading {
flex-wrap: wrap
}
.vhost-list-tools {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px;
margin-left: auto
}
#vhost-search,
#web-service-search {
#web-service-search,
#redirect-search,
#notfound-search {
width: min(260px, 32vw);
min-width: 180px
}
@@ -5108,7 +5113,9 @@ dialog .window-heading {
flex-direction: column
}
#panel-vhosts .card-heading {
#panel-vhosts .card-heading,
#panel-redirects .card-heading,
#panel-notfound .card-heading {
align-items: stretch;
flex-direction: column
}
@@ -5118,7 +5125,9 @@ dialog .window-heading {
margin-left: 0
}
#vhost-search {
#vhost-search,
#redirect-search,
#notfound-search {
flex: 1 1 auto;
width: auto;
min-width: 0
@@ -7140,7 +7149,8 @@ body[data-density="compact"] .page-footer {
border-left-color: var(--green)
}
#panel-tls .certificate-operation-card[data-state="failed"] {
#panel-tls .certificate-operation-card[data-state="failed"],
#panel-tls .certificate-operation-card[data-state="unavailable"] {
border-left-color: var(--red)
}
@@ -7162,7 +7172,9 @@ body[data-density="compact"] .page-footer {
}
#panel-tls .certificate-operation-card[data-state="failed"] .certificate-operation-heading strong,
#panel-tls .certificate-operation-card[data-state="failed"] .pill {
#panel-tls .certificate-operation-card[data-state="failed"] .pill,
#panel-tls .certificate-operation-card[data-state="unavailable"] .certificate-operation-heading strong,
#panel-tls .certificate-operation-card[data-state="unavailable"] .pill {
color: var(--red)
}
@@ -8596,8 +8608,6 @@ button.config-history-day.is-selected {
}
}
/* Account-scoped appearance preferences. */
.appearance-settings-card {
max-width: 980px;
}
@@ -9612,6 +9622,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
}
@media(max-width:900px) {
.acl-profile-layout,
.acl-policy-modes,
.acl-policy-custom-controls {
@@ -9624,6 +9635,7 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
}
@media(max-width:640px) {
.acl-policy-step-head,
.acl-policy-preview-head {
align-items: stretch;
@@ -9634,4 +9646,4 @@ html[data-chart-style] :is(#workspace, dialog) svg polyline {
.acl-policy-step-head>button {
width: 100%
}
}
}
+38 -5
View File
@@ -1,6 +1,8 @@
const poolColumns = ["vhost", "backend", "status", "active", "requests", "errors", "latency", "traffic", "generation", "generationRequests", "generationAge", "rotations", "lastFailure", "lastRecovery"];
const vhostSortColumns = ["id", "hostname", "status", "listeners", "certificate", "backends"];
const defaultPoolTablePrefs = () => ({ sort: "vhost", descending: false, columns: [...poolColumns] });
let poolTablePrefs = defaultPoolTablePrefs(), poolPreferenceSaveRunning = false, poolPreferenceSaveQueued = false;
const defaultVhostListPrefs = () => ({ sort: "id", descending: false });
let poolTablePrefs = defaultPoolTablePrefs(), vhostListPrefs = defaultVhostListPrefs(), poolPreferenceSaveRunning = false, poolPreferenceSaveQueued = false;
function normalizePoolTablePrefs(preferences) {
const selected = Array.isArray(preferences?.backend_table_columns) ? preferences.backend_table_columns.filter(key => poolColumns.includes(key)) : [];
return {
@@ -9,10 +11,27 @@
columns: selected.length ? selected : [...poolColumns],
};
}
function applyPoolPreferences(preferences) {
function normalizeVhostListPrefs(preferences) {
return { sort: vhostSortColumns.includes(preferences?.vhost_list_sort) ? preferences.vhost_list_sort : "id", descending: preferences?.vhost_list_descending === true };
}
function syncVhostSortDirection() {
const control = $("vhost-sort-direction"); if (!control) return;
control.textContent = vhostListPrefs.descending ? "↓" : "↑";
control.setAttribute("aria-label", t(vhostListPrefs.descending ? "backendSortDescending" : "backendSortAscending"));
}
function applyPreferences(preferences) {
poolTablePrefs = normalizePoolTablePrefs(preferences);
const sort = $("pool-sort"); if (sort) sort.value = poolTablePrefs.sort;
syncPoolColumns(); syncPoolSortDirection(); applyPoolTableView();
vhostListPrefs = normalizeVhostListPrefs(preferences);
const poolSort = $("pool-sort"); if (poolSort) poolSort.value = poolTablePrefs.sort;
const vhostSort = $("vhost-sort"); if (vhostSort) vhostSort.value = vhostListPrefs.sort;
syncPoolColumns(); syncPoolSortDirection(); syncVhostSortDirection(); applyPoolTableView(); render();
}
async function saveVhostListPrefs() {
if (!authenticated) return;
try {
const saved = await window.rustProxyPreferences.update({ vhost_list_sort: vhostListPrefs.sort, vhost_list_descending: vhostListPrefs.descending });
vhostListPrefs = normalizeVhostListPrefs(saved); const sort = $("vhost-sort"); if (sort) sort.value = vhostListPrefs.sort; syncVhostSortDirection();
} catch (error) { if (error.status !== 401) toast(error.message, true); }
}
async function savePoolTablePrefs() {
if (!authenticated) return;
@@ -103,6 +122,18 @@
].filter(Boolean).join(" ").toLowerCase();
return haystack.includes(query);
});
const sortValue = v => ({
id: String(v.id || "").toLowerCase(),
hostname: String(v.frontend?.hosts?.[0] || "").toLowerCase(),
status: v.enabled === false ? 1 : 0,
listeners: (v.frontend?.listener_ids || []).join(" ").toLowerCase(),
certificate: String(v.certificate_id || "").toLowerCase(),
backends: Number(v.backends?.length || 0),
}[vhostListPrefs.sort]);
visibleVhosts.sort((left, right) => {
const a = sortValue(left), b = sortValue(right); const result = typeof a === "number" && typeof b === "number" ? a - b : String(a).localeCompare(String(b), locale(), { numeric: true, sensitivity: "base" });
return vhostListPrefs.descending ? -result : result;
});
const cards = visibleVhosts.map(v => {
const box = el("article", undefined, "vhost-card"); box.dataset.vhostCard = v.id;
const head = el("div", undefined, "vhost-card-head"), identity = el("div", undefined, "vhost-card-identity");
@@ -324,6 +355,8 @@
});
$("vhost-new").addEventListener("click", () => { window.rustProxyDrafts.prepare(); load(); });
$("vhost-search").addEventListener("input", render);
$("vhost-sort")?.addEventListener("change", event => { vhostListPrefs.sort = event.target.value; void saveVhostListPrefs(); render(); });
$("vhost-sort-direction")?.addEventListener("click", () => { vhostListPrefs.descending = !vhostListPrefs.descending; syncVhostSortDirection(); void saveVhostListPrefs(); render(); });
$("vhost-reset").addEventListener("click", async () => {
if (!(await allowDiscard())) return;
const saved = editing ? (envelope?.config.vhosts || []).find(item => item.id === editing) : null;
@@ -333,5 +366,5 @@
initPoolTableTools();
$("location-help-close")?.addEventListener("click", () => $("location-help-dialog")?.close());
window.rustProxyDrafts.register("vhost", { labelKey: "vhosts", isOpen: () => dialog.open, isDirty: () => dirty, suspend: suspendDraft, restore: restoreDraft, close: () => { dirty = false; if (dialog.open) dialog.close(); } });
window.rustProxyVhosts = { render, renderStatus, applyPreferences: applyPoolPreferences, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; poolTablePrefs = defaultPoolTablePrefs(); poolPreferenceSaveRunning = false; poolPreferenceSaveQueued = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; if ($("pool-search")) $("pool-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); const sort = $("pool-sort"); if (sort) sort.value = poolTablePrefs.sort; applyPoolTableView(); } };
window.rustProxyVhosts = { render, renderStatus, applyPreferences, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; poolTablePrefs = defaultPoolTablePrefs(); vhostListPrefs = defaultVhostListPrefs(); poolPreferenceSaveRunning = false; poolPreferenceSaveQueued = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; if ($("pool-search")) $("pool-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); const sort = $("pool-sort"); if (sort) sort.value = poolTablePrefs.sort; const vhostSort = $("vhost-sort"); if (vhostSort) vhostSort.value = vhostListPrefs.sort; syncVhostSortDirection(); applyPoolTableView(); } };
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);
+29
View File
@@ -396,6 +396,35 @@ async fn run(
} else {
println!(" Sign in with your administrator account.");
}
let certificate_runtime = state.acme.runtime_view();
let runtime_pending = matches!(certificate_runtime.status, "checking" | "initializing");
let component_state = |available: bool| {
if available {
"available"
} else if runtime_pending {
"checking"
} else {
"unavailable"
}
};
let managed_python_directory = certificate_runtime
.managed_directory
.clone()
.unwrap_or_else(|| data_dir.join("acme-python").to_string_lossy().into_owned());
println!(" Source: https://git.linuxiarz.pl/gru/RustProxy");
println!(" Packages: https://rustproxy.linuxiarz.pl/");
println!(
" Python: {} (ACME mode: {}, pip: {}, Certbot: {}; managed env: {})",
component_state(certificate_runtime.python),
if runtime_pending {
"preparing"
} else {
certificate_runtime.mode
},
component_state(certificate_runtime.pip),
component_state(certificate_runtime.certbot),
managed_python_directory
);
println!(" Data: {}\n Stop: Ctrl+C\n", data_dir.display());
if state
.active_listeners
+1 -1
View File
@@ -41,7 +41,7 @@ use zeroize::Zeroizing;
#[command(
name = "rust-proxy",
version,
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.27.0"
about = "RustProxy - independent Rust reverse proxy and static Web Server 0.27.1"
)]
struct Cli {
#[arg(
+1 -1
View File
@@ -239,7 +239,7 @@ async fn live_logs_command(dir: PathBuf, addr: SocketAddr) -> Result<()> {
}
});
let interactive = io::stdin().is_terminal() && io::stdout().is_terminal();
println!("RustProxy live logs 0.27.0 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
println!("RustProxy live logs 0.27.1 - all vhosts. Type 'help' for interactive filters; Ctrl-C or 'quit' exits.");
if interactive { print!("> "); io::stdout().flush()?; }
let mut filters = LiveLogFilters::default();
let mut history = std::collections::BTreeMap::<u64, serde_json::Value>::new();
+4
View File
@@ -200,6 +200,10 @@ impl App {
Arc::downgrade(&app),
app.shutdown.clone(),
));
if !cfg!(test) {
app.tunnels
.spawn(crate::acme::runtime_setup_once(Arc::downgrade(&app)));
}
app.tunnels.spawn(crate::docker::poll_loop(
Arc::downgrade(&app),
app.shutdown.clone(),
+1 -1
View File
@@ -159,7 +159,7 @@ pub(super) fn fastcgi_params(
if context.tls { "on" } else { "off" }.into(),
);
params.insert("GATEWAY_INTERFACE".into(), "CGI/1.1".into());
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.27.0".into());
params.insert("SERVER_SOFTWARE".into(), "RustProxy/0.27.1".into());
params.insert("REDIRECT_STATUS".into(), "200".into());
if let Some(value) = headers
.get(CONTENT_TYPE)
+1 -1
View File
@@ -185,7 +185,7 @@ pub(super) fn directory_listing(path: &str, entries: &[DirectoryEntry]) -> Vec<u
"<a class=\"{class}\" href=\"{href}\"><span class=\"type\">{type_label}</span><span class=\"name\">{label}{slash}</span><span class=\"kind\">{kind}</span></a>"
));
}
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.27.0</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
html.push_str(r#"</div></section><footer class="page-footer"><span>RustProxy <b>0.27.1</b></span><span>Web Server · Directory listing</span></footer></main></body></html>"#);
html.into_bytes()
}
+1 -1
View File
@@ -202,7 +202,7 @@ async fn empty_index_can_go_directly_to_directory_listing() {
let body = std::str::from_utf8(&body).unwrap();
assert!(body.contains("file.txt"));
assert!(body.contains("DIRECTORY LISTING"));
assert!(body.contains("RustProxy <b>0.27.0</b>"));
assert!(body.contains("RustProxy <b>0.27.1</b>"));
}
#[tokio::test]