v0.25.1
This commit is contained in:
1 parent
87e571c170
commit
fa7abc9e53
308 files changed
+49746
-50237
No files matched your search
@@ -0,0 +1,285 @@
|
||||
fn valid_certificate_api_token(value: &str) -> bool {
|
||||
(32..=128).contains(&value.len())
|
||||
&& value.bytes().all(|byte| {
|
||||
byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~')
|
||||
})
|
||||
}
|
||||
|
||||
fn valid_certificate_api_key_id(value: &str) -> bool {
|
||||
(3..=64).contains(&value.len()) && crate::config::valid_id(value)
|
||||
}
|
||||
|
||||
fn certificate_api_credentials(headers: &HeaderMap) -> Result<Option<(String, String)>, ()> {
|
||||
let authorization_present = headers.contains_key(AUTHORIZATION);
|
||||
let key_present = headers.contains_key(CERTIFICATE_API_KEY_HEADER);
|
||||
if !authorization_present && !key_present {
|
||||
return Ok(None);
|
||||
}
|
||||
if !authorization_present || !key_present {
|
||||
return Err(());
|
||||
}
|
||||
let authorization = single_header(headers, AUTHORIZATION.as_str()).ok_or(())?;
|
||||
let token = authorization.strip_prefix("Bearer ").ok_or(())?;
|
||||
if !valid_certificate_api_token(token) {
|
||||
return Err(());
|
||||
}
|
||||
let key_id = single_header(headers, CERTIFICATE_API_KEY_HEADER).ok_or(())?;
|
||||
if !valid_certificate_api_key_id(key_id) {
|
||||
return Err(());
|
||||
}
|
||||
Ok(Some((key_id.to_owned(), token.to_owned())))
|
||||
}
|
||||
|
||||
fn single_header<'a>(headers: &'a HeaderMap, name: &str) -> Option<&'a str> {
|
||||
let all = headers.get_all(name);
|
||||
let mut values = all.iter();
|
||||
let value = values.next()?.to_str().ok()?;
|
||||
if values.next().is_some() {
|
||||
None
|
||||
} else {
|
||||
Some(value)
|
||||
}
|
||||
}
|
||||
fn unauthorized(request: &Request) -> Response {
|
||||
// Only top-level documentation navigation gets a fixed, same-origin login
|
||||
// redirect. API/spec/assets ALWAYS return a small 401, never a login HTML body.
|
||||
if (request.method() == Method::GET || request.method() == Method::HEAD)
|
||||
&& matches!(request.uri().path(), "/docs" | "/docs/")
|
||||
{
|
||||
axum::response::Redirect::to("/?next=docs").into_response()
|
||||
} else {
|
||||
reject(StatusCode::UNAUTHORIZED, "auth.required")
|
||||
}
|
||||
}
|
||||
fn admin_request_authority(request: &Request, tls: bool) -> Result<String, ()> {
|
||||
let mut host_values = request.headers().get_all(HOST).iter();
|
||||
let header_host = match (host_values.next(), host_values.next()) {
|
||||
(None, None) => None,
|
||||
(Some(value), None) => Some(value.to_str().map_err(|_| ())?),
|
||||
(None, Some(_)) | (Some(_), Some(_)) => return Err(()),
|
||||
};
|
||||
|
||||
if matches!(request.version(), Version::HTTP_2 | Version::HTTP_3) {
|
||||
// Hyper exposes :authority through the request URI for H2/H3. Browsers do
|
||||
// not have to send a legacy Host field, so requiring one here rejects
|
||||
// perfectly valid administration requests negotiated over ALPN h2.
|
||||
let authority = request.uri().authority().ok_or(())?.as_str();
|
||||
if let Some(scheme) = request.uri().scheme_str() {
|
||||
if scheme != if tls { "https" } else { "http" } {
|
||||
return Err(());
|
||||
}
|
||||
}
|
||||
if let Some(host) = header_host {
|
||||
if !host.eq_ignore_ascii_case(authority) {
|
||||
return Err(());
|
||||
}
|
||||
}
|
||||
if authority.contains('@') {
|
||||
return Err(());
|
||||
}
|
||||
HeaderValue::from_str(authority).map_err(|_| ())?;
|
||||
return Ok(authority.to_owned());
|
||||
}
|
||||
|
||||
// The administration listener is an origin server, never a forward proxy.
|
||||
// HTTP/1.x therefore keeps the existing exact single-Host requirement and
|
||||
// rejects absolute-form targets.
|
||||
if request.uri().scheme().is_some() || request.uri().authority().is_some() {
|
||||
return Err(());
|
||||
}
|
||||
let authority = header_host.ok_or(())?;
|
||||
if authority.contains('@') {
|
||||
return Err(());
|
||||
}
|
||||
HeaderValue::from_str(authority).map_err(|_| ())?;
|
||||
Ok(authority.to_owned())
|
||||
}
|
||||
|
||||
fn admin_host_allowed(
|
||||
raw_host: &str,
|
||||
configured_addr: std::net::SocketAddr,
|
||||
tls: bool,
|
||||
allowed_hosts: &[String],
|
||||
) -> bool {
|
||||
raw_host
|
||||
.parse::<http::uri::Authority>()
|
||||
.ok()
|
||||
.is_some_and(|a| {
|
||||
let host = a.host().trim_start_matches('[').trim_end_matches(']');
|
||||
let configured = configured_addr.ip();
|
||||
let dns_without_allowlist = tls
|
||||
&& allowed_hosts.is_empty()
|
||||
&& host.parse::<IpAddr>().is_err()
|
||||
&& crate::config::normalize_host(host).is_ok();
|
||||
let direct_host = (host.eq_ignore_ascii_case("localhost")
|
||||
&& (configured.is_loopback() || configured.is_unspecified()))
|
||||
|| (!configured.is_unspecified() && host == configured.to_string())
|
||||
|| (configured.is_unspecified()
|
||||
&& host
|
||||
.parse::<IpAddr>()
|
||||
.ok()
|
||||
.is_some_and(|ip| !ip.is_unspecified()));
|
||||
let explicit_allowed = allowed_hosts
|
||||
.iter()
|
||||
.any(|allowed| allowed.eq_ignore_ascii_case(host));
|
||||
let port = a.port_u16().unwrap_or(if tls { 443 } else { 80 });
|
||||
|
||||
!a.as_str().contains('@')
|
||||
&& ((direct_host && port == configured_addr.port())
|
||||
// An explicit DNS allow-list describes the browser-facing
|
||||
// authority. A reverse proxy is therefore free to terminate
|
||||
// on 80/443 (or another external port) while forwarding to
|
||||
// the private administration socket on its configured port.
|
||||
|| explicit_allowed
|
||||
// Preserve the historical direct-TLS convenience when no
|
||||
// strict DNS allow-list exists; unlike the explicit list it
|
||||
// remains bound to the actual administration listener port.
|
||||
|| (dns_without_allowlist && port == configured_addr.port()))
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
struct AdminOrigin {
|
||||
secure: bool,
|
||||
}
|
||||
|
||||
fn default_origin_port(scheme: &str) -> Option<u16> {
|
||||
match scheme {
|
||||
"http" => Some(80),
|
||||
"https" => Some(443),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn authority_matches_origin(actual: &http::uri::Authority, expected: &str, scheme: &str) -> bool {
|
||||
let Ok(expected) = expected.parse::<http::uri::Authority>() else {
|
||||
return false;
|
||||
};
|
||||
let Some(default_port) = default_origin_port(scheme) else {
|
||||
return false;
|
||||
};
|
||||
actual.host().eq_ignore_ascii_case(expected.host())
|
||||
&& actual.port_u16().unwrap_or(default_port) == expected.port_u16().unwrap_or(default_port)
|
||||
}
|
||||
|
||||
fn admin_origin_matches(value: &str, scheme: &str, authority: &str) -> bool {
|
||||
let Ok(origin) = value.parse::<http::Uri>() else {
|
||||
return false;
|
||||
};
|
||||
if origin.scheme_str() != Some(scheme)
|
||||
|| origin.query().is_some()
|
||||
|| !matches!(origin.path(), "" | "/")
|
||||
{
|
||||
return false;
|
||||
}
|
||||
origin
|
||||
.authority()
|
||||
.is_some_and(|actual| authority_matches_origin(actual, authority, scheme))
|
||||
}
|
||||
|
||||
fn trusted_admin_proxy(state: &App, peer: Option<PeerAddr>) -> bool {
|
||||
let Some(peer) = peer else {
|
||||
return false;
|
||||
};
|
||||
let ip = peer.0.ip();
|
||||
ip.is_loopback() || state.config.load().forwarding.trusted(ip)
|
||||
}
|
||||
|
||||
fn trusted_admin_forwarded_proto(state: &App, peer: Option<PeerAddr>) -> bool {
|
||||
let Some(peer) = peer else {
|
||||
return false;
|
||||
};
|
||||
let ip = peer.0.ip();
|
||||
ip.is_loopback() || state.config.load().forwarding.trusted_forwarded_proto(ip)
|
||||
}
|
||||
|
||||
fn external_admin_authority(
|
||||
state: &App,
|
||||
headers: &HeaderMap,
|
||||
peer: Option<PeerAddr>,
|
||||
raw_host: &str,
|
||||
secure: bool,
|
||||
) -> Result<String, ()> {
|
||||
if trusted_admin_proxy(state, peer) && headers.contains_key("x-forwarded-host") {
|
||||
let forwarded = single_header(headers, "x-forwarded-host").ok_or(())?;
|
||||
if admin_host_allowed(
|
||||
forwarded,
|
||||
state.policy.admin_addr,
|
||||
secure,
|
||||
&state.active_administration.allowed_hosts,
|
||||
) {
|
||||
return Ok(forwarded.to_owned());
|
||||
}
|
||||
return Err(());
|
||||
}
|
||||
if admin_host_allowed(
|
||||
raw_host,
|
||||
state.policy.admin_addr,
|
||||
secure,
|
||||
&state.active_administration.allowed_hosts,
|
||||
) {
|
||||
Ok(raw_host.to_owned())
|
||||
} else {
|
||||
Err(())
|
||||
}
|
||||
}
|
||||
|
||||
fn external_admin_scheme(
|
||||
state: &App,
|
||||
headers: &HeaderMap,
|
||||
peer: Option<PeerAddr>,
|
||||
transport: Transport,
|
||||
) -> Result<&'static str, ()> {
|
||||
if transport.tls {
|
||||
return Ok("https");
|
||||
}
|
||||
if !trusted_admin_forwarded_proto(state, peer) || !headers.contains_key("x-forwarded-proto") {
|
||||
return Ok("http");
|
||||
}
|
||||
match single_header(headers, "x-forwarded-proto") {
|
||||
Some("https") => Ok("https"),
|
||||
Some("http") => Ok("http"),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Plain HTTP requests accepted on an HTTPS administration socket are redirected
|
||||
/// to the same authority/path. The TLS server only selects this router after it
|
||||
/// has positively identified a non-TLS first byte, so admin content is never
|
||||
/// served over this path.
|
||||
pub fn https_redirect_router() -> Router {
|
||||
Router::new().fallback(|request: Request| async move {
|
||||
let raw_host = request
|
||||
.headers()
|
||||
.get(HOST)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.unwrap_or("");
|
||||
let authority = raw_host.parse::<http::uri::Authority>();
|
||||
if request.headers().get_all(HOST).iter().count() != 1
|
||||
|| authority.as_ref().is_err()
|
||||
|| raw_host.contains('@')
|
||||
{
|
||||
return reject(StatusCode::BAD_REQUEST, "auth.invalid_host");
|
||||
}
|
||||
let path = request
|
||||
.uri()
|
||||
.path_and_query()
|
||||
.map(|value| value.as_str())
|
||||
.unwrap_or("/");
|
||||
let location = format!("https://{raw_host}{path}");
|
||||
match HeaderValue::from_str(&location) {
|
||||
Ok(location) => {
|
||||
let mut response = StatusCode::PERMANENT_REDIRECT.into_response();
|
||||
response
|
||||
.headers_mut()
|
||||
.insert(http::header::LOCATION, location);
|
||||
response.headers_mut().insert(
|
||||
http::header::CACHE_CONTROL,
|
||||
HeaderValue::from_static("no-store"),
|
||||
);
|
||||
response
|
||||
}
|
||||
Err(_) => reject(StatusCode::BAD_REQUEST, "auth.invalid_host"),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,427 @@
|
||||
async fn certificate_deploy(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
Json(input): Json<CertificateDeploy>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let directory = state.policy.cert_dir.clone().ok_or_else(|| {
|
||||
ApiError(
|
||||
StatusCode::CONFLICT,
|
||||
"certificate storage is unavailable; configure the certificate directory".into(),
|
||||
)
|
||||
})?;
|
||||
let CertificateDeploy {
|
||||
certificate_id,
|
||||
cert_pem,
|
||||
key_pem,
|
||||
} = input;
|
||||
let certificate_id = certificate_id.trim().to_owned();
|
||||
if !crate::config::valid_id(&certificate_id) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid certificate id".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let snapshot = state.config.load_full();
|
||||
let expected_revision = snapshot.envelope.revision;
|
||||
let mut config = snapshot.envelope.config.clone();
|
||||
let existing_index = config
|
||||
.tls
|
||||
.certificates
|
||||
.iter()
|
||||
.position(|certificate| certificate.id == certificate_id);
|
||||
let previous_pair = if let Some(index) = existing_index {
|
||||
let selected = &config.tls.certificates[index];
|
||||
if selected.issuer.is_some() {
|
||||
return Err(ApiError(
|
||||
StatusCode::CONFLICT,
|
||||
format!(
|
||||
"certificate {certificate_id} is managed by RustProxy ACME; use a separate manual certificate id for an external issuer"
|
||||
),
|
||||
));
|
||||
}
|
||||
Some((selected.cert_file.clone(), selected.key_file.clone()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
let key = zeroize::Zeroizing::new(key_pem);
|
||||
let permit = state
|
||||
.certificate_slots
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| {
|
||||
ApiError(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"certificate deployment queue is full".into(),
|
||||
)
|
||||
})?;
|
||||
let id_for_stage = certificate_id.clone();
|
||||
let current_pair = previous_pair.clone();
|
||||
let deployment = tokio::task::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
crate::tls::reconcile_external_pair(
|
||||
&directory,
|
||||
&id_for_stage,
|
||||
current_pair
|
||||
.as_ref()
|
||||
.map(|(cert_file, key_file)| (cert_file.as_str(), key_file.as_str())),
|
||||
cert_pem.as_bytes(),
|
||||
key.as_bytes(),
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ApiError(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"certificate deployment task failed".into(),
|
||||
)
|
||||
})?
|
||||
.map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{error:#}")))?;
|
||||
|
||||
match deployment {
|
||||
crate::tls::ExternalPairDeployment::Unchanged { covered_hosts } => {
|
||||
let index = existing_index.expect("unchanged deployment requires an existing pair");
|
||||
let cert_file = config.tls.certificates[index].cert_file.clone();
|
||||
let key_file = config.tls.certificates[index].key_file.clone();
|
||||
|
||||
if config.tls.certificates[index].covered_hosts == covered_hosts {
|
||||
// A no-op does not pass through update_as(), so explicitly retain
|
||||
// the same optimistic-concurrency guarantee as a real publish.
|
||||
if state.config.load().envelope.revision != expected_revision {
|
||||
return Err(ApiError(
|
||||
StatusCode::CONFLICT,
|
||||
"configuration changed during certificate deployment; retry".into(),
|
||||
));
|
||||
}
|
||||
return Ok(Json(json!({
|
||||
"deployed": true,
|
||||
"operation": "unchanged",
|
||||
"created": false,
|
||||
"changed": false,
|
||||
"certificate_id": certificate_id,
|
||||
"revision": expected_revision,
|
||||
"cert_file": cert_file,
|
||||
"key_file": key_file,
|
||||
"covered_hosts": covered_hosts,
|
||||
"public_tls": true,
|
||||
"http3_new_handshakes": state.config.load().quic_tls.is_some(),
|
||||
"administration_restart_required": false
|
||||
})));
|
||||
}
|
||||
|
||||
config.tls.certificates[index].covered_hosts = covered_hosts.clone();
|
||||
let updated = state
|
||||
.update_as(
|
||||
Update {
|
||||
expected_revision,
|
||||
config,
|
||||
},
|
||||
session.username.clone(),
|
||||
)
|
||||
.await?;
|
||||
state
|
||||
.store
|
||||
.audit(
|
||||
&session.username,
|
||||
"certificate.deploy",
|
||||
&format!(
|
||||
"certificate_id={certificate_id}; operation=metadata-updated; cert_file={cert_file}"
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
return Ok(Json(json!({
|
||||
"deployed": true,
|
||||
"operation": "metadata-updated",
|
||||
"created": false,
|
||||
"changed": true,
|
||||
"certificate_id": certificate_id,
|
||||
"revision": updated.revision,
|
||||
"cert_file": cert_file,
|
||||
"key_file": key_file,
|
||||
"covered_hosts": covered_hosts,
|
||||
"public_tls": true,
|
||||
"http3_new_handshakes": state.config.load().quic_tls.is_some(),
|
||||
"administration_restart_required": false
|
||||
})));
|
||||
}
|
||||
crate::tls::ExternalPairDeployment::Staged {
|
||||
cert_file,
|
||||
key_file,
|
||||
covered_hosts,
|
||||
} => {
|
||||
let created = existing_index.is_none();
|
||||
if let Some(index) = existing_index {
|
||||
let selected = &mut config.tls.certificates[index];
|
||||
selected.cert_file = cert_file.clone();
|
||||
selected.key_file = key_file.clone();
|
||||
selected.covered_hosts = covered_hosts.clone();
|
||||
} else {
|
||||
config.tls.certificates.push(crate::tls::Certificate {
|
||||
issuer: None,
|
||||
id: certificate_id.clone(),
|
||||
covered_hosts: covered_hosts.clone(),
|
||||
hosts: Vec::new(),
|
||||
cert_file: cert_file.clone(),
|
||||
key_file: key_file.clone(),
|
||||
});
|
||||
}
|
||||
|
||||
let administration_restart_required = config.administration.tls
|
||||
&& config.administration.certificate_id.as_deref()
|
||||
== Some(certificate_id.as_str());
|
||||
let result = state
|
||||
.update_as(
|
||||
Update {
|
||||
expected_revision,
|
||||
config,
|
||||
},
|
||||
session.username.clone(),
|
||||
)
|
||||
.await;
|
||||
let updated = match result {
|
||||
Ok(updated) => updated,
|
||||
Err(error) => {
|
||||
let rollback_directory = state.policy.cert_dir.clone();
|
||||
let rollback_cert = cert_file.clone();
|
||||
let rollback_key = key_file.clone();
|
||||
if let Some(rollback_directory) = rollback_directory {
|
||||
match tokio::task::spawn_blocking(move || {
|
||||
crate::tls::remove_external_pair(
|
||||
&rollback_directory,
|
||||
&rollback_cert,
|
||||
&rollback_key,
|
||||
)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Err(join_error) => tracing::warn!(%join_error, "external certificate rollback task failed"),
|
||||
Ok(Err(rollback_error)) => tracing::warn!(%rollback_error, "external certificate rollback failed"),
|
||||
Ok(Ok(())) => {}
|
||||
}
|
||||
}
|
||||
return Err(error);
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(previous_pair) = previous_pair {
|
||||
let previous_still_referenced =
|
||||
updated.config.tls.certificates.iter().any(|certificate| {
|
||||
certificate.cert_file == previous_pair.0
|
||||
|| certificate.key_file == previous_pair.1
|
||||
});
|
||||
if !previous_still_referenced
|
||||
&& previous_pair.0.starts_with("external-")
|
||||
&& previous_pair.1.starts_with("external-")
|
||||
{
|
||||
if let Some(cleanup_directory) = state.policy.cert_dir.clone() {
|
||||
let cleanup_cert = previous_pair.0.clone();
|
||||
let cleanup_key = previous_pair.1.clone();
|
||||
match tokio::task::spawn_blocking(move || {
|
||||
crate::tls::remove_external_pair(
|
||||
&cleanup_directory,
|
||||
&cleanup_cert,
|
||||
&cleanup_key,
|
||||
)
|
||||
})
|
||||
.await
|
||||
{
|
||||
Err(join_error) => tracing::warn!(%join_error, "previous external certificate cleanup task failed"),
|
||||
Ok(Err(cleanup_error)) => tracing::warn!(%cleanup_error, "previous external certificate cleanup failed"),
|
||||
Ok(Ok(())) => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let operation = if created { "created" } else { "updated" };
|
||||
state
|
||||
.store
|
||||
.audit(
|
||||
&session.username,
|
||||
"certificate.deploy",
|
||||
&format!(
|
||||
"certificate_id={certificate_id}; operation={operation}; cert_file={cert_file}"
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
Ok(Json(json!({
|
||||
"deployed": true,
|
||||
"operation": operation,
|
||||
"created": created,
|
||||
"changed": true,
|
||||
"certificate_id": certificate_id,
|
||||
"revision": updated.revision,
|
||||
"cert_file": cert_file,
|
||||
"key_file": key_file,
|
||||
"covered_hosts": covered_hosts,
|
||||
"public_tls": true,
|
||||
"http3_new_handshakes": state.config.load().quic_tls.is_some(),
|
||||
"administration_restart_required": administration_restart_required
|
||||
})))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn certificate_reload(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let revision = state.reload_certificates().await?;
|
||||
let snapshot = state.config.load_full();
|
||||
let admin_certificate_restart_required = snapshot
|
||||
.envelope
|
||||
.config
|
||||
.administration
|
||||
.tls
|
||||
&& snapshot.envelope.config.administration.certificate_id.is_some();
|
||||
state
|
||||
.store
|
||||
.audit(
|
||||
&session.username,
|
||||
"certificate.reload",
|
||||
"re-read configured certificate files",
|
||||
)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
Ok(Json(json!({
|
||||
"reloaded": true,
|
||||
"revision": revision,
|
||||
"public_tls": true,
|
||||
"http3_new_handshakes": snapshot.quic_tls.is_some(),
|
||||
"administration_restart_required": admin_certificate_restart_required
|
||||
})))
|
||||
}
|
||||
|
||||
|
||||
async fn certificate_reload_one(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
Path(certificate_id): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
if !crate::config::valid_id(&certificate_id) {
|
||||
return Err(ApiError(StatusCode::NOT_FOUND, "certificate not found".into()));
|
||||
}
|
||||
let revision = state.reload_certificate(&certificate_id).await?;
|
||||
let snapshot = state.config.load_full();
|
||||
let admin_certificate_restart_required = snapshot
|
||||
.envelope
|
||||
.config
|
||||
.administration
|
||||
.tls
|
||||
&& snapshot.envelope.config.administration.certificate_id.as_deref()
|
||||
== Some(certificate_id.as_str());
|
||||
state
|
||||
.store
|
||||
.audit(
|
||||
&session.username,
|
||||
"certificate.reload_one",
|
||||
&format!("certificate_id={certificate_id}"),
|
||||
)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
Ok(Json(json!({
|
||||
"reloaded": true,
|
||||
"certificate_id": certificate_id,
|
||||
"revision": revision,
|
||||
"public_tls": true,
|
||||
"http3_new_handshakes": snapshot.quic_tls.is_some(),
|
||||
"administration_restart_required": admin_certificate_restart_required
|
||||
})))
|
||||
}
|
||||
|
||||
async fn acme_status(State(state): State<Arc<App>>) -> Json<Value> {
|
||||
let snapshot = state.config.load();
|
||||
let log_directories: std::collections::BTreeMap<String, String> = state
|
||||
.policy
|
||||
.cert_dir
|
||||
.as_deref()
|
||||
.map(|directory| {
|
||||
snapshot
|
||||
.envelope
|
||||
.config
|
||||
.tls
|
||||
.certificates
|
||||
.iter()
|
||||
.filter_map(|certificate| {
|
||||
certificate.issuer.as_ref().map(|issuer| {
|
||||
(
|
||||
certificate.id.clone(),
|
||||
crate::acme::certbot_log_directory(
|
||||
directory,
|
||||
&issuer.provider,
|
||||
&issuer.environment,
|
||||
&certificate.id,
|
||||
),
|
||||
)
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
Json(json!({
|
||||
"job":state.acme.view(),
|
||||
"providers":["http01","cloudflare","ovh","rfc2136"],
|
||||
"automatic_renewal":true,
|
||||
"interval_seconds":43200,
|
||||
"renewals":state.acme.renewal_view(),
|
||||
"log_directories":log_directories
|
||||
}))
|
||||
}
|
||||
async fn acme_issue(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
Json(input): Json<Value>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let job = crate::acme::Manager::start(state, input, session.username).await?;
|
||||
Ok((StatusCode::ACCEPTED, Json(json!({"job":job}))).into_response())
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct BasicHash {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
async fn basic_hash(
|
||||
State(state): State<Arc<App>>,
|
||||
Json(input): Json<BasicHash>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
crate::basic_auth::validate_username(&input.username)
|
||||
.map_err(|_| ApiError(StatusCode::BAD_REQUEST, "basic.invalid_username".into()))?;
|
||||
// The semaphore is held by the blocking worker even if the HTTP client disconnects.
|
||||
let permit = state
|
||||
.basic_workers
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "basic.busy".into()))?;
|
||||
let password = zeroize::Zeroizing::new(input.password);
|
||||
auth::validate_password(&password)
|
||||
.map_err(|_| ApiError(StatusCode::BAD_REQUEST, "auth.password_policy".into()))?;
|
||||
if password.chars().any(char::is_control) {
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"auth.password_policy".into(),
|
||||
));
|
||||
}
|
||||
let hash = tokio::task::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
crate::auth::hash_sync(&password)
|
||||
})
|
||||
.await
|
||||
.map_err(auth::internal)?
|
||||
.map_err(auth::internal)?;
|
||||
Ok(Json(
|
||||
json!({"username":input.username,"password_hash":hash}),
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RenewRequest {
|
||||
certificate_id: String,
|
||||
#[serde(default)]
|
||||
dry_run: bool,
|
||||
}
|
||||
@@ -71,6 +71,7 @@ static READER_DOCUMENTS: &[(&str, &str, &str)] = &[
|
||||
("docs/V0.24.7_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.7_AUDIT.md"))),
|
||||
("docs/V0.24.8_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.24.8_AUDIT.md"))),
|
||||
("docs/V0.25.0_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.0_AUDIT.md"))),
|
||||
("docs/V0.25.1_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.1_AUDIT.md"))),
|
||||
("docs/V0.25.0_SECURITY_PREPROD_AUDIT.md", "audits", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/V0.25.0_SECURITY_PREPROD_AUDIT.md"))),
|
||||
("docs/VHOSTS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/VHOSTS.md"))),
|
||||
("docs/VHOST_ACCESS.md", "guides", include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/docs/VHOST_ACCESS.md"))),
|
||||
@@ -166,47 +167,4 @@ pub(super) fn asset(name: &'static str) -> Response {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reader_bundle_contains_embedded_project_and_web_server_docs() {
|
||||
let value: Value = serde_json::from_slice(reader_bundle()).unwrap();
|
||||
let documents = value["documents"].as_array().unwrap();
|
||||
assert!(documents.iter().any(|doc| doc["path"] == "README.md"));
|
||||
assert!(documents.iter().any(|doc| doc["path"] == "docs/WEB_SERVER.md"));
|
||||
assert!(documents.iter().any(|doc| doc["path"] == "docs/PHP_FPM.md"));
|
||||
assert!(documents.iter().all(|doc| {
|
||||
doc["path"].as_str().is_some_and(|value| !value.is_empty())
|
||||
&& doc["title"].as_str().is_some_and(|value| !value.is_empty())
|
||||
&& doc["category"].as_str().is_some_and(|value| !value.is_empty())
|
||||
&& doc["content"].as_str().is_some_and(|value| !value.is_empty())
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fixed_vendor_names_return_embedded_assets_with_expected_types() {
|
||||
for (name, expected_type) in [
|
||||
("swagger-ui-bundle.js", "text/javascript; charset=utf-8"),
|
||||
("swagger-ui.css", "text/css; charset=utf-8"),
|
||||
("licenses.txt", "text/plain; charset=utf-8"),
|
||||
] {
|
||||
let response = asset(name);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()["content-type"], expected_type);
|
||||
let bytes = axum::body::to_bytes(response.into_body(), 16 * 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!bytes.is_empty(), "{name}");
|
||||
}
|
||||
for name in [
|
||||
"../Cargo.toml",
|
||||
"index.html",
|
||||
"openapi.json",
|
||||
"source.json",
|
||||
"unknown.js",
|
||||
] {
|
||||
assert_eq!(asset(name).status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
}
|
||||
include!("documentation/tests.rs");
|
||||
@@ -0,0 +1,44 @@
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn reader_bundle_contains_embedded_project_and_web_server_docs() {
|
||||
let value: Value = serde_json::from_slice(reader_bundle()).unwrap();
|
||||
let documents = value["documents"].as_array().unwrap();
|
||||
assert!(documents.iter().any(|doc| doc["path"] == "README.md"));
|
||||
assert!(documents.iter().any(|doc| doc["path"] == "docs/WEB_SERVER.md"));
|
||||
assert!(documents.iter().any(|doc| doc["path"] == "docs/PHP_FPM.md"));
|
||||
assert!(documents.iter().all(|doc| {
|
||||
doc["path"].as_str().is_some_and(|value| !value.is_empty())
|
||||
&& doc["title"].as_str().is_some_and(|value| !value.is_empty())
|
||||
&& doc["category"].as_str().is_some_and(|value| !value.is_empty())
|
||||
&& doc["content"].as_str().is_some_and(|value| !value.is_empty())
|
||||
}));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn fixed_vendor_names_return_embedded_assets_with_expected_types() {
|
||||
for (name, expected_type) in [
|
||||
("swagger-ui-bundle.js", "text/javascript; charset=utf-8"),
|
||||
("swagger-ui.css", "text/css; charset=utf-8"),
|
||||
("licenses.txt", "text/plain; charset=utf-8"),
|
||||
] {
|
||||
let response = asset(name);
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(response.headers()["content-type"], expected_type);
|
||||
let bytes = axum::body::to_bytes(response.into_body(), 16 * 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!bytes.is_empty(), "{name}");
|
||||
}
|
||||
for name in [
|
||||
"../Cargo.toml",
|
||||
"index.html",
|
||||
"openapi.json",
|
||||
"source.json",
|
||||
"unknown.js",
|
||||
] {
|
||||
assert_eq!(asset(name).status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
async fn update_section(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
Path(section): Path<String>,
|
||||
Json(input): Json<SectionUpdate>,
|
||||
) -> Result<Json<crate::config::Envelope>, ApiError> {
|
||||
let current = state.config.load_full();
|
||||
if current.envelope.revision != input.expected_revision {
|
||||
return Err(ApiError(
|
||||
StatusCode::CONFLICT,
|
||||
"configuration changed; reload before saving".into(),
|
||||
));
|
||||
}
|
||||
let mut config = current.envelope.config.clone();
|
||||
let value = input.value;
|
||||
match section.as_str() {
|
||||
"listeners" => {
|
||||
config.listeners =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"docker-agents" => {
|
||||
config.docker_agents =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"http2" => {
|
||||
config.http2 =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"response" => {
|
||||
let part: ResponseSection =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?;
|
||||
config.server_header = part.server_header;
|
||||
config.diagnostic_headers = part.diagnostic_headers;
|
||||
config.compression = part.compression;
|
||||
}
|
||||
"pages" => {
|
||||
config.pages =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"cache" => {
|
||||
config.cache =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"transform" => {
|
||||
config.transform =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"logging" => {
|
||||
if value.get("logging").is_some() || value.get("error_log").is_some() {
|
||||
let part: LoggingSection =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?;
|
||||
config.logging = part.logging;
|
||||
config.error_log = part.error_log;
|
||||
} else {
|
||||
// Backward compatibility for clients that still send only the
|
||||
// historical access-logging object to /api/config/logging.
|
||||
config.logging =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?;
|
||||
}
|
||||
}
|
||||
"forwarding" => {
|
||||
config.forwarding =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"headers" => {
|
||||
config.headers =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"prometheus" => {
|
||||
config.prometheus =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"administration" => {
|
||||
config.administration =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"tls" => {
|
||||
config.tls = serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"vhosts" => {
|
||||
config.vhosts =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"web-services" => {
|
||||
config.web_services =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"redirects" => {
|
||||
config.redirects =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"not_found_hosts" => {
|
||||
config.not_found_hosts =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"streams" => {
|
||||
config.streams =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"acl-profiles" => {
|
||||
config.acl_profiles =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
"host-policies" => {
|
||||
config.host_policies =
|
||||
serde_json::from_value(value).map_err(|e| invalid_section(§ion, e))?
|
||||
}
|
||||
_ => {
|
||||
return Err(ApiError(
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown configuration section".into(),
|
||||
))
|
||||
}
|
||||
}
|
||||
state
|
||||
.update_as(
|
||||
Update {
|
||||
expected_revision: input.expected_revision,
|
||||
config,
|
||||
},
|
||||
session.username,
|
||||
)
|
||||
.await
|
||||
.map(Json)
|
||||
}
|
||||
fn logs_payload(state: &App) -> Value {
|
||||
json!({
|
||||
"entries": state.logs.recent_limit(crate::logging::LIVE_WINDOW),
|
||||
"capacity": crate::logging::HISTORY_CAPACITY,
|
||||
"window": crate::logging::LIVE_WINDOW,
|
||||
"dropped": state.logs.dropped.load(Ordering::Relaxed)
|
||||
})
|
||||
}
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct LogFilter {
|
||||
vhost: Option<String>,
|
||||
q: Option<String>,
|
||||
status: Option<u16>,
|
||||
cache: Option<String>,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
async fn logs(
|
||||
State(state): State<Arc<App>>,
|
||||
Query(filter): Query<LogFilter>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
if filter.q.as_ref().is_some_and(|q| q.len() > 256)
|
||||
|| filter
|
||||
.vhost
|
||||
.as_ref()
|
||||
.is_some_and(|v| !crate::config::valid_id(v))
|
||||
|| filter
|
||||
.cache
|
||||
.as_ref()
|
||||
.is_some_and(|v| !matches!(v.as_str(), "HIT" | "MISS" | "BYPASS" | "-"))
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid log filter".into(),
|
||||
));
|
||||
}
|
||||
let q = filter.q.unwrap_or_default().to_ascii_lowercase();
|
||||
let limit = filter
|
||||
.limit
|
||||
.unwrap_or(crate::logging::LIVE_WINDOW)
|
||||
.clamp(1, crate::logging::HISTORY_CAPACITY);
|
||||
let entries: Vec<_> = state
|
||||
.logs
|
||||
.recent()
|
||||
.into_iter()
|
||||
.filter(|e| {
|
||||
filter
|
||||
.vhost
|
||||
.as_ref()
|
||||
.is_none_or(|v| e.vhost.as_ref() == Some(v))
|
||||
&& filter.status.is_none_or(|v| e.status == v)
|
||||
&& filter.cache.as_ref().is_none_or(|v| e.cache == v.as_str())
|
||||
&& (q.is_empty()
|
||||
|| format!(
|
||||
"{} {} {} {}",
|
||||
e.method,
|
||||
e.host,
|
||||
e.path,
|
||||
e.backend.as_deref().unwrap_or("")
|
||||
)
|
||||
.to_ascii_lowercase()
|
||||
.contains(&q))
|
||||
})
|
||||
.take(limit)
|
||||
.collect();
|
||||
Ok(Json(
|
||||
json!({
|
||||
"entries": entries,
|
||||
"capacity": crate::logging::HISTORY_CAPACITY,
|
||||
"window": crate::logging::LIVE_WINDOW,
|
||||
"dropped": state.logs.dropped.load(Ordering::Relaxed)
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
async fn live_logs_stream(
|
||||
State(state): State<Arc<App>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Response, ApiError> {
|
||||
let secret = auth::cookie_secret(&headers)
|
||||
.ok_or_else(|| ApiError(StatusCode::UNAUTHORIZED, "auth.required".into()))?;
|
||||
let permit = state
|
||||
.viewer_slots
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "events.too_many_viewers".into()))?;
|
||||
let receiver = state.logs.subscribe();
|
||||
let next_auth_check = tokio::time::Instant::now() + Duration::from_secs(10);
|
||||
let stream = futures_util::stream::unfold(
|
||||
(state, secret, permit, receiver, next_auth_check),
|
||||
|(state, secret, permit, mut receiver, mut next_auth_check)| async move {
|
||||
if tokio::time::Instant::now() >= next_auth_check {
|
||||
let session_seconds = state
|
||||
.config
|
||||
.load()
|
||||
.envelope
|
||||
.config
|
||||
.administration
|
||||
.session_seconds();
|
||||
match state
|
||||
.store
|
||||
.session_at_with_browser_lifetime(&secret, auth::now(), session_seconds)
|
||||
.await
|
||||
{
|
||||
Ok(Some(_)) => {
|
||||
next_auth_check = tokio::time::Instant::now() + Duration::from_secs(10);
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
let event = tokio::select! {
|
||||
received = receiver.recv() => match received {
|
||||
Ok(entry) => Event::default()
|
||||
.event("log")
|
||||
.data(serde_json::to_string(&entry).unwrap_or_else(|_| "{}".into())),
|
||||
Err(tokio::sync::broadcast::error::RecvError::Lagged(dropped)) => {
|
||||
Event::default().event("gap").data(json!({"dropped": dropped}).to_string())
|
||||
}
|
||||
Err(tokio::sync::broadcast::error::RecvError::Closed) => return None,
|
||||
},
|
||||
_ = tokio::time::sleep_until(next_auth_check) => {
|
||||
let session_seconds = state
|
||||
.config
|
||||
.load()
|
||||
.envelope
|
||||
.config
|
||||
.administration
|
||||
.session_seconds();
|
||||
match state
|
||||
.store
|
||||
.session_at_with_browser_lifetime(&secret, auth::now(), session_seconds)
|
||||
.await
|
||||
{
|
||||
Ok(Some(_)) => {
|
||||
next_auth_check = tokio::time::Instant::now() + Duration::from_secs(10);
|
||||
Event::default().event("heartbeat").data("{}")
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
};
|
||||
Some((
|
||||
Ok::<Event, Infallible>(event),
|
||||
(state, secret, permit, receiver, next_auth_check),
|
||||
))
|
||||
},
|
||||
);
|
||||
Ok(Sse::new(stream)
|
||||
.keep_alive(KeepAlive::new().interval(Duration::from_secs(5)))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
async fn audit(State(state): State<Arc<App>>) -> Result<Json<Value>, ApiError> {
|
||||
Ok(Json(
|
||||
json!({"entries":state.store.recent_audit().await.map_err(auth::internal)?,"capacity":crate::db::AUDIT_CAPACITY}),
|
||||
))
|
||||
}
|
||||
async fn metrics(State(state): State<Arc<App>>) -> Response {
|
||||
crate::prometheus::response(&state)
|
||||
}
|
||||
async fn events(State(state): State<Arc<App>>, headers: HeaderMap) -> Result<Response, ApiError> {
|
||||
let secret = auth::cookie_secret(&headers)
|
||||
.ok_or_else(|| ApiError(StatusCode::UNAUTHORIZED, "auth.required".into()))?;
|
||||
let permit = state
|
||||
.viewer_slots
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| {
|
||||
ApiError(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"events.too_many_viewers".into(),
|
||||
)
|
||||
})?;
|
||||
// One bounded snapshot, produced only when the consumer polls. No per-client queue.
|
||||
// Absolute expiry/revocation is rechecked on every snapshot; no immortal SSE sessions.
|
||||
let stream = futures_util::stream::unfold(
|
||||
(state, secret, permit, false),
|
||||
|(state, secret, permit, delayed)| async move {
|
||||
if delayed {
|
||||
tokio::time::sleep(Duration::from_secs(2)).await;
|
||||
}
|
||||
let session_seconds = state
|
||||
.config
|
||||
.load()
|
||||
.envelope
|
||||
.config
|
||||
.administration
|
||||
.session_seconds();
|
||||
match state
|
||||
.store
|
||||
.session_at_with_browser_lifetime(&secret, auth::now(), session_seconds)
|
||||
.await
|
||||
{
|
||||
Ok(Some(_)) => {}
|
||||
_ => return None,
|
||||
}
|
||||
let payload =
|
||||
json!({"status":status_payload(&state), "logs":logs_payload(&state)}).to_string();
|
||||
let event = Event::default().event("snapshot").data(payload);
|
||||
Some((
|
||||
Ok::<Event, Infallible>(event),
|
||||
(state, secret, permit, true),
|
||||
))
|
||||
},
|
||||
);
|
||||
Ok(Sse::new(stream)
|
||||
.keep_alive(KeepAlive::new().interval(Duration::from_secs(10)))
|
||||
.into_response())
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct TrafficQuery {
|
||||
vhost: Option<String>,
|
||||
}
|
||||
async fn traffic_status(
|
||||
State(state): State<Arc<App>>,
|
||||
Query(query): Query<TrafficQuery>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let snapshot = state.config.load_full();
|
||||
if query.vhost.as_ref().is_some_and(|id| {
|
||||
id != "*" && !snapshot.envelope.config.vhosts.iter().any(|vhost| &vhost.id == id)
|
||||
}) {
|
||||
return Err(ApiError(
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown vhost traffic scope".into(),
|
||||
));
|
||||
}
|
||||
let scope = query.vhost.as_deref().unwrap_or("*");
|
||||
let vhosts = snapshot
|
||||
.envelope
|
||||
.config
|
||||
.vhosts
|
||||
.iter()
|
||||
.map(|vhost| json!({"id": vhost.id, "hosts": vhost.frontend.hosts}))
|
||||
.collect::<Vec<_>>();
|
||||
Ok(Json(json!({
|
||||
"scope": scope,
|
||||
"since": "process_start",
|
||||
"settings": {
|
||||
"capture_user_agent": snapshot.envelope.config.logging.capture_user_agent,
|
||||
"capture_country": snapshot.envelope.config.logging.capture_country
|
||||
},
|
||||
"geoip": state.geoip.view(),
|
||||
"analytics": state.traffic.view(Some(scope)),
|
||||
"vhosts": vhosts
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct CacheQuery {
|
||||
vhost: Option<String>,
|
||||
offset: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
}
|
||||
async fn cache_status(
|
||||
State(state): State<Arc<App>>,
|
||||
Query(query): Query<CacheQuery>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let snapshot = state.config.load_full();
|
||||
if query
|
||||
.vhost
|
||||
.as_ref()
|
||||
.is_some_and(|id| !snapshot.envelope.config.vhosts.iter().any(|v| &v.id == id))
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown vhost cache scope".into(),
|
||||
));
|
||||
}
|
||||
let scopes: Vec<_> = snapshot.envelope.config.vhosts.iter().map(|vhost| {
|
||||
json!({"id":vhost.id, "hosts":vhost.frontend.hosts, "metrics":state.cache.scope_view(&vhost.id),
|
||||
"settings":vhost.cache.resolve(&snapshot.envelope.config.cache).ok()})
|
||||
}).collect();
|
||||
let dashboard = state.cache.dashboard(
|
||||
query.vhost.as_deref(),
|
||||
query.offset.unwrap_or(0).min(16384),
|
||||
query.limit.unwrap_or(50),
|
||||
);
|
||||
Ok(Json(
|
||||
json!({"settings":snapshot.envelope.config.cache, "metrics":state.cache.view(), "vhosts":scopes, "dashboard":dashboard, "bans":state.cache.bans()}),
|
||||
))
|
||||
}
|
||||
+9
-4499
File diff suppressed because it is too large.
Load diff
@@ -2,7 +2,7 @@
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "RustProxy Administration API",
|
||||
"version": "0.25.0",
|
||||
"version": "0.25.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": [
|
||||
|
||||
@@ -0,0 +1,405 @@
|
||||
async fn transform_status(State(state): State<Arc<App>>) -> Json<Value> {
|
||||
let snapshot = state.config.load_full();
|
||||
let global = &snapshot.envelope.config.transform;
|
||||
let vhosts: Vec<_> = snapshot
|
||||
.envelope
|
||||
.config
|
||||
.vhosts
|
||||
.iter()
|
||||
.map(|vhost| {
|
||||
json!({
|
||||
"id": vhost.id,
|
||||
"hosts": vhost.frontend.hosts,
|
||||
"settings": vhost.transform.resolve(global).ok()
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
Json(json!({
|
||||
"settings": global,
|
||||
"resources": crate::transform::resource_usage(global),
|
||||
"metrics": state.transform_metrics.view(),
|
||||
"vhosts": vhosts
|
||||
}))
|
||||
}
|
||||
|
||||
#[derive(Default, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
struct PurgeScope {
|
||||
vhost: Option<String>,
|
||||
}
|
||||
async fn cache_purge(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
Query(scope): Query<PurgeScope>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
if let Some(id) = &scope.vhost {
|
||||
if !state
|
||||
.config
|
||||
.load()
|
||||
.envelope
|
||||
.config
|
||||
.vhosts
|
||||
.iter()
|
||||
.any(|host| &host.id == id)
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown vhost cache scope".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let detail = format!(
|
||||
"scope={}; in-flight fills invalidated",
|
||||
scope.vhost.as_deref().unwrap_or("all")
|
||||
);
|
||||
state
|
||||
.store
|
||||
.audit(&session.username, "cache.purge", &detail)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
let purged = match &scope.vhost {
|
||||
Some(id) => state.cache.purge_scope(id),
|
||||
None => state.cache.purge(None),
|
||||
};
|
||||
Ok(Json(
|
||||
json!({"purged_entries":purged, "vhost":scope.vhost, "metrics":state.cache.view()}),
|
||||
))
|
||||
}
|
||||
|
||||
|
||||
async fn cache_invalidate(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
input: Result<Json<crate::cache::InvalidationRequest>, axum::extract::rejection::JsonRejection>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let Json(request) = input.map_err(public::invalid_json)?;
|
||||
if let Some(id) = &request.vhost {
|
||||
if !state
|
||||
.config
|
||||
.load()
|
||||
.envelope
|
||||
.config
|
||||
.vhosts
|
||||
.iter()
|
||||
.any(|host| &host.id == id)
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::NOT_FOUND,
|
||||
"unknown vhost cache scope".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
let detail = format!(
|
||||
"operation={:?}; selector={:?}; vhost={}; host={}; value={:?}",
|
||||
request.operation,
|
||||
request.selector,
|
||||
request.vhost.as_deref().unwrap_or("all"),
|
||||
request.host.as_deref().unwrap_or("any"),
|
||||
request.value
|
||||
);
|
||||
state
|
||||
.store
|
||||
.audit(&session.username, "cache.invalidate", &detail)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
let result = state
|
||||
.cache
|
||||
.invalidate(request)
|
||||
.map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, error.to_string()))?;
|
||||
Ok(Json(json!({
|
||||
"result": result,
|
||||
"metrics": state.cache.view(),
|
||||
"bans": state.cache.bans()
|
||||
})))
|
||||
}
|
||||
|
||||
async fn cache_bans(State(state): State<Arc<App>>) -> Json<Value> {
|
||||
Json(json!({"bans": state.cache.bans()}))
|
||||
}
|
||||
|
||||
async fn cache_ban_delete(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
Path(id): Path<u64>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
if !state.cache.remove_ban(id) {
|
||||
return Err(ApiError(StatusCode::NOT_FOUND, "unknown cache BAN rule".into()));
|
||||
}
|
||||
state
|
||||
.store
|
||||
.audit(&session.username, "cache.ban.remove", &format!("ban_id={id}"))
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
Ok(Json(json!({"removed": id, "bans": state.cache.bans()})))
|
||||
}
|
||||
|
||||
async fn active_tunnels(State(state): State<Arc<App>>) -> Json<Value> {
|
||||
Json(json!({"tunnels": state.tunnel_registry.list()}))
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct KillTunnelRequest {
|
||||
#[serde(default)]
|
||||
reason: Option<String>,
|
||||
}
|
||||
|
||||
async fn kill_tunnel(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
Path(id): Path<u64>,
|
||||
input: Result<Json<KillTunnelRequest>, axum::extract::rejection::JsonRejection>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let Json(input) = input.map_err(public::invalid_json)?;
|
||||
let reason = input
|
||||
.reason
|
||||
.as_deref()
|
||||
.map(str::trim)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or("administrator request");
|
||||
if reason.len() > 256 || reason.chars().any(char::is_control) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid tunnel close reason".into(),
|
||||
));
|
||||
}
|
||||
let Some(tunnel) = state.tunnel_registry.kill(id) else {
|
||||
return Err(ApiError(StatusCode::NOT_FOUND, "unknown active tunnel".into()));
|
||||
};
|
||||
state
|
||||
.store
|
||||
.audit(
|
||||
&session.username,
|
||||
"tunnel.kill",
|
||||
&format!(
|
||||
"tunnel_id={}; client={}; host={}; upstream={}; protocol={}; reason={}",
|
||||
tunnel.id, tunnel.client, tunnel.host, tunnel.upstream, tunnel.protocol, reason
|
||||
),
|
||||
)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
Ok(Json(json!({"closed": tunnel, "reason": reason})))
|
||||
}
|
||||
|
||||
async fn cache_cold_cleanup(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
input: Result<Json<crate::cache::ColdCleanupRequest>, axum::extract::rejection::JsonRejection>,
|
||||
) -> Result<(StatusCode, Json<Value>), ApiError> {
|
||||
let Json(request) = input.map_err(public::invalid_json)?;
|
||||
let detail = format!(
|
||||
"unused_for_seconds={:?}; oldest={:?}; hits_zero={}",
|
||||
request.unused_for_seconds, request.oldest, request.hits_zero
|
||||
);
|
||||
let job = state
|
||||
.cache
|
||||
.start_cold_cleanup(request)
|
||||
.map_err(|error| ApiError(StatusCode::UNPROCESSABLE_ENTITY, error.to_string()))?;
|
||||
state
|
||||
.store
|
||||
.audit(&session.username, "cache.cold_cleanup", &detail)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
Ok((StatusCode::ACCEPTED, Json(json!({"job": job}))))
|
||||
}
|
||||
|
||||
async fn cache_cold_cleanup_job(
|
||||
State(state): State<Arc<App>>,
|
||||
Path(id): Path<u64>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let job = state
|
||||
.cache
|
||||
.cold_cleanup_job(id)
|
||||
.ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "unknown cold-cache cleanup job".into()))?;
|
||||
Ok(Json(json!({"job": job})))
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CertificateUpload {
|
||||
cert_pem: String,
|
||||
key_pem: String,
|
||||
hosts: Vec<String>,
|
||||
}
|
||||
async fn certificate_upload(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
Json(input): Json<CertificateUpload>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let key = zeroize::Zeroizing::new(input.key_pem);
|
||||
let directory = state.policy.cert_dir.clone().ok_or_else(|| {
|
||||
ApiError(
|
||||
StatusCode::CONFLICT,
|
||||
"certificate storage is unavailable; configure the certificate directory".into(),
|
||||
)
|
||||
})?;
|
||||
let permit = state
|
||||
.certificate_slots
|
||||
.clone()
|
||||
.try_acquire_owned()
|
||||
.map_err(|_| {
|
||||
ApiError(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
"certificate upload queue is full".into(),
|
||||
)
|
||||
})?;
|
||||
let certificate = tokio::task::spawn_blocking(move || {
|
||||
let _permit = permit;
|
||||
crate::tls::upload(
|
||||
&directory,
|
||||
input.cert_pem.as_bytes(),
|
||||
key.as_bytes(),
|
||||
input.hosts,
|
||||
)
|
||||
})
|
||||
.await
|
||||
.map_err(|_| {
|
||||
ApiError(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"certificate upload task failed".into(),
|
||||
)
|
||||
})?
|
||||
.map_err(|e| ApiError(StatusCode::UNPROCESSABLE_ENTITY, format!("{e:#}")))?;
|
||||
state
|
||||
.store
|
||||
.audit(&session.username, "certificate.upload", &certificate.id)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
// Never serialize the PEM request or any key material, including on success.
|
||||
Ok(Json(json!({"certificate":certificate, "staged":true})))
|
||||
}
|
||||
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CertificateApiRotate {
|
||||
#[serde(default)]
|
||||
key_id: Option<String>,
|
||||
#[serde(default)]
|
||||
token: Option<String>,
|
||||
#[serde(default)]
|
||||
comment: Option<String>,
|
||||
}
|
||||
|
||||
fn valid_certificate_api_comment(value: &str) -> bool {
|
||||
value.len() <= 256 && !value.chars().any(char::is_control)
|
||||
}
|
||||
|
||||
fn certificate_api_status_value(values: Vec<crate::db::CertificateApiCredential>) -> Value {
|
||||
let legacy = values.first();
|
||||
json!({
|
||||
"configured": !values.is_empty(),
|
||||
"key_id": legacy.map(|value| value.key_id.clone()),
|
||||
"created_at": legacy.map(|value| value.created_at),
|
||||
"last_used_at": legacy.and_then(|value| value.last_used_at),
|
||||
"credentials": values,
|
||||
"max_credentials": crate::db::CERTIFICATE_API_CREDENTIAL_CAPACITY,
|
||||
"scopes": ["certificates:deploy", "certificates:reload"],
|
||||
})
|
||||
}
|
||||
|
||||
async fn certificate_api_status(State(state): State<Arc<App>>) -> Result<Json<Value>, ApiError> {
|
||||
let values = state
|
||||
.store
|
||||
.certificate_api_credentials()
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
Ok(Json(certificate_api_status_value(values)))
|
||||
}
|
||||
|
||||
async fn certificate_api_rotate(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
Json(input): Json<CertificateApiRotate>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let generated = input.token.is_none();
|
||||
let token = input.token.unwrap_or_else(auth::random_secret);
|
||||
if !valid_certificate_api_token(&token) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"certificate_api.invalid_token_format".into(),
|
||||
));
|
||||
}
|
||||
let key_id = input.key_id.unwrap_or_else(|| {
|
||||
let random = auth::random_secret();
|
||||
format!("certapi-{}", &random[..12])
|
||||
});
|
||||
if !valid_certificate_api_key_id(&key_id) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"certificate_api.invalid_key_id".into(),
|
||||
));
|
||||
}
|
||||
let comment = input.comment.unwrap_or_default().trim().to_owned();
|
||||
if !valid_certificate_api_comment(&comment) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"certificate_api.invalid_comment".into(),
|
||||
));
|
||||
}
|
||||
let token_hash = auth::secret_hash(&token);
|
||||
let credential = state
|
||||
.store
|
||||
.upsert_certificate_api_credential(
|
||||
&key_id,
|
||||
&token_hash,
|
||||
&comment,
|
||||
&session.username,
|
||||
)
|
||||
.await
|
||||
.map_err(|error| {
|
||||
if error.to_string().contains("credential limit reached") {
|
||||
ApiError(StatusCode::CONFLICT, "certificate_api.limit_reached".into())
|
||||
} else {
|
||||
auth::internal(error)
|
||||
}
|
||||
})?;
|
||||
let values = state
|
||||
.store
|
||||
.certificate_api_credentials()
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
let mut value = certificate_api_status_value(values);
|
||||
value["credential"] = serde_json::to_value(&credential).map_err(auth::internal)?;
|
||||
value["token"] = Value::String(token);
|
||||
value["generated"] = Value::Bool(generated);
|
||||
Ok(Json(value))
|
||||
}
|
||||
|
||||
async fn certificate_api_revoke(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
Path(key_id): Path<String>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
if !valid_certificate_api_key_id(&key_id) {
|
||||
return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()));
|
||||
}
|
||||
let revoked = state
|
||||
.store
|
||||
.revoke_certificate_api_credential(&key_id, &session.username)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
if !revoked {
|
||||
return Err(ApiError(StatusCode::NOT_FOUND, "certificate_api.not_found".into()));
|
||||
}
|
||||
Ok(Json(json!({"revoked": true, "key_id": key_id})))
|
||||
}
|
||||
|
||||
async fn certificate_api_revoke_all(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let revoked = state
|
||||
.store
|
||||
.revoke_all_certificate_api_credentials(&session.username)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
Ok(Json(json!({"configured": false, "revoked": revoked})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct CertificateDeploy {
|
||||
certificate_id: String,
|
||||
cert_pem: String,
|
||||
key_pem: String,
|
||||
}
|
||||
@@ -0,0 +1,451 @@
|
||||
mod documentation;
|
||||
mod public;
|
||||
use crate::{
|
||||
auth::{self, Session},
|
||||
config::Update,
|
||||
runtime::{ApiError, App},
|
||||
server::{PeerAddr, Transport},
|
||||
};
|
||||
use axum::{
|
||||
extract::{DefaultBodyLimit, Path, Query, Request, State},
|
||||
middleware::{self, Next},
|
||||
response::{
|
||||
sse::{Event, KeepAlive, Sse},
|
||||
IntoResponse, Response,
|
||||
},
|
||||
routing::{delete, get, post, put},
|
||||
Extension, Json, Router,
|
||||
};
|
||||
use http::{
|
||||
header::{AUTHORIZATION, HOST, ORIGIN, SET_COOKIE},
|
||||
HeaderMap, HeaderValue, Method, StatusCode, Version,
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::{
|
||||
convert::Infallible,
|
||||
net::IpAddr,
|
||||
sync::{atomic::Ordering, Arc},
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
fn admin_app_javascript() -> &'static str {
|
||||
static ASSET: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
|
||||
[
|
||||
include_str!("web/app/core.js"),
|
||||
include_str!("web/app/runtime.js"),
|
||||
include_str!("web/app/settings.js"),
|
||||
].join("\n")
|
||||
});
|
||||
ASSET.as_str()
|
||||
}
|
||||
|
||||
fn admin_vhosts_javascript() -> &'static str {
|
||||
static ASSET: std::sync::LazyLock<String> = std::sync::LazyLock::new(|| {
|
||||
[
|
||||
"(() => {",
|
||||
include_str!("web/vhosts/core.js"),
|
||||
include_str!("web/vhosts/editor.js"),
|
||||
include_str!("web/vhosts/render.js"),
|
||||
"})();",
|
||||
].join("\n")
|
||||
});
|
||||
ASSET.as_str()
|
||||
}
|
||||
|
||||
pub fn router(state: Arc<App>) -> Router {
|
||||
let authentication = Router::new()
|
||||
.route("/api/auth/status", get(auth_status))
|
||||
.route("/api/auth/setup", post(setup))
|
||||
.route("/api/auth/login", post(login))
|
||||
.route("/api/auth/session", get(session))
|
||||
.route("/api/auth/logout", post(logout))
|
||||
.route("/api/auth/logout-all", post(logout_all))
|
||||
.route("/api/auth/password", post(password))
|
||||
.layer(DefaultBodyLimit::max(4096));
|
||||
Router::new()
|
||||
.merge(authentication)
|
||||
.route("/", get(public::index))
|
||||
.route("/overview", get(public::index))
|
||||
.route("/vhosts", get(public::index))
|
||||
.route("/web-server", get(public::index))
|
||||
.route("/docker-vhosts", get(public::index))
|
||||
.route("/redirects", get(public::index))
|
||||
.route("/404-hosts", get(public::index))
|
||||
.route("/streams", get(public::index))
|
||||
.route("/acl-profiles", get(public::index))
|
||||
.route("/configuration", get(public::index))
|
||||
.route("/tls", get(public::index))
|
||||
.route("/traffic", get(public::index))
|
||||
.route("/cache", get(public::index))
|
||||
.route("/transform", get(public::index))
|
||||
.route("/logging", get(public::index))
|
||||
.route("/settings", get(public::index))
|
||||
.route(
|
||||
"/assets/login.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/login.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/login-langs.json",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "application/json; charset=utf-8")],
|
||||
include_str!(concat!(env!("OUT_DIR"), "/login-langs.json")),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route("/docs", get(documentation::index))
|
||||
.route("/docs/", get(documentation::index))
|
||||
.route("/docs/openapi.json", get(documentation::schema))
|
||||
.route("/docs/content.json", get(documentation::content))
|
||||
.route("/docs/init.js", get(documentation::script))
|
||||
.route("/docs/style.css", get(documentation::style))
|
||||
.route(
|
||||
"/docs/swagger-ui-bundle.js",
|
||||
get(|| async { documentation::asset("swagger-ui-bundle.js") }),
|
||||
)
|
||||
.route(
|
||||
"/docs/swagger-ui.css",
|
||||
get(|| async { documentation::asset("swagger-ui.css") }),
|
||||
)
|
||||
.route(
|
||||
"/docs/licenses.txt",
|
||||
get(|| async { documentation::asset("licenses.txt") }),
|
||||
)
|
||||
.route(
|
||||
"/assets/langs.json",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "application/json; charset=utf-8")],
|
||||
include_str!(concat!(env!("OUT_DIR"), "/langs.json")),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/app.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
admin_app_javascript(),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/management.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/management.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/integrations.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/integrations.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/traffic-panel.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/traffic-panel.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/cache-panel.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/cache-panel.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/transform-panel.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/transform-panel.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/settings.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/settings.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/acme.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/acme.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/vhosts.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
admin_vhosts_javascript(),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/web-server.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/web-server.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/docker.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/docker.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/streams.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/streams.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/redirects.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/redirects.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/host404.js",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/javascript; charset=utf-8")],
|
||||
include_str!("web/host404.js"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/assets/style.css",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "text/css; charset=utf-8")],
|
||||
include_str!("web/style.css"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route(
|
||||
"/favicon.svg",
|
||||
get(|| async {
|
||||
(
|
||||
[("content-type", "image/svg+xml")],
|
||||
include_str!("web/favicon.svg"),
|
||||
)
|
||||
}),
|
||||
)
|
||||
.route("/healthz", get(|| async { Json(json!({"status":"ok"})) }))
|
||||
.route("/api/status", get(status))
|
||||
.route("/api/backends", get(backend_status))
|
||||
.route("/api/docker", get(docker_status))
|
||||
.route("/api/docker/refresh", post(docker_refresh))
|
||||
.route("/api/certificates", get(certificate_inventory))
|
||||
.route(
|
||||
"/api/basic-auth/hash",
|
||||
post(basic_hash).layer(DefaultBodyLimit::max(4096)),
|
||||
)
|
||||
.route("/api/acme", get(acme_status))
|
||||
.route(
|
||||
"/api/acme/renew",
|
||||
post(acme_renew).layer(DefaultBodyLimit::max(4096)),
|
||||
)
|
||||
.route(
|
||||
"/api/acme/issue",
|
||||
post(acme_issue).layer(DefaultBodyLimit::max(16384)),
|
||||
)
|
||||
.route(
|
||||
"/api/certificates/upload",
|
||||
post(certificate_upload).layer(DefaultBodyLimit::max(300 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/api/certificates/deploy",
|
||||
post(certificate_deploy).layer(DefaultBodyLimit::max(300 * 1024)),
|
||||
)
|
||||
.route(
|
||||
"/api/certificates/reload",
|
||||
post(certificate_reload).layer(DefaultBodyLimit::max(4096)),
|
||||
)
|
||||
.route(
|
||||
"/api/certificates/{certificate_id}/reload",
|
||||
post(certificate_reload_one).layer(DefaultBodyLimit::max(4096)),
|
||||
)
|
||||
.route(
|
||||
"/api/certificate-api",
|
||||
get(certificate_api_status)
|
||||
.put(certificate_api_rotate)
|
||||
.delete(certificate_api_revoke_all)
|
||||
.layer(DefaultBodyLimit::max(4096)),
|
||||
)
|
||||
.route(
|
||||
"/api/certificate-api/{key_id}",
|
||||
delete(certificate_api_revoke).layer(DefaultBodyLimit::max(4096)),
|
||||
)
|
||||
.route("/api/traffic", get(traffic_status))
|
||||
.route("/api/cache", get(cache_status))
|
||||
.route("/api/transform", get(transform_status))
|
||||
.route("/api/cache/purge", post(cache_purge))
|
||||
.route(
|
||||
"/api/cache/invalidate",
|
||||
post(cache_invalidate).layer(DefaultBodyLimit::max(16 * 1024)),
|
||||
)
|
||||
.route("/api/cache/bans", get(cache_bans))
|
||||
.route("/api/cache/bans/{id}", delete(cache_ban_delete))
|
||||
.route("/api/tunnels", get(active_tunnels))
|
||||
.route(
|
||||
"/api/tunnels/{id}/kill",
|
||||
post(kill_tunnel).layer(DefaultBodyLimit::max(4096)),
|
||||
)
|
||||
.route(
|
||||
"/api/cache/cold/cleanup",
|
||||
post(cache_cold_cleanup).layer(DefaultBodyLimit::max(4096)),
|
||||
)
|
||||
.route("/api/cache/cold/cleanup/{id}", get(cache_cold_cleanup_job))
|
||||
.route("/api/config", get(configuration).merge(put(update)))
|
||||
.route("/api/config/{section}", put(update_section))
|
||||
.route("/api/logs", get(logs))
|
||||
.route("/api/live-logs", get(live_logs_stream))
|
||||
.route("/api/preferences", get(preferences).put(update_preferences))
|
||||
.route("/api/audit", get(audit))
|
||||
.route("/api/events", get(events))
|
||||
.route("/metrics", get(metrics))
|
||||
.route("/api/metrics", get(metrics))
|
||||
.fallback(|| async { reject(StatusCode::NOT_FOUND, "not_found") })
|
||||
.layer(DefaultBodyLimit::max(
|
||||
crate::config::MAX_CONFIG_BYTES + 1024,
|
||||
))
|
||||
.layer(middleware::from_fn_with_state(state.clone(), guard))
|
||||
.layer(middleware::from_fn_with_state(state.clone(), observe_admin))
|
||||
.with_state(state)
|
||||
}
|
||||
// Separate from proxy exchanges: measure only time to admin response headers,
|
||||
// including access denials. SSE stream lifetime is intentionally not included.
|
||||
async fn observe_admin(State(state): State<Arc<App>>, request: Request, next: Next) -> Response {
|
||||
use std::sync::atomic::Ordering;
|
||||
state.metrics.admin_requests.fetch_add(1, Ordering::Relaxed);
|
||||
let started = std::time::Instant::now();
|
||||
let response = next.run(request).await;
|
||||
let class = (response.status().as_u16() as usize / 100).clamp(1, 5) - 1;
|
||||
state.metrics.admin_status[class].fetch_add(1, Ordering::Relaxed);
|
||||
state.metrics.admin_header_duration_us.fetch_add(
|
||||
started.elapsed().as_micros().min(u64::MAX as u128) as u64,
|
||||
Ordering::Relaxed,
|
||||
);
|
||||
state
|
||||
.metrics
|
||||
.admin_response_headers
|
||||
.fetch_add(1, Ordering::Relaxed);
|
||||
response
|
||||
}
|
||||
fn reject(status: StatusCode, message: &str) -> Response {
|
||||
(status, Json(json!({"error":message}))).into_response()
|
||||
}
|
||||
fn secure(mut response: Response) -> Response {
|
||||
let h = response.headers_mut();
|
||||
h.insert(
|
||||
"cache-control",
|
||||
HeaderValue::from_static("private, no-store, no-cache"),
|
||||
);
|
||||
h.insert(
|
||||
"x-content-type-options",
|
||||
HeaderValue::from_static("nosniff"),
|
||||
);
|
||||
h.insert("referrer-policy", HeaderValue::from_static("no-referrer"));
|
||||
h.insert("x-frame-options", HeaderValue::from_static("DENY"));
|
||||
h.insert("content-security-policy", HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self'; connect-src 'self'; img-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"));
|
||||
h.insert(
|
||||
"permissions-policy",
|
||||
HeaderValue::from_static("camera=(), microphone=(), geolocation=()"),
|
||||
);
|
||||
// Do not let downstream middleware add implementation/version fingerprints.
|
||||
h.remove("server");
|
||||
h.remove("x-powered-by");
|
||||
response
|
||||
}
|
||||
// This is a method-and-path allowlist, NOT a path prefix. Everything else,
|
||||
// including unknown routes, schema, metrics, assets and method mismatches,
|
||||
// requires an active administrator session before extractors read a body.
|
||||
fn panel_document_path(path: &str) -> bool {
|
||||
matches!(
|
||||
path,
|
||||
"/" | "/overview"
|
||||
| "/vhosts"
|
||||
| "/web-server"
|
||||
| "/docker-vhosts"
|
||||
| "/redirects"
|
||||
| "/404-hosts"
|
||||
| "/streams"
|
||||
| "/acl-profiles"
|
||||
| "/configuration"
|
||||
| "/tls"
|
||||
| "/traffic"
|
||||
| "/cache"
|
||||
| "/transform"
|
||||
| "/logging"
|
||||
| "/settings"
|
||||
)
|
||||
}
|
||||
fn anonymous_allowed(method: &Method, path: &str) -> bool {
|
||||
if method == Method::GET || method == Method::HEAD {
|
||||
panel_document_path(path)
|
||||
|| matches!(
|
||||
path,
|
||||
"/assets/login.js"
|
||||
| "/assets/login-langs.json"
|
||||
| "/assets/style.css"
|
||||
| "/favicon.svg"
|
||||
| "/healthz"
|
||||
| "/api/auth/status"
|
||||
)
|
||||
} else {
|
||||
method == Method::POST && matches!(path, "/api/auth/setup" | "/api/auth/login")
|
||||
}
|
||||
}
|
||||
|
||||
const CERTIFICATE_API_KEY_HEADER: &str = "x-rustproxy-certificate-key";
|
||||
|
||||
fn certificate_api_operation(method: &Method, path: &str) -> bool {
|
||||
if method != Method::POST {
|
||||
return false;
|
||||
}
|
||||
matches!(path, "/api/certificates/deploy" | "/api/certificates/reload")
|
||||
|| path
|
||||
.strip_prefix("/api/certificates/")
|
||||
.and_then(|tail| tail.strip_suffix("/reload"))
|
||||
.is_some_and(crate::config::valid_id)
|
||||
}
|
||||
@@ -0,0 +1,406 @@
|
||||
async fn guard(State(state): State<Arc<App>>, mut request: Request, next: Next) -> Response {
|
||||
let transport = request
|
||||
.extensions()
|
||||
.get::<Transport>()
|
||||
.copied()
|
||||
.unwrap_or_default();
|
||||
let peer = request.extensions().get::<PeerAddr>().copied();
|
||||
let raw_host = match admin_request_authority(&request, transport.tls) {
|
||||
Ok(value) => value,
|
||||
Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_host")),
|
||||
};
|
||||
let headers = request.headers();
|
||||
let scheme = match external_admin_scheme(&state, headers, peer, transport) {
|
||||
Ok(value) => value,
|
||||
Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_origin")),
|
||||
};
|
||||
let external_authority =
|
||||
match external_admin_authority(&state, headers, peer, &raw_host, scheme == "https") {
|
||||
Ok(value) => value,
|
||||
Err(()) => return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_host")),
|
||||
};
|
||||
if headers.get_all(ORIGIN).iter().count() > 1
|
||||
|| headers.get(ORIGIN).is_some_and(|h| {
|
||||
h.to_str()
|
||||
.ok()
|
||||
.is_none_or(|origin| !admin_origin_matches(origin, scheme, &external_authority))
|
||||
})
|
||||
{
|
||||
return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_origin"));
|
||||
}
|
||||
if headers
|
||||
.get("sec-fetch-site")
|
||||
.is_some_and(|h| h == "cross-site")
|
||||
{
|
||||
return secure(reject(StatusCode::FORBIDDEN, "auth.invalid_origin"));
|
||||
}
|
||||
let path = request.uri().path().to_owned();
|
||||
let public = anonymous_allowed(request.method(), &path);
|
||||
let unsafe_method = request.method() != Method::GET
|
||||
&& request.method() != Method::HEAD
|
||||
&& request.method() != Method::OPTIONS;
|
||||
let secret = auth::cookie_secret(headers);
|
||||
let certificate_api_session = if certificate_api_operation(request.method(), &path) {
|
||||
match certificate_api_credentials(headers) {
|
||||
Ok(None) => None,
|
||||
Ok(Some((key_id, token))) => {
|
||||
if peer.is_some_and(|peer| !peer.0.ip().is_loopback()) && scheme != "https" {
|
||||
return secure(reject(StatusCode::FORBIDDEN, "certificate_api.https_required"));
|
||||
}
|
||||
let token_hash = auth::secret_hash(&token);
|
||||
match state
|
||||
.store
|
||||
.verify_certificate_api_credential(&key_id, &token_hash)
|
||||
.await
|
||||
{
|
||||
Ok(true) => Some(Session {
|
||||
token_hash,
|
||||
csrf: String::new(),
|
||||
username: format!("certificate-api:{key_id}"),
|
||||
expires_at: auth::now().saturating_add(60),
|
||||
generation: 0,
|
||||
}),
|
||||
Ok(false) => {
|
||||
return secure(reject(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"certificate_api.invalid_token",
|
||||
))
|
||||
}
|
||||
Err(error) => return secure(auth::internal(error).into_response()),
|
||||
}
|
||||
}
|
||||
Err(()) => {
|
||||
return secure(reject(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"certificate_api.invalid_token",
|
||||
))
|
||||
}
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let external_certificate_api = certificate_api_session.is_some();
|
||||
let session_seconds = state
|
||||
.config
|
||||
.load()
|
||||
.envelope
|
||||
.config
|
||||
.administration
|
||||
.session_seconds();
|
||||
// Panel document routes optionally authenticate so a direct refresh can choose
|
||||
// the application shell when the browser still has a valid cookie. Public static
|
||||
// login resources and capability discovery never load operational state.
|
||||
let session = if certificate_api_session.is_some() {
|
||||
certificate_api_session
|
||||
} else if !public || panel_document_path(&path) {
|
||||
match secret.as_deref() {
|
||||
Some(secret) => match state
|
||||
.store
|
||||
.session_at_with_browser_lifetime(secret, auth::now(), session_seconds)
|
||||
.await
|
||||
{
|
||||
Ok(value) => value,
|
||||
Err(error) => return secure(auth::internal(error).into_response()),
|
||||
},
|
||||
None => None,
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
if !public && session.is_none() {
|
||||
return secure(unauthorized(&request));
|
||||
}
|
||||
if unsafe_method && !external_certificate_api {
|
||||
if single_header(headers, "origin")
|
||||
.is_none_or(|origin| !admin_origin_matches(origin, scheme, &external_authority))
|
||||
|| single_header(headers, "x-rustproxy-request") != Some("1")
|
||||
{
|
||||
return secure(reject(StatusCode::FORBIDDEN, "auth.csrf"));
|
||||
}
|
||||
if !public
|
||||
&& !session.as_ref().is_some_and(|session| {
|
||||
single_header(headers, "x-csrf-token")
|
||||
.is_some_and(|provided| auth::equal_secret(provided, &session.csrf))
|
||||
})
|
||||
{
|
||||
return secure(reject(StatusCode::FORBIDDEN, "auth.csrf"));
|
||||
}
|
||||
}
|
||||
if let Some(session) = session.clone() {
|
||||
request.extensions_mut().insert(session);
|
||||
}
|
||||
let admin_origin = AdminOrigin {
|
||||
secure: scheme == "https",
|
||||
};
|
||||
request.extensions_mut().insert(admin_origin);
|
||||
request.extensions_mut().insert(transport);
|
||||
let mut response = secure(next.run(request).await);
|
||||
let refresh_session_cookie = !matches!(
|
||||
path.as_str(),
|
||||
"/api/auth/logout" | "/api/auth/logout-all" | "/api/auth/password"
|
||||
);
|
||||
if refresh_session_cookie {
|
||||
if let (Some(secret), Some(session)) = (secret.as_deref(), session.as_ref()) {
|
||||
let remaining = session.expires_at.saturating_sub(auth::now());
|
||||
if remaining > 0 {
|
||||
if let Ok(cookie) = HeaderValue::from_str(&auth::session_cookie(
|
||||
secret,
|
||||
admin_origin.secure,
|
||||
remaining,
|
||||
)) {
|
||||
response.headers_mut().append(SET_COOKIE, cookie);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if matches!(path.as_str(), "/docs" | "/docs/") && response.status().is_success() {
|
||||
// Swagger UI sets inline layout styles. This exception is limited to the
|
||||
// authenticated documentation document; scripts remain self-only/no-eval.
|
||||
response.headers_mut().insert("content-security-policy", HeaderValue::from_static(
|
||||
"default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; font-src 'self'; object-src 'none'; frame-ancestors 'none'; base-uri 'none'; form-action 'self'"));
|
||||
}
|
||||
response
|
||||
}
|
||||
async fn auth_status(State(state): State<Arc<App>>) -> Result<Json<Value>, ApiError> {
|
||||
// The one bit needed to render first-account setup; no version, paths, names,
|
||||
// listeners, provider state, metrics or configuration before authentication.
|
||||
Ok(Json(
|
||||
json!({"setup_required":state.store.admin_record().await.map_err(auth::internal)?.is_none()}),
|
||||
))
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Setup {
|
||||
token: String,
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct Login {
|
||||
username: String,
|
||||
password: String,
|
||||
}
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct PasswordChange {
|
||||
current_password: String,
|
||||
new_password: String,
|
||||
}
|
||||
async fn setup(
|
||||
State(state): State<Arc<App>>,
|
||||
input: Result<Json<Setup>, axum::extract::rejection::JsonRejection>,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let Json(input) = input.map_err(public::invalid_json)?;
|
||||
state
|
||||
.auth
|
||||
.setup(&input.token, &input.username, input.password)
|
||||
.await?;
|
||||
Ok(Json(json!({"initialized":true})))
|
||||
}
|
||||
fn session_payload(session: &Session, session_seconds: i64) -> Value {
|
||||
json!({"username":session.username, "csrf_token":session.csrf, "expires_at":session.expires_at,
|
||||
"idle_timeout_seconds":session_seconds, "session_lifetime_seconds":session_seconds})
|
||||
}
|
||||
async fn login(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(origin): Extension<AdminOrigin>,
|
||||
headers: HeaderMap,
|
||||
input: Result<Json<Login>, axum::extract::rejection::JsonRejection>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let Json(input) = input.map_err(public::invalid_json)?;
|
||||
let previous = auth::cookie_secret(&headers);
|
||||
let session_seconds = state
|
||||
.config
|
||||
.load()
|
||||
.envelope
|
||||
.config
|
||||
.administration
|
||||
.session_seconds();
|
||||
let issued = state
|
||||
.auth
|
||||
.login(
|
||||
&input.username,
|
||||
input.password,
|
||||
previous.as_deref(),
|
||||
session_seconds,
|
||||
)
|
||||
.await?;
|
||||
let mut response = Json(session_payload(&issued.session, session_seconds)).into_response();
|
||||
response.headers_mut().insert(
|
||||
SET_COOKIE,
|
||||
auth::session_cookie(&issued.secret, origin.secure, session_seconds)
|
||||
.parse()
|
||||
.map_err(auth::internal)?,
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
async fn session(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
) -> Json<Value> {
|
||||
let session_seconds = state
|
||||
.config
|
||||
.load()
|
||||
.envelope
|
||||
.config
|
||||
.administration
|
||||
.session_seconds();
|
||||
Json(session_payload(&session, session_seconds))
|
||||
}
|
||||
|
||||
const LIVE_LOG_COLUMNS: &[&str] = &[
|
||||
"time",
|
||||
"request",
|
||||
"host",
|
||||
"source_ip",
|
||||
"vhost",
|
||||
"backend",
|
||||
"status",
|
||||
"duration",
|
||||
"cache",
|
||||
"outcome",
|
||||
"transport",
|
||||
"websocket",
|
||||
"upstream_error",
|
||||
];
|
||||
|
||||
fn default_admin_theme() -> String {
|
||||
"dark".to_owned()
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct AdminPreferences {
|
||||
live_log_columns: Vec<String>,
|
||||
#[serde(default = "default_admin_theme")]
|
||||
theme: String,
|
||||
}
|
||||
|
||||
impl Default for AdminPreferences {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
live_log_columns: LIVE_LOG_COLUMNS
|
||||
.iter()
|
||||
.map(|value| (*value).to_owned())
|
||||
.collect(),
|
||||
theme: default_admin_theme(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn validate_admin_preferences(preferences: &AdminPreferences) -> Result<(), ApiError> {
|
||||
if preferences.live_log_columns.is_empty()
|
||||
|| preferences.live_log_columns.len() > LIVE_LOG_COLUMNS.len()
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"at least one live-log column must be visible".into(),
|
||||
));
|
||||
}
|
||||
let mut seen = std::collections::HashSet::new();
|
||||
for column in &preferences.live_log_columns {
|
||||
if !LIVE_LOG_COLUMNS.contains(&column.as_str()) || !seen.insert(column.as_str()) {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid live-log column preference".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
if !matches!(preferences.theme.as_str(), "dark" | "light") {
|
||||
return Err(ApiError(
|
||||
StatusCode::UNPROCESSABLE_ENTITY,
|
||||
"invalid administration theme preference".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn preferences(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
) -> Result<Json<AdminPreferences>, ApiError> {
|
||||
let stored = state
|
||||
.store
|
||||
.admin_preferences(&session.username)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
let preferences = match stored {
|
||||
Some(document) => match serde_json::from_str::<AdminPreferences>(&document) {
|
||||
Ok(preferences) if validate_admin_preferences(&preferences).is_ok() => preferences,
|
||||
Ok(_) | Err(_) => {
|
||||
tracing::warn!(user = %session.username, "invalid admin view preferences ignored");
|
||||
AdminPreferences::default()
|
||||
}
|
||||
},
|
||||
None => AdminPreferences::default(),
|
||||
};
|
||||
Ok(Json(preferences))
|
||||
}
|
||||
|
||||
async fn update_preferences(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
input: Result<Json<AdminPreferences>, axum::extract::rejection::JsonRejection>,
|
||||
) -> Result<Json<AdminPreferences>, ApiError> {
|
||||
let Json(preferences) = input.map_err(public::invalid_json)?;
|
||||
validate_admin_preferences(&preferences)?;
|
||||
let document = serde_json::to_string(&preferences).map_err(auth::internal)?;
|
||||
if !state
|
||||
.store
|
||||
.save_admin_preferences(&session.username, &document)
|
||||
.await
|
||||
.map_err(auth::internal)?
|
||||
{
|
||||
return Err(ApiError(
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"auth.invalid_credentials".into(),
|
||||
));
|
||||
}
|
||||
Ok(Json(preferences))
|
||||
}
|
||||
fn logged_out(secure_cookie: bool) -> Response {
|
||||
let mut response = Json(json!({"logged_out":true})).into_response();
|
||||
response.headers_mut().insert(
|
||||
SET_COOKIE,
|
||||
HeaderValue::from_str(&auth::expired_cookie(secure_cookie))
|
||||
.expect("static cookie attributes"),
|
||||
);
|
||||
response
|
||||
}
|
||||
async fn logout(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(origin): Extension<AdminOrigin>,
|
||||
Extension(session): Extension<Session>,
|
||||
) -> Result<Response, ApiError> {
|
||||
state
|
||||
.store
|
||||
.end_session(&session)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
Ok(logged_out(origin.secure))
|
||||
}
|
||||
async fn logout_all(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(origin): Extension<AdminOrigin>,
|
||||
Extension(session): Extension<Session>,
|
||||
) -> Result<Response, ApiError> {
|
||||
state
|
||||
.store
|
||||
.revoke_all_sessions(&session.username)
|
||||
.await
|
||||
.map_err(auth::internal)?;
|
||||
Ok(logged_out(origin.secure))
|
||||
}
|
||||
async fn password(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(origin): Extension<AdminOrigin>,
|
||||
Extension(session): Extension<Session>,
|
||||
Json(input): Json<PasswordChange>,
|
||||
) -> Result<Response, ApiError> {
|
||||
state
|
||||
.auth
|
||||
.change_password(&session, input.current_password, input.new_password)
|
||||
.await?;
|
||||
Ok(logged_out(origin.secure))
|
||||
}
|
||||
@@ -0,0 +1,370 @@
|
||||
fn status_payload(state: &App) -> Value {
|
||||
let snapshot = state.config.load_full();
|
||||
let listeners = json!({
|
||||
"default_http":state.startup_listeners.0.to_string(),
|
||||
"default_https":state.startup_listeners.1.map(|v| v.to_string()),
|
||||
"locked_http":state.startup_locks.http.map(|v| v.to_string()),
|
||||
"locked_https":state.startup_locks.https.map(|v| v.to_string()),
|
||||
"active_endpoints":state.active_listeners,
|
||||
"desired_endpoints":snapshot.listeners,
|
||||
"named_endpoints":snapshot.envelope.config.listeners.endpoints,
|
||||
"restart_required":snapshot.listeners != state.active_listeners
|
||||
});
|
||||
let mut features = serde_json::Map::new();
|
||||
for name in [
|
||||
"http1",
|
||||
"streaming",
|
||||
"hot_reload",
|
||||
"sqlite",
|
||||
"admin_accounts",
|
||||
"acl",
|
||||
"headers",
|
||||
"live_sse",
|
||||
"http2",
|
||||
"tls",
|
||||
"websocket",
|
||||
"dns_backends",
|
||||
"https_backends",
|
||||
"cache",
|
||||
"compression",
|
||||
"system_diagnostics",
|
||||
"language_packs",
|
||||
"vhosts",
|
||||
"redirect_hosts",
|
||||
"not_found_hosts",
|
||||
"ip_bindings",
|
||||
"backend_pools",
|
||||
"health_checks",
|
||||
"sticky_sessions",
|
||||
"security_headers",
|
||||
"http3",
|
||||
"tinyauth",
|
||||
"trusted_forwarding",
|
||||
"named_listeners",
|
||||
"stream_proxy",
|
||||
"certificate_library",
|
||||
"vhost_server_header",
|
||||
"diagnostic_header_controls",
|
||||
"cache_dashboard",
|
||||
"transform_dashboard",
|
||||
"cache_reasons",
|
||||
"acme_http01",
|
||||
"acme_cloudflare",
|
||||
"acme_ovh",
|
||||
"prometheus_exporter",
|
||||
"vhosts_only",
|
||||
"acme_rfc2136",
|
||||
"certificate_upload",
|
||||
"vhost_cache",
|
||||
"selective_purge",
|
||||
"custom_pages",
|
||||
"rate_limit",
|
||||
"country_access",
|
||||
"cloudflare_client_ip",
|
||||
"server_timing",
|
||||
"static_cookie_cache",
|
||||
"per_vhost_http3",
|
||||
"structured_log_sinks",
|
||||
"admin_https",
|
||||
"tls_policy",
|
||||
"cache_resident_age",
|
||||
] {
|
||||
features.insert(name.into(), Value::Bool(true));
|
||||
}
|
||||
features.insert(
|
||||
"tls_enabled".into(),
|
||||
Value::Bool(state.active_listeners.iter().any(|l| l.tls)),
|
||||
);
|
||||
features.insert("docker".into(), Value::Bool(true));
|
||||
let mut payload = json!({"version":env!("CARGO_PKG_VERSION"), "project":"RustProxy", "mode":"standalone",
|
||||
"auth":"argon2id_cookie_session", "uptime_seconds":state.started.elapsed().as_secs(),
|
||||
"revision":snapshot.envelope.revision, "proxy_listen":state.policy.proxy_addr.to_string(),
|
||||
"admin_listen":state.policy.admin_addr.to_string(), "tls_listen":state.policy.tls_addr.map(|v| v.to_string()),
|
||||
"certificate_bindings":snapshot.envelope.config.tls.certificates.len(),
|
||||
"vhosts":snapshot.envelope.config.vhosts.len(),
|
||||
"redirect_hosts":snapshot.envelope.config.redirects.len(),
|
||||
"not_found_hosts":snapshot.envelope.config.not_found_hosts.len()});
|
||||
payload["listeners"] = listeners;
|
||||
let stream_metrics = state
|
||||
.active_streams
|
||||
.iter()
|
||||
.filter_map(|stream| {
|
||||
state
|
||||
.stream_metrics
|
||||
.get(&stream.id)
|
||||
.map(|metrics| (stream.id.clone(), metrics.view()))
|
||||
})
|
||||
.collect::<std::collections::BTreeMap<_, _>>();
|
||||
payload["streams"] = json!({
|
||||
"active": state.active_streams,
|
||||
"desired": snapshot.envelope.config.streams,
|
||||
"restart_required": snapshot.envelope.config.streams != state.active_streams,
|
||||
"metrics": stream_metrics
|
||||
});
|
||||
payload["prometheus"] = json!({"active": state.active_prometheus,
|
||||
"desired": snapshot.envelope.config.prometheus,
|
||||
"restart_required": snapshot.envelope.config.prometheus.restart_differs(&state.active_prometheus)});
|
||||
let mut desired_admin = snapshot.envelope.config.administration.clone();
|
||||
desired_admin.listen = Some(desired_admin.resolved_listen(state.policy.admin_addr));
|
||||
if let Some(id) = desired_admin.certificate_id.as_deref() {
|
||||
if let Some(certificate) = snapshot
|
||||
.envelope
|
||||
.config
|
||||
.tls
|
||||
.certificates
|
||||
.iter()
|
||||
.find(|certificate| certificate.id == id)
|
||||
{
|
||||
desired_admin
|
||||
.allowed_hosts
|
||||
.extend(certificate.hosts.clone());
|
||||
desired_admin.allowed_hosts.sort();
|
||||
desired_admin.allowed_hosts.dedup();
|
||||
}
|
||||
}
|
||||
let desired_admin_certificate_files = desired_admin
|
||||
.certificate_id
|
||||
.as_deref()
|
||||
.and_then(|id| {
|
||||
snapshot
|
||||
.envelope
|
||||
.config
|
||||
.tls
|
||||
.certificates
|
||||
.iter()
|
||||
.find(|certificate| certificate.id == id)
|
||||
})
|
||||
.map(|certificate| (certificate.cert_file.clone(), certificate.key_file.clone()));
|
||||
let administration_restart_required = desired_admin
|
||||
.restart_differs(&state.active_administration)
|
||||
|| (state.active_administration.tls
|
||||
&& snapshot.envelope.config.tls.policy != state.active_administration_tls_policy)
|
||||
|| (state.active_administration.tls
|
||||
&& desired_admin_certificate_files != state.active_administration_certificate_files);
|
||||
payload["administration"] = json!({
|
||||
"active": state.active_administration,
|
||||
"desired": desired_admin,
|
||||
"restart_required": administration_restart_required,
|
||||
"listen_locked": state.startup_locks.administration.is_some(),
|
||||
"locked_listen": state.startup_locks.administration.map(|value| value.to_string())
|
||||
});
|
||||
payload["features"] = Value::Object(features);
|
||||
let cache_view = state.cache.view();
|
||||
let transform_resources = crate::transform::resource_usage(&snapshot.envelope.config.transform);
|
||||
payload["cache"] = json!(&cache_view);
|
||||
payload["transform_resources"] = json!(&transform_resources);
|
||||
payload["geoip"] = json!(state.geoip.view());
|
||||
let system = state.diagnostics.view();
|
||||
let cache_ram_bytes = (cache_view.hot_allocated_bytes as u64)
|
||||
.saturating_add(cache_view.transient_bytes);
|
||||
let non_cache_rss_bytes = system
|
||||
.process_rss_bytes
|
||||
.map(|rss| rss.saturating_sub(cache_ram_bytes));
|
||||
payload["memory_accounting"] = json!({
|
||||
"process_rss_bytes": system.process_rss_bytes,
|
||||
"cache_hot_bytes": cache_view.hot_allocated_bytes,
|
||||
"cache_hot_limit_bytes": cache_view.hot_limit_bytes,
|
||||
"cache_transient_bytes": cache_view.transient_bytes,
|
||||
"cache_transient_limit_bytes": cache_view.transient_limit_bytes,
|
||||
"cache_total_bytes": cache_ram_bytes,
|
||||
"transform_reserved_bytes": transform_resources.memory_reserved_bytes,
|
||||
"transform_budget_bytes": transform_resources.memory_budget_bytes,
|
||||
"transform_inflight": transform_resources.inflight,
|
||||
"transform_max_inflight": transform_resources.max_inflight,
|
||||
"non_cache_rss_bytes": non_cache_rss_bytes,
|
||||
"non_cache_rss_includes_transform": true,
|
||||
"method": "rss_minus_accounted_cache; transform reservation is an overlapping estimate"
|
||||
});
|
||||
payload["runtime"] = json!({
|
||||
"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())
|
||||
});
|
||||
payload["system"] = json!(system);
|
||||
payload["backend_pools"] = json!(snapshot.pools.iter().map(|p| p.view()).collect::<Vec<_>>());
|
||||
payload["vhost_metrics"] = json!(snapshot
|
||||
.vhost_metrics
|
||||
.iter()
|
||||
.map(|(id, metrics)| (id.clone(), metrics.view()))
|
||||
.collect::<std::collections::BTreeMap<_, _>>());
|
||||
let mut backend_pool_domains = std::collections::BTreeMap::new();
|
||||
for agent in &snapshot.envelope.config.docker_agents.agents {
|
||||
for assignment in agent
|
||||
.assignments
|
||||
.iter()
|
||||
.filter(|assignment| assignment.enabled)
|
||||
{
|
||||
if let Ok(hosts) = crate::docker::assignment_hosts(assignment, &agent.base_domain) {
|
||||
if let Some(host) = hosts.first() {
|
||||
backend_pool_domains.insert(
|
||||
crate::docker::generated_id(&agent.id, &assignment.service_key),
|
||||
host.clone(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
payload["backend_pool_domains"] = json!(backend_pool_domains);
|
||||
payload["backends"] = json!(snapshot
|
||||
.envelope
|
||||
.config
|
||||
.vhosts
|
||||
.iter()
|
||||
.map(|h| h.backends.len())
|
||||
.sum::<usize>());
|
||||
payload["routes"] = json!(snapshot
|
||||
.envelope
|
||||
.config
|
||||
.vhosts
|
||||
.iter()
|
||||
.map(|h| h.routes.len())
|
||||
.sum::<usize>());
|
||||
payload["enabled_routes"] = json!(snapshot
|
||||
.envelope
|
||||
.config
|
||||
.vhosts
|
||||
.iter()
|
||||
.filter(|h| h.enabled)
|
||||
.map(|h| h.routes.iter().filter(|r| r.enabled).count())
|
||||
.sum::<usize>());
|
||||
payload["metrics"] = json!(state.metrics.view());
|
||||
payload["transform_metrics"] = json!(state.transform_metrics.total());
|
||||
payload["logs_dropped"] = json!(state.logs.dropped.load(Ordering::Relaxed));
|
||||
payload["software_update"] = json!(state.software_update.snapshot());
|
||||
payload["logging"] = json!({
|
||||
"settings": &snapshot.envelope.config.logging,
|
||||
"sinks": state.logs.stats(),
|
||||
"error_file": crate::logging::error_log_stats(),
|
||||
});
|
||||
payload["tls"] = json!({
|
||||
"policy": &snapshot.envelope.config.tls.policy,
|
||||
"cipher_suites": crate::tls::cipher_suite_names(),
|
||||
"effective_cipher_suites": snapshot.envelope.config.tls.policy.effective_cipher_suites()
|
||||
});
|
||||
payload
|
||||
}
|
||||
async fn status(State(state): State<Arc<App>>) -> Json<Value> {
|
||||
Json(status_payload(&state))
|
||||
}
|
||||
async fn backend_status(State(state): State<Arc<App>>) -> Json<Value> {
|
||||
let snapshot = state.config.load_full();
|
||||
Json(
|
||||
json!({"pools":snapshot.pools.iter().map(|p| p.view()).collect::<Vec<_>>(),
|
||||
"active_frontends":state.active_listeners, "desired_frontends":snapshot.listeners}),
|
||||
)
|
||||
}
|
||||
fn docker_runtime_views(state: &Arc<App>) -> (Vec<Value>, Vec<Value>) {
|
||||
let snapshot = state.config.load_full();
|
||||
let mut routes = Vec::new();
|
||||
let mut ids = std::collections::HashSet::new();
|
||||
for agent in &snapshot.envelope.config.docker_agents.agents {
|
||||
for assignment in &agent.assignments {
|
||||
let pool_id = crate::docker::generated_id(&agent.id, &assignment.service_key);
|
||||
ids.insert(pool_id.clone());
|
||||
routes.push(json!({"agent_id": &agent.id, "service_key": &assignment.service_key, "pool_id": pool_id}));
|
||||
}
|
||||
}
|
||||
let pools = snapshot
|
||||
.pools
|
||||
.iter()
|
||||
.filter(|pool| ids.contains(&pool.id))
|
||||
.map(|pool| pool.view())
|
||||
.collect();
|
||||
(pools, routes)
|
||||
}
|
||||
async fn docker_status(State(state): State<Arc<App>>) -> Json<Value> {
|
||||
let (pools, pool_routes) = docker_runtime_views(&state);
|
||||
Json(
|
||||
json!({"observations": state.docker.view().await, "pools": pools, "pool_routes": pool_routes}),
|
||||
)
|
||||
}
|
||||
async fn docker_refresh(State(state): State<Arc<App>>) -> Result<Json<Value>, ApiError> {
|
||||
let observations = state.refresh_docker().await?;
|
||||
let (pools, pool_routes) = docker_runtime_views(&state);
|
||||
Ok(Json(
|
||||
json!({"observations": observations, "pools": pools, "pool_routes": pool_routes}),
|
||||
))
|
||||
}
|
||||
async fn certificate_inventory(State(state): State<Arc<App>>) -> Json<Value> {
|
||||
let snapshot = state.config.load_full();
|
||||
let config = &snapshot.envelope.config;
|
||||
let configured_certificates = config.tls.certificates.clone();
|
||||
let certificate_directory = state.policy.cert_dir.clone();
|
||||
// Inspect only public certificate files on a blocking worker; never read keys.
|
||||
// Live PEM coverage wins so in-place renewals are reflected immediately; stored
|
||||
// metadata is only a fallback when the public certificate file cannot be read.
|
||||
let inspected_metadata = tokio::task::spawn_blocking(move || {
|
||||
configured_certificates
|
||||
.into_iter()
|
||||
.filter_map(|cert| {
|
||||
certificate_directory.as_deref().and_then(|directory| {
|
||||
crate::tls::certificate_metadata(directory, &cert.cert_file)
|
||||
.ok()
|
||||
.map(|metadata| (cert.id, metadata))
|
||||
})
|
||||
})
|
||||
.collect::<std::collections::HashMap<_, _>>()
|
||||
})
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let renewals = state.acme.renewal_view();
|
||||
let entries: Vec<_> = config.tls.certificates.iter().map(|cert| {
|
||||
let assigned: Vec<_> = config.vhosts.iter().filter(|v|v.certificate_id.as_deref()==Some(cert.id.as_str()))
|
||||
.map(|v|json!({"id":v.id,"hosts":v.frontend.hosts})).collect();
|
||||
let metadata = inspected_metadata.get(&cert.id);
|
||||
let covered_hosts = metadata.map(|value| value.covered_hosts.clone()).unwrap_or_else(|| cert.covered_hosts.clone());
|
||||
let renewal = renewals.get(&cert.id);
|
||||
json!({"id":cert.id,"hosts":cert.hosts,"covered_hosts":covered_hosts,"cert_file":cert.cert_file,"key_file":cert.key_file,
|
||||
"issuer":cert.issuer,"certificate_issuer":metadata.and_then(|value| value.issuer.clone()),
|
||||
"not_before":metadata.and_then(|value| value.not_before.clone()),"not_after":metadata.and_then(|value| value.not_after.clone()),
|
||||
"not_before_unix":metadata.and_then(|value| value.not_before_unix),"not_after_unix":metadata.and_then(|value| value.not_after_unix),
|
||||
"days_remaining":metadata.and_then(|value| value.days_remaining),"expiry_status":metadata.map(|value| value.expiry_status).unwrap_or("unknown"),
|
||||
"renewal":renewal,"renewal_status":renewal.map(|value| value.status.as_str()),
|
||||
"last_renewal_attempt":renewal.map(|value| value.last_checked).filter(|value| *value > 0),
|
||||
"last_renewal_error":renewal.filter(|value| value.status == "failed").map(|value| value.message.as_str()),
|
||||
"default":config.tls.default_certificate.as_deref()==Some(cert.id.as_str()),"assigned_vhosts":assigned})
|
||||
}).collect();
|
||||
Json(
|
||||
json!({"revision":snapshot.envelope.revision,"certificates":entries,"acme":true, "providers":["http01", "cloudflare", "ovh", "rfc2136"]}),
|
||||
)
|
||||
}
|
||||
async fn configuration(State(state): State<Arc<App>>) -> Json<crate::config::Envelope> {
|
||||
Json(state.config.load().envelope.clone())
|
||||
}
|
||||
async fn update(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
Json(input): Json<Update>,
|
||||
) -> Result<Json<crate::config::Envelope>, ApiError> {
|
||||
state.update_as(input, session.username).await.map(Json)
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct SectionUpdate {
|
||||
expected_revision: u64,
|
||||
value: Value,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ResponseSection {
|
||||
server_header: crate::settings::ServerHeader,
|
||||
diagnostic_headers: crate::settings::DiagnosticHeaders,
|
||||
compression: crate::compression::Settings,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct LoggingSection {
|
||||
logging: crate::logging::Settings,
|
||||
error_log: crate::logging::ErrorLogSettings,
|
||||
}
|
||||
|
||||
fn invalid_section(section: &str, error: serde_json::Error) -> ApiError {
|
||||
ApiError(
|
||||
StatusCode::BAD_REQUEST,
|
||||
format!("invalid {section} configuration: {error}"),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
async fn acme_renew(
|
||||
State(state): State<Arc<App>>,
|
||||
Extension(session): Extension<Session>,
|
||||
Json(input): Json<RenewRequest>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let job = crate::acme::Manager::renew(
|
||||
state,
|
||||
&input.certificate_id,
|
||||
session.username,
|
||||
input.dry_run,
|
||||
false,
|
||||
)
|
||||
.await?;
|
||||
Ok((StatusCode::ACCEPTED, Json(json!({"job":job}))).into_response())
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mod tests {
|
||||
include!("tests/part_01.rs");
|
||||
include!("tests/part_02.rs");
|
||||
include!("tests/part_03.rs");
|
||||
include!("tests/part_04.rs");
|
||||
include!("tests/part_05.rs");
|
||||
}
|
||||
@@ -0,0 +1,366 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
config::{self, Config, ValidationPolicy},
|
||||
db::Store,
|
||||
};
|
||||
use axum::body::Body;
|
||||
use http::Request;
|
||||
use tower::ServiceExt;
|
||||
async fn fixture() -> (Router, Store, tempfile::TempDir, String, String) {
|
||||
fixture_with_config(Config::default()).await
|
||||
}
|
||||
async fn fixture_with_config(
|
||||
config: Config,
|
||||
) -> (Router, Store, tempfile::TempDir, String, String) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let policy = ValidationPolicy {
|
||||
tls_addr: None,
|
||||
cert_dir: None,
|
||||
data_dir: None,
|
||||
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
|
||||
admin_addr: "127.0.0.1:9090".parse().unwrap(),
|
||||
};
|
||||
let store = Store::open(&dir.path().join("db"), &config).await.unwrap();
|
||||
store
|
||||
.initialize_admin("owner", "unused-test-hash", "test")
|
||||
.await
|
||||
.unwrap();
|
||||
let auth = auth::Auth::new(store.clone(), dir.path().to_path_buf())
|
||||
.await
|
||||
.unwrap();
|
||||
let issued = store
|
||||
.create_session(1, "browser", None)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let snapshot = config::compile(config, 0, &policy).unwrap();
|
||||
let state = App::new(
|
||||
store.clone(),
|
||||
snapshot,
|
||||
policy,
|
||||
auth,
|
||||
10,
|
||||
Duration::from_secs(30),
|
||||
Duration::from_secs(30),
|
||||
Duration::from_secs(300),
|
||||
);
|
||||
(
|
||||
router(state),
|
||||
store,
|
||||
dir,
|
||||
issued.secret,
|
||||
issued.session.csrf,
|
||||
)
|
||||
}
|
||||
async fn fixture_with_certificate_storage(
|
||||
config: Config,
|
||||
) -> (Router, Store, tempfile::TempDir, String, String) {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let certificate_directory = dir.path().join("certs");
|
||||
crate::tls::initialize_default(&certificate_directory).unwrap();
|
||||
let policy = ValidationPolicy {
|
||||
tls_addr: None,
|
||||
cert_dir: Some(certificate_directory),
|
||||
data_dir: None,
|
||||
proxy_addr: "127.0.0.1:8080".parse().unwrap(),
|
||||
admin_addr: "127.0.0.1:9090".parse().unwrap(),
|
||||
};
|
||||
let store = Store::open(&dir.path().join("db"), &config).await.unwrap();
|
||||
store
|
||||
.initialize_admin("owner", "unused-test-hash", "test")
|
||||
.await
|
||||
.unwrap();
|
||||
let auth = auth::Auth::new(store.clone(), dir.path().to_path_buf())
|
||||
.await
|
||||
.unwrap();
|
||||
let issued = store
|
||||
.create_session(1, "browser", None)
|
||||
.await
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
let snapshot = config::compile(config, 0, &policy).unwrap();
|
||||
let state = App::new(
|
||||
store.clone(),
|
||||
snapshot,
|
||||
policy,
|
||||
auth,
|
||||
10,
|
||||
Duration::from_secs(30),
|
||||
Duration::from_secs(30),
|
||||
Duration::from_secs(300),
|
||||
);
|
||||
(
|
||||
router(state),
|
||||
store,
|
||||
dir,
|
||||
issued.secret,
|
||||
issued.session.csrf,
|
||||
)
|
||||
}
|
||||
fn request(path: &str) -> http::request::Builder {
|
||||
Request::builder()
|
||||
.uri(path)
|
||||
.header("host", "127.0.0.1:9090")
|
||||
}
|
||||
#[test]
|
||||
fn administration_authority_uses_http2_pseudo_authority_without_legacy_host() {
|
||||
let request = Request::builder()
|
||||
.version(Version::HTTP_2)
|
||||
.uri("https://192.0.2.10:9090/healthz")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
admin_request_authority(&request, true).unwrap(),
|
||||
"192.0.2.10:9090"
|
||||
);
|
||||
|
||||
let conflicting = Request::builder()
|
||||
.version(Version::HTTP_2)
|
||||
.uri("https://192.0.2.10:9090/healthz")
|
||||
.header(HOST, "panel.example.test:9090")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert!(admin_request_authority(&conflicting, true).is_err());
|
||||
|
||||
let h1_absolute = Request::builder()
|
||||
.uri("https://192.0.2.10:9090/healthz")
|
||||
.header(HOST, "192.0.2.10:9090")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert!(admin_request_authority(&h1_absolute, true).is_err());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn administration_https_health_accepts_http2_authority_without_host_header() {
|
||||
let (router, _store, _dir, _secret, _csrf) = fixture().await;
|
||||
let request = Request::builder()
|
||||
.version(Version::HTTP_2)
|
||||
.uri("https://127.0.0.1:9090/healthz")
|
||||
.extension(Transport { tls: true })
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.oneshot(request).await.unwrap().status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
}
|
||||
#[test]
|
||||
fn tls_admin_accepts_dns_without_allowlist_but_keeps_explicit_lists_strict() {
|
||||
let wildcard: std::net::SocketAddr = "0.0.0.0:9090".parse().unwrap();
|
||||
assert!(admin_host_allowed("127.0.0.1:9090", wildcard, true, &[]));
|
||||
assert!(admin_host_allowed("192.0.2.10:9090", wildcard, true, &[]));
|
||||
assert!(admin_host_allowed(
|
||||
"example.test:9090",
|
||||
wildcard,
|
||||
true,
|
||||
&["example.test".into()]
|
||||
));
|
||||
assert!(admin_host_allowed("example.test:9090", wildcard, true, &[]));
|
||||
assert!(!admin_host_allowed(
|
||||
"example.test:9090",
|
||||
wildcard,
|
||||
false,
|
||||
&[]
|
||||
));
|
||||
assert!(!admin_host_allowed("0.0.0.0:9091", wildcard, true, &[]));
|
||||
assert!(!admin_host_allowed("0.0.0.0:9090", wildcard, true, &[]));
|
||||
assert!(admin_host_allowed("localhost:9090", wildcard, true, &[]));
|
||||
|
||||
let concrete: std::net::SocketAddr = "192.0.2.10:9090".parse().unwrap();
|
||||
assert!(admin_host_allowed("localhost:9090", concrete, true, &[]));
|
||||
assert!(admin_host_allowed(
|
||||
"localhost:9090",
|
||||
concrete,
|
||||
true,
|
||||
&["localhost".into()]
|
||||
));
|
||||
assert!(!admin_host_allowed(
|
||||
"other.test:9090",
|
||||
concrete,
|
||||
true,
|
||||
&["localhost".into()]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reverse_proxy_dns_allowlist_uses_external_port_and_keeps_direct_port_available() {
|
||||
let wildcard: std::net::SocketAddr = "0.0.0.0:9090".parse().unwrap();
|
||||
let allowed = vec!["panel.example.test".to_owned()];
|
||||
assert!(admin_host_allowed(
|
||||
"panel.example.test",
|
||||
wildcard,
|
||||
false,
|
||||
&allowed
|
||||
));
|
||||
assert!(admin_host_allowed(
|
||||
"panel.example.test:443",
|
||||
wildcard,
|
||||
true,
|
||||
&allowed
|
||||
));
|
||||
assert!(admin_host_allowed(
|
||||
"panel.example.test:8443",
|
||||
wildcard,
|
||||
true,
|
||||
&allowed
|
||||
));
|
||||
assert!(admin_host_allowed(
|
||||
"192.0.2.10:9090",
|
||||
wildcard,
|
||||
false,
|
||||
&allowed
|
||||
));
|
||||
assert!(admin_host_allowed(
|
||||
"localhost:9090",
|
||||
wildcard,
|
||||
false,
|
||||
&allowed
|
||||
));
|
||||
assert!(!admin_host_allowed(
|
||||
"192.0.2.10:443",
|
||||
wildcard,
|
||||
true,
|
||||
&allowed
|
||||
));
|
||||
assert!(!admin_host_allowed(
|
||||
"other.example.test:443",
|
||||
wildcard,
|
||||
true,
|
||||
&allowed
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn origin_matching_normalizes_default_ports_without_weakening_host_checks() {
|
||||
assert!(admin_origin_matches(
|
||||
"https://panel.example.test",
|
||||
"https",
|
||||
"panel.example.test:443"
|
||||
));
|
||||
assert!(admin_origin_matches(
|
||||
"https://PANEL.EXAMPLE.TEST:443/",
|
||||
"https",
|
||||
"panel.example.test"
|
||||
));
|
||||
assert!(!admin_origin_matches(
|
||||
"https://panel.example.test:8443",
|
||||
"https",
|
||||
"panel.example.test"
|
||||
));
|
||||
assert!(!admin_origin_matches(
|
||||
"http://panel.example.test",
|
||||
"https",
|
||||
"panel.example.test"
|
||||
));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn trusted_https_reverse_proxy_accepts_external_origin_and_refreshes_secure_cookie() {
|
||||
let mut config = Config::default();
|
||||
config.administration.allowed_hosts = vec!["panel.example.test".into()];
|
||||
let (router, store, _dir, secret, _) = fixture_with_config(config).await;
|
||||
let response = router
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.uri("/api/status")
|
||||
.header("host", "127.0.0.1:9090")
|
||||
.header("x-forwarded-host", "panel.example.test")
|
||||
.header("x-forwarded-proto", "https")
|
||||
.header("origin", "https://panel.example.test")
|
||||
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME))
|
||||
.extension(PeerAddr("127.0.0.1:45000".parse().unwrap()))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let refreshed = response
|
||||
.headers()
|
||||
.get_all(SET_COOKIE)
|
||||
.iter()
|
||||
.filter_map(|value| value.to_str().ok())
|
||||
.find(|value| value.starts_with(auth::COOKIE_NAME))
|
||||
.expect("session cookie refreshed");
|
||||
assert!(refreshed.contains("; Secure"));
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn https_redirect_router_preserves_authority_path_and_query() {
|
||||
let request = Request::builder()
|
||||
.uri("/api/status?view=compact")
|
||||
.header("host", "panel.example.test:9090")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let response = https_redirect_router().oneshot(request).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::PERMANENT_REDIRECT);
|
||||
assert_eq!(
|
||||
response.headers().get(http::header::LOCATION).unwrap(),
|
||||
"https://panel.example.test:9090/api/status?view=compact"
|
||||
);
|
||||
assert_eq!(
|
||||
response.headers().get(http::header::CACHE_CONTROL).unwrap(),
|
||||
"no-store"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn status_exposes_runtime_memory_accounting_and_cold_limits() {
|
||||
let (router, store, _dir, secret, _) = fixture().await;
|
||||
let response = router
|
||||
.oneshot(
|
||||
request("/api/status")
|
||||
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let body = json_body(response).await;
|
||||
assert_eq!(body["transform_resources"]["memory_budget_bytes"], 128 * 1024 * 1024);
|
||||
assert_eq!(body["transform_resources"]["max_inflight"], 32);
|
||||
assert_eq!(body["memory_accounting"]["transform_budget_bytes"], 128 * 1024 * 1024);
|
||||
assert_eq!(body["memory_accounting"]["cache_hot_limit_bytes"], 256 * 1024 * 1024);
|
||||
assert_eq!(body["cache"]["cold_limit_bytes"], 4_u64 * 1024 * 1024 * 1024);
|
||||
assert_eq!(body["cache"]["cold_limit_entries"], 50_000);
|
||||
assert_eq!(body["memory_accounting"]["non_cache_rss_includes_transform"], true);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn rejects_legacy_token_rebinding_and_cross_origin() {
|
||||
let (router, store, _dir, secret, _) = fixture().await;
|
||||
let old = request("/api/status")
|
||||
.header("authorization", format!("Bearer {}", "a".repeat(64)))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(old).await.unwrap().status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
let evil = Request::builder()
|
||||
.uri("/healthz")
|
||||
.header("host", "evil.test:9090")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(evil).await.unwrap().status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
let req = request("/api/status")
|
||||
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME))
|
||||
.header("origin", "http://evil.test")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(req).await.unwrap().status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
let req = request("/api/status")
|
||||
.header("cookie", format!("{}={secret}", auth::COOKIE_NAME))
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(router.oneshot(req).await.unwrap().status(), StatusCode::OK);
|
||||
store.close().await;
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
#[tokio::test]
|
||||
async fn login_bundle_is_public_but_cache_mutations_require_session_and_csrf() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
let public = request("/assets/login-langs.json")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(public).await.unwrap().status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
let anonymous = request("/api/cache").body(Body::empty()).unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(anonymous).await.unwrap().status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
let anonymous = request("/api/traffic").body(Body::empty()).unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(anonymous).await.unwrap().status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
let anonymous = request("/api/transform").body(Body::empty()).unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(anonymous).await.unwrap().status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
for valid in [false, true] {
|
||||
let req = request("/api/cache/purge")
|
||||
.method("POST")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header(
|
||||
"x-csrf-token",
|
||||
if valid { csrf.as_str() } else { "invalid" },
|
||||
)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
let expected = if valid {
|
||||
StatusCode::OK
|
||||
} else {
|
||||
StatusCode::FORBIDDEN
|
||||
};
|
||||
assert_eq!(
|
||||
router.clone().oneshot(req).await.unwrap().status(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
let cleanup = request("/api/cache/cold/cleanup")
|
||||
.method("POST")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", "invalid")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"oldest":1,"hits_zero":false}"#))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(cleanup).await.unwrap().status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
let cleanup_status = request("/api/cache/cold/cleanup/1")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(cleanup_status).await.unwrap().status(),
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
store.close().await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn selective_cache_invalidation_api_manages_bans_and_rejects_wide_rules_without_confirmation() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let mutate = |path: &str, method: &str, body: Body| {
|
||||
request(path)
|
||||
.method(method)
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(body)
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let create = router
|
||||
.clone()
|
||||
.oneshot(mutate(
|
||||
"/api/cache/invalidate",
|
||||
"POST",
|
||||
Body::from(
|
||||
r#"{"operation":"ban","selector":"exact","value":"/asset.js?v=1","host":"example.test","ttl_seconds":60}"#,
|
||||
),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(create.status(), StatusCode::OK);
|
||||
let created = json_body(create).await;
|
||||
let ban_id = created["result"]["ban_id"].as_u64().expect("ban id");
|
||||
assert_eq!(created["result"]["status"], "active");
|
||||
assert_eq!(created["bans"].as_array().unwrap().len(), 1);
|
||||
|
||||
let list = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/cache/bans")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(list.status(), StatusCode::OK);
|
||||
assert_eq!(json_body(list).await["bans"].as_array().unwrap().len(), 1);
|
||||
|
||||
let wide = router
|
||||
.clone()
|
||||
.oneshot(mutate(
|
||||
"/api/cache/invalidate",
|
||||
"POST",
|
||||
Body::from(r#"{"operation":"purge","selector":"regex","value":".*"}"#),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(wide.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
let remove = router
|
||||
.clone()
|
||||
.oneshot(mutate(
|
||||
&format!("/api/cache/bans/{ban_id}"),
|
||||
"DELETE",
|
||||
Body::empty(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(remove.status(), StatusCode::OK);
|
||||
assert!(json_body(remove).await["bans"].as_array().unwrap().is_empty());
|
||||
|
||||
let missing = router
|
||||
.clone()
|
||||
.oneshot(mutate(
|
||||
"/api/cache/bans/999999",
|
||||
"DELETE",
|
||||
Body::empty(),
|
||||
))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(missing.status(), StatusCode::NOT_FOUND);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn section_updates_are_revisioned_and_do_not_require_full_config_payloads() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let logging = Config::default().logging;
|
||||
let body = serde_json::to_vec(&json!({
|
||||
"expected_revision": 0,
|
||||
"value": logging,
|
||||
}))
|
||||
.unwrap();
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/config/logging")
|
||||
.method("PUT")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from(body))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(json_body(response).await["revision"], 1);
|
||||
|
||||
let http2 = crate::settings::Http2Connections {
|
||||
idle_timeout_seconds: 120,
|
||||
max_connection_lifetime_seconds: 7_200,
|
||||
drain_timeout_seconds: 15,
|
||||
};
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/config/http2")
|
||||
.method("PUT")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&json!({"expected_revision":1,"value":http2})).unwrap(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let updated = json_body(response).await;
|
||||
assert_eq!(updated["revision"], 2);
|
||||
assert_eq!(updated["config"]["http2"]["max_connection_lifetime_seconds"], 7_200);
|
||||
|
||||
let stale = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/config/logging")
|
||||
.method("PUT")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(
|
||||
&json!({"expected_revision":0,"value":Config::default().logging}),
|
||||
)
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(stale.status(), StatusCode::CONFLICT);
|
||||
|
||||
let unknown = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/config/not-a-section")
|
||||
.method("PUT")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from(r#"{"expected_revision":2,"value":{}}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(unknown.status(), StatusCode::NOT_FOUND);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn logging_section_persists_error_log_settings_atomically() {
|
||||
let (router, store, dir, secret, csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let logging = Config::default().logging;
|
||||
let mut error_log = Config::default().error_log;
|
||||
error_log.enabled = true;
|
||||
error_log.path = "/tmp/rustproxy-error.log".into();
|
||||
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/config/logging")
|
||||
.method("PUT")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&json!({
|
||||
"expected_revision": 0,
|
||||
"value": {
|
||||
"logging": logging,
|
||||
"error_log": error_log,
|
||||
},
|
||||
}))
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let updated = json_body(response).await;
|
||||
assert_eq!(updated["revision"], 1);
|
||||
assert_eq!(updated["config"]["error_log"]["enabled"], true);
|
||||
assert_eq!(updated["config"]["error_log"]["path"], "/tmp/rustproxy-error.log");
|
||||
|
||||
let configuration = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/config")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let persisted = json_body(configuration).await;
|
||||
assert_eq!(persisted["config"]["error_log"]["enabled"], true);
|
||||
assert_eq!(persisted["config"]["error_log"]["path"], "/tmp/rustproxy-error.log");
|
||||
drop(router);
|
||||
store.close().await;
|
||||
let reopened = Store::open(&dir.path().join("db"), &Config::default())
|
||||
.await
|
||||
.unwrap();
|
||||
let persisted = reopened.load().await.unwrap();
|
||||
assert_eq!(persisted.revision, 1);
|
||||
assert!(persisted.config.error_log.enabled);
|
||||
assert_eq!(persisted.config.error_log.path, "/tmp/rustproxy-error.log");
|
||||
reopened.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn error_log_is_not_a_standalone_configuration_section() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let response = router
|
||||
.oneshot(
|
||||
request("/api/config/error_log")
|
||||
.method("PUT")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from(r#"{"expected_revision":0,"value":{}}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn default_pages_section_accepts_the_standard_value_payload() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let pages = Config::default().pages;
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/config/pages")
|
||||
.method("PUT")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from(
|
||||
serde_json::to_vec(&json!({
|
||||
"expected_revision": 0,
|
||||
"value": pages,
|
||||
}))
|
||||
.unwrap(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(json_body(response).await["revision"], 1);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn panel_subpage_routes_survive_browser_refresh() {
|
||||
let (router, store, _dir, secret, _) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
for path in [
|
||||
"/overview",
|
||||
"/vhosts",
|
||||
"/web-server",
|
||||
"/docker-vhosts",
|
||||
"/redirects",
|
||||
"/404-hosts",
|
||||
"/streams",
|
||||
"/acl-profiles",
|
||||
"/configuration",
|
||||
"/tls",
|
||||
"/traffic",
|
||||
"/cache",
|
||||
"/transform",
|
||||
"/logging",
|
||||
"/settings",
|
||||
] {
|
||||
let public = router
|
||||
.clone()
|
||||
.oneshot(request(path).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(public.status(), StatusCode::OK, "anonymous {path}");
|
||||
let public_body = axum::body::to_bytes(public.into_body(), 100_000)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(std::str::from_utf8(&public_body)
|
||||
.unwrap()
|
||||
.contains("/assets/login.js"));
|
||||
|
||||
let private = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request(path)
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(private.status(), StatusCode::OK, "authenticated {path}");
|
||||
let private_body = axum::body::to_bytes(private.into_body(), 2 * 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(std::str::from_utf8(&private_body)
|
||||
.unwrap()
|
||||
.contains("/assets/app.js"));
|
||||
}
|
||||
store.close().await;
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
#[tokio::test]
|
||||
async fn csrf_required_and_logout_revokes_cookie() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let req = request("/api/auth/logout")
|
||||
.method("POST")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(req).await.unwrap().status(),
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
let req = request("/api/auth/logout")
|
||||
.method("POST")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", csrf)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(req).await.unwrap().status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
let req = request("/api/status")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.oneshot(req).await.unwrap().status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
store.close().await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn upload_and_scoped_queries_keep_control_plane_guards() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
for (path, body) in [
|
||||
(
|
||||
"/api/certificates/upload",
|
||||
r#"{"cert_pem":"test","key_pem":"test","hosts":["app.localhost"]}"#,
|
||||
),
|
||||
(
|
||||
"/api/certificates/deploy",
|
||||
r#"{"certificate_id":"shared","cert_pem":"test","key_pem":"test"}"#,
|
||||
),
|
||||
] {
|
||||
for (authenticated, valid_csrf, expected) in
|
||||
[(false, false, 401), (true, false, 403), (true, true, 409)]
|
||||
{
|
||||
let mut req = request(path)
|
||||
.method("POST")
|
||||
.header("content-type", "application/json")
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1");
|
||||
if authenticated {
|
||||
req = req.header("cookie", &cookie);
|
||||
}
|
||||
if valid_csrf {
|
||||
req = req.header("x-csrf-token", &csrf);
|
||||
}
|
||||
assert_eq!(
|
||||
router
|
||||
.clone()
|
||||
.oneshot(req.body(Body::from(body)).unwrap())
|
||||
.await
|
||||
.unwrap()
|
||||
.status()
|
||||
.as_u16(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
}
|
||||
for (path, expected) in [
|
||||
("/api/logs?vhost=demo&limit=8", 200),
|
||||
("/api/logs?cache=BAD", 400),
|
||||
("/api/traffic", 200),
|
||||
("/api/cache", 200),
|
||||
("/api/transform", 200),
|
||||
] {
|
||||
let req = request(path)
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(req).await.unwrap().status().as_u16(),
|
||||
expected
|
||||
);
|
||||
}
|
||||
let req = request("/api/cache/purge?vhost=missing")
|
||||
.method("POST")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.oneshot(req).await.unwrap().status(),
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn certificate_reload_keeps_configuration_revision_stable() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/certificates/reload")
|
||||
.method("POST")
|
||||
.header("content-type", "application/json")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from("{}"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload = json_body(response).await;
|
||||
assert_eq!(payload["reloaded"], true);
|
||||
assert_eq!(payload["revision"], 0);
|
||||
|
||||
let configuration = router
|
||||
.oneshot(
|
||||
request("/api/config")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(json_body(configuration).await["revision"], 0);
|
||||
store.close().await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn manual_certificate_can_be_reloaded_by_id_without_revision_change() {
|
||||
let mut config = Config::default();
|
||||
config.tls.certificates.push(crate::tls::Certificate {
|
||||
issuer: None,
|
||||
id: "manual-file".into(),
|
||||
covered_hosts: Vec::new(),
|
||||
hosts: Vec::new(),
|
||||
cert_file: "default.pem".into(),
|
||||
key_file: "default.key".into(),
|
||||
});
|
||||
let (router, store, _dir, secret, csrf) = fixture_with_certificate_storage(config).await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/certificates/manual-file/reload")
|
||||
.method("POST")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let payload = json_body(response).await;
|
||||
assert_eq!(payload["certificate_id"], "manual-file");
|
||||
assert_eq!(payload["revision"], 0);
|
||||
assert_eq!(payload["reloaded"], true);
|
||||
|
||||
let api_token = "c".repeat(64);
|
||||
store
|
||||
.upsert_certificate_api_credential(
|
||||
"file-renewer",
|
||||
&crate::auth::secret_hash(&api_token),
|
||||
"external file renewal hook",
|
||||
"owner",
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let external = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/certificates/manual-file/reload")
|
||||
.method("POST")
|
||||
.header("authorization", format!("Bearer {api_token}"))
|
||||
.header("x-rustproxy-certificate-key", "file-renewer")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(external.status(), StatusCode::OK);
|
||||
assert_eq!(json_body(external).await["certificate_id"], "manual-file");
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn certificate_api_tokens_are_scoped_commented_and_individually_revocable() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let token = "a".repeat(64);
|
||||
let token_two = "b".repeat(64);
|
||||
let invalid_key = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/certificate-api")
|
||||
.method("PUT")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(json!({"key_id":"x","token":token}).to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(invalid_key.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
for (key_id, value, comment) in [
|
||||
("certbot-main", token.as_str(), "production certbot"),
|
||||
("deploy-hook", token_two.as_str(), "secondary deploy hook"),
|
||||
] {
|
||||
let rotated = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/certificate-api")
|
||||
.method("PUT")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
json!({"key_id":key_id,"token":value,"comment":comment}).to_string(),
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(rotated.status(), StatusCode::OK);
|
||||
let rotated = json_body(rotated).await;
|
||||
assert_eq!(rotated["configured"], true);
|
||||
assert_eq!(rotated["credential"]["key_id"], key_id);
|
||||
assert_eq!(rotated["credential"]["comment"], comment);
|
||||
assert_eq!(rotated["token"], value);
|
||||
}
|
||||
|
||||
let status = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/certificate-api")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let status = json_body(status).await;
|
||||
assert_eq!(status["configured"], true);
|
||||
assert_eq!(status["credentials"].as_array().unwrap().len(), 2);
|
||||
assert!(status.get("token").is_none());
|
||||
|
||||
let external = |key_id: &'static str, value: String| {
|
||||
request("/api/certificates/reload")
|
||||
.method("POST")
|
||||
.header("authorization", format!("Bearer {value}"))
|
||||
.header("x-rustproxy-certificate-key", key_id)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap()
|
||||
};
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(external("certbot-main", token.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(external("deploy-hook", token_two.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let missing_key = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/certificates/reload")
|
||||
.method("POST")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from("{}"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(missing_key.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let forbidden_scope = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/config")
|
||||
.header("authorization", format!("Bearer {token}"))
|
||||
.header("x-rustproxy-certificate-key", "certbot-main")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(forbidden_scope.status(), StatusCode::UNAUTHORIZED);
|
||||
|
||||
let revoked = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/certificate-api/certbot-main")
|
||||
.method("DELETE")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(revoked.status(), StatusCode::OK);
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(external("certbot-main", token.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(external("deploy-hook", token_two.clone()))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
|
||||
let revoked_all = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/certificate-api")
|
||||
.method("DELETE")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(revoked_all.status(), StatusCode::OK);
|
||||
let response = router
|
||||
.oneshot(external("deploy-hook", token_two))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
store.close().await;
|
||||
}
|
||||
@@ -0,0 +1,420 @@
|
||||
#[tokio::test]
|
||||
async fn admin_preferences_are_account_scoped_and_csrf_guarded() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/preferences")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let initial = json_body(response).await;
|
||||
assert_eq!(
|
||||
initial["live_log_columns"].as_array().unwrap().len(),
|
||||
LIVE_LOG_COLUMNS.len()
|
||||
);
|
||||
assert_eq!(initial["theme"], "dark");
|
||||
|
||||
assert!(store
|
||||
.save_admin_preferences("owner", r#"{"live_log_columns":["time","status"]}"#)
|
||||
.await
|
||||
.unwrap());
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/preferences")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let legacy = json_body(response).await;
|
||||
assert_eq!(legacy["live_log_columns"], json!(["time", "status"]));
|
||||
assert_eq!(legacy["theme"], "dark");
|
||||
|
||||
let selected = json!({
|
||||
"live_log_columns":["time","request","status","upstream_error"],
|
||||
"theme":"light"
|
||||
});
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/preferences")
|
||||
.method("PUT")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(selected.to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(json_body(response).await, selected);
|
||||
|
||||
let saved: Value =
|
||||
serde_json::from_str(&store.admin_preferences("owner").await.unwrap().unwrap())
|
||||
.unwrap();
|
||||
assert_eq!(saved, selected);
|
||||
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/preferences")
|
||||
.method("PUT")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(selected.to_string()))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/preferences")
|
||||
.method("PUT")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(r#"{"live_log_columns":[]}"#))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
|
||||
let response = router
|
||||
.oneshot(
|
||||
request("/api/preferences")
|
||||
.method("PUT")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"live_log_columns":["time"],"theme":"sepia"}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn acme_and_cache_inspection_require_existing_admin_guards() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
for path in ["/api/acme", "/api/cache?offset=0&limit=2"] {
|
||||
let req = request(path).body(Body::empty()).unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(req).await.unwrap().status(),
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
let req = request(path)
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.clone().oneshot(req).await.unwrap().status(),
|
||||
StatusCode::OK
|
||||
);
|
||||
}
|
||||
for (authenticated, valid_csrf, expected) in
|
||||
[(false, false, 401), (true, false, 403), (true, true, 409)]
|
||||
{
|
||||
let mut req = request("/api/acme/issue")
|
||||
.method("POST")
|
||||
.header("content-type", "application/json")
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1");
|
||||
if authenticated {
|
||||
req = req.header("cookie", &cookie);
|
||||
}
|
||||
if valid_csrf {
|
||||
req = req.header("x-csrf-token", &csrf);
|
||||
}
|
||||
// This fixture has no certificate directory: valid auth reaches the
|
||||
// 409 precondition, never a child process, DNS update or CA request.
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(req.body(Body::from("{}")).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status().as_u16(), expected);
|
||||
}
|
||||
let req = request("/api/cache?vhost=missing")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
router.oneshot(req).await.unwrap().status(),
|
||||
StatusCode::NOT_FOUND
|
||||
);
|
||||
store.close().await;
|
||||
}
|
||||
|
||||
async fn json_body(response: Response) -> Value {
|
||||
let bytes = axum::body::to_bytes(response.into_body(), 2 * 1024 * 1024)
|
||||
.await
|
||||
.unwrap();
|
||||
serde_json::from_slice(&bytes).unwrap()
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn external_certificate_deploy_creates_updates_and_noops() {
|
||||
let (router, store, dir, secret, csrf) =
|
||||
fixture_with_certificate_storage(Config::default()).await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
let first = rcgen::generate_simple_self_signed(vec!["app.example.test".into()]).unwrap();
|
||||
let first_body = json!({
|
||||
"certificate_id": "opnsense-cert",
|
||||
"cert_pem": first.cert.pem(),
|
||||
"key_pem": first.key_pair.serialize_pem(),
|
||||
})
|
||||
.to_string();
|
||||
|
||||
let deploy = |body: String| {
|
||||
request("/api/certificates/deploy")
|
||||
.method("POST")
|
||||
.header("content-type", "application/json")
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("cookie", &cookie)
|
||||
.header("x-csrf-token", &csrf)
|
||||
.body(Body::from(body))
|
||||
.unwrap()
|
||||
};
|
||||
|
||||
let response = router.clone().oneshot(deploy(first_body.clone())).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let created = json_body(response).await;
|
||||
assert_eq!(created["operation"], "created");
|
||||
assert_eq!(created["created"], true);
|
||||
assert_eq!(created["changed"], true);
|
||||
let first_revision = created["revision"].as_u64().unwrap();
|
||||
let first_cert_file = created["cert_file"].as_str().unwrap().to_owned();
|
||||
let first_key_file = created["key_file"].as_str().unwrap().to_owned();
|
||||
assert!(dir.path().join("certs").join(&first_cert_file).is_file());
|
||||
assert!(dir.path().join("certs").join(&first_key_file).is_file());
|
||||
|
||||
let response = router.clone().oneshot(deploy(first_body)).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let unchanged = json_body(response).await;
|
||||
assert_eq!(unchanged["operation"], "unchanged");
|
||||
assert_eq!(unchanged["created"], false);
|
||||
assert_eq!(unchanged["changed"], false);
|
||||
assert_eq!(unchanged["revision"].as_u64(), Some(first_revision));
|
||||
assert_eq!(unchanged["cert_file"].as_str(), Some(first_cert_file.as_str()));
|
||||
assert_eq!(unchanged["key_file"].as_str(), Some(first_key_file.as_str()));
|
||||
|
||||
let second = rcgen::generate_simple_self_signed(vec!["app.example.test".into()]).unwrap();
|
||||
let second_body = json!({
|
||||
"certificate_id": "opnsense-cert",
|
||||
"cert_pem": second.cert.pem(),
|
||||
"key_pem": second.key_pair.serialize_pem(),
|
||||
})
|
||||
.to_string();
|
||||
let response = router.oneshot(deploy(second_body)).await.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
let updated = json_body(response).await;
|
||||
assert_eq!(updated["operation"], "updated");
|
||||
assert_eq!(updated["created"], false);
|
||||
assert_eq!(updated["changed"], true);
|
||||
assert!(updated["revision"].as_u64().unwrap() > first_revision);
|
||||
assert_ne!(updated["cert_file"].as_str(), Some(first_cert_file.as_str()));
|
||||
assert_ne!(updated["key_file"].as_str(), Some(first_key_file.as_str()));
|
||||
assert!(!dir.path().join("certs").join(&first_cert_file).exists());
|
||||
assert!(!dir.path().join("certs").join(&first_key_file).exists());
|
||||
store.close().await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn anonymous_operational_paths_fail_closed_before_extractors() {
|
||||
let (router, store, _dir, _, _) = fixture().await;
|
||||
for path in [
|
||||
"/api/status",
|
||||
"/api/config",
|
||||
"/api/backends",
|
||||
"/api/docker",
|
||||
"/api/traffic",
|
||||
"/api/cache?limit=invalid",
|
||||
"/api/logs?limit=invalid",
|
||||
"/api/live-logs",
|
||||
"/api/certificate-api",
|
||||
"/api/preferences",
|
||||
"/api/audit",
|
||||
"/api/events",
|
||||
"/api/certificates",
|
||||
"/api/acme",
|
||||
"/api/auth/session",
|
||||
"/api/metrics",
|
||||
"/metrics",
|
||||
"/docs/openapi.json",
|
||||
"/docs/content.json",
|
||||
"/docs/init.js",
|
||||
"/docs/style.css",
|
||||
"/docs/swagger-ui-bundle.js",
|
||||
"/docs/swagger-ui.css",
|
||||
"/docs/licenses.txt",
|
||||
"/assets/langs.json",
|
||||
"/assets/app.js",
|
||||
"/assets/management.js",
|
||||
"/assets/integrations.js",
|
||||
"/assets/traffic-panel.js",
|
||||
"/assets/cache-panel.js",
|
||||
"/assets/transform-panel.js",
|
||||
"/assets/vhosts.js",
|
||||
"/assets/docker.js",
|
||||
"/assets/streams.js",
|
||||
"/assets/redirects.js",
|
||||
"/assets/host404.js",
|
||||
"/assets/settings.js",
|
||||
"/assets/acme.js",
|
||||
"/api/not-a-route",
|
||||
"/not-a-route",
|
||||
] {
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(request(path).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED, "{path}");
|
||||
assert_eq!(response.headers()["cache-control"], "no-store");
|
||||
assert!(!response
|
||||
.headers()
|
||||
.contains_key("access-control-allow-origin"));
|
||||
assert!(!response.headers().contains_key("server"));
|
||||
assert_eq!(
|
||||
json_body(response).await,
|
||||
json!({"error":"auth.required"}),
|
||||
"{path}"
|
||||
);
|
||||
}
|
||||
for (path, method) in [
|
||||
("/api/config", "PUT"),
|
||||
("/api/config/cache", "PUT"),
|
||||
("/api/acme/issue", "POST"),
|
||||
("/api/acme/renew", "POST"),
|
||||
("/api/docker/refresh", "POST"),
|
||||
("/api/cache/purge", "POST"),
|
||||
("/api/cache/invalidate", "POST"),
|
||||
("/api/cache/bans", "GET"),
|
||||
("/api/cache/bans/1", "DELETE"),
|
||||
("/api/tunnels", "GET"),
|
||||
("/api/tunnels/1/kill", "POST"),
|
||||
("/api/cache/cold/cleanup", "POST"),
|
||||
("/api/cache/cold/cleanup/1", "GET"),
|
||||
("/api/certificates/upload", "POST"),
|
||||
("/api/certificates/deploy", "POST"),
|
||||
("/api/certificates/reload", "POST"),
|
||||
("/api/certificates/manual-file/reload", "POST"),
|
||||
("/api/certificate-api", "PUT"),
|
||||
("/api/certificate-api/certbot-main", "DELETE"),
|
||||
("/api/certificate-api", "DELETE"),
|
||||
("/api/basic-auth/hash", "POST"),
|
||||
("/api/auth/password", "POST"),
|
||||
("/api/auth/logout", "POST"),
|
||||
("/api/auth/logout-all", "POST"),
|
||||
("/api/config", "OPTIONS"),
|
||||
("/api/config", "DELETE"),
|
||||
("/api/auth/login", "GET"),
|
||||
("/api/auth/setup", "GET"),
|
||||
("/healthz", "POST"),
|
||||
("/docs/openapi.json", "HEAD"),
|
||||
("/docs/content.json", "HEAD"),
|
||||
] {
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request(path)
|
||||
.method(method)
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from("malformed secret payload"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
response.status(),
|
||||
StatusCode::UNAUTHORIZED,
|
||||
"{method} {path}"
|
||||
);
|
||||
if method != "HEAD" {
|
||||
assert_eq!(json_body(response).await, json!({"error":"auth.required"}));
|
||||
}
|
||||
}
|
||||
store.close().await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn public_document_and_discovery_contain_only_login_information() {
|
||||
let (router, store, _dir, _, _) = fixture().await;
|
||||
for (path, expected) in [
|
||||
("/healthz", json!({"status":"ok"})),
|
||||
("/api/auth/status", json!({"setup_required":false})),
|
||||
] {
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(request(path).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert_eq!(json_body(response).await, expected);
|
||||
}
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(request("/").body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
let bytes = axum::body::to_bytes(response.into_body(), 100_000)
|
||||
.await
|
||||
.unwrap();
|
||||
let text = std::str::from_utf8(&bytes).unwrap();
|
||||
assert!(text.contains("/assets/login.js"));
|
||||
for absent in [
|
||||
"/assets/app.js",
|
||||
"id=\"workspace\"",
|
||||
"loginLead",
|
||||
"config-editor",
|
||||
"version",
|
||||
] {
|
||||
assert!(!text.contains(absent), "unexpected public content {absent}");
|
||||
}
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/assets/login-langs.json")
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let bundle = json_body(response).await;
|
||||
let keys: Vec<String> = serde_json::from_str(include_str!("../web/login-keys.json")).unwrap();
|
||||
for language in bundle["languages"].as_array().unwrap() {
|
||||
assert!(language["translations"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.keys()
|
||||
.all(|key| keys.contains(key)));
|
||||
assert!(language["translations"].get("cacheTitle").is_none());
|
||||
}
|
||||
store.close().await;
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
#[tokio::test]
|
||||
async fn docs_require_the_same_session_and_do_not_redirect_schema_fetches() {
|
||||
let (router, store, _dir, secret, _) = fixture().await;
|
||||
for path in ["/docs", "/docs/"] {
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(request(path).body(Body::empty()).unwrap())
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::SEE_OTHER);
|
||||
assert_eq!(response.headers()["location"], "/?next=docs");
|
||||
}
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
for path in [
|
||||
"/",
|
||||
"/docs",
|
||||
"/docs/",
|
||||
"/docs/init.js",
|
||||
"/docs/style.css",
|
||||
"/docs/swagger-ui-bundle.js",
|
||||
"/docs/swagger-ui.css",
|
||||
"/docs/licenses.txt",
|
||||
"/docs/content.json",
|
||||
"/assets/app.js",
|
||||
] {
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request(path)
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK, "{path}");
|
||||
assert_eq!(response.headers()["x-frame-options"], "DENY");
|
||||
assert_eq!(response.headers()["cache-control"], "no-store");
|
||||
}
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/docs/openapi.json")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let spec = json_body(response).await;
|
||||
assert_eq!(spec["openapi"], "3.0.3");
|
||||
assert_eq!(spec["info"]["version"], env!("CARGO_PKG_VERSION"));
|
||||
assert_eq!(
|
||||
spec["components"]["securitySchemes"]["AdminSession"]["in"],
|
||||
"cookie"
|
||||
);
|
||||
assert!(spec["paths"].get("/api/config").is_some());
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/docs/content.json")
|
||||
.header("cookie", &cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let docs = json_body(response).await;
|
||||
let documents = docs["documents"].as_array().expect("documentation bundle");
|
||||
assert!(documents.iter().any(|document| document["path"] == "docs/README.md"));
|
||||
assert!(documents.iter().any(|document| document["path"] == "docs/WEB_SERVER.md"));
|
||||
assert!(documents.iter().any(|document| document["path"] == "docs/PHP_FPM.md"));
|
||||
assert!(documents.iter().any(|document| document["path"] == "docs/V0.25.0_AUDIT.md"));
|
||||
assert!(documents.iter().any(|document| document["path"] == "docs/V0.25.0_SECURITY_PREPROD_AUDIT.md"));
|
||||
store.close().await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn credentials_in_query_or_duplicate_cookies_cannot_authorize() {
|
||||
let (router, store, _dir, secret, _) = fixture().await;
|
||||
for cookie in [
|
||||
String::new(),
|
||||
format!("{}=invalid", auth::COOKIE_NAME),
|
||||
format!(
|
||||
"{}={secret}; {}={secret}",
|
||||
auth::COOKIE_NAME,
|
||||
auth::COOKIE_NAME
|
||||
),
|
||||
] {
|
||||
let path = format!("/api/config?token={secret}&session={secret}");
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request(&path)
|
||||
.header("cookie", cookie)
|
||||
.header("authorization", format!("Bearer {secret}"))
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||
}
|
||||
store.close().await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn duplicate_csrf_or_marker_is_rejected_and_session_survives() {
|
||||
let (router, store, _dir, secret, csrf) = fixture().await;
|
||||
let cookie = format!("{}={secret}", auth::COOKIE_NAME);
|
||||
for duplicate in ["x-csrf-token", "x-rustproxy-request"] {
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request("/api/auth/logout")
|
||||
.method("POST")
|
||||
.header("cookie", &cookie)
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("x-csrf-token", &csrf)
|
||||
.header(
|
||||
duplicate,
|
||||
if duplicate == "x-csrf-token" {
|
||||
csrf.as_str()
|
||||
} else {
|
||||
"1"
|
||||
},
|
||||
)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::FORBIDDEN);
|
||||
}
|
||||
let response = router
|
||||
.oneshot(
|
||||
request("/api/auth/session")
|
||||
.header("cookie", cookie)
|
||||
.body(Body::empty())
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
store.close().await;
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn malformed_anonymous_json_never_reflects_submitted_values() {
|
||||
let (router, store, _dir, _, _) = fixture().await;
|
||||
for path in ["/api/auth/login", "/api/auth/setup"] {
|
||||
let response = router
|
||||
.clone()
|
||||
.oneshot(
|
||||
request(path)
|
||||
.method("POST")
|
||||
.header("origin", "http://127.0.0.1:9090")
|
||||
.header("x-rustproxy-request", "1")
|
||||
.header("content-type", "application/json")
|
||||
.body(Body::from(
|
||||
r#"{"username":{"untrusted":"DO_NOT_REFLECT_ME"}}"#,
|
||||
))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||
assert_eq!(
|
||||
json_body(response).await,
|
||||
json!({"error":"auth.invalid_request"})
|
||||
);
|
||||
}
|
||||
store.close().await;
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,419 @@
|
||||
"use strict";
|
||||
const $ = (id) => document.getElementById(id);
|
||||
let language = null;
|
||||
try { language = localStorage.getItem("rp-language"); } catch { }
|
||||
let csrf = "", authenticated = false, setupRequired = false, sessionInfo = null;
|
||||
const ADMIN_THEMES = new Set(["dark", "light"]);
|
||||
let adminPreferences = null, adminPreferencesLoading = null, adminPreferencesWrite = Promise.resolve();
|
||||
function normalizeAdminTheme(value) { return ADMIN_THEMES.has(value) ? value : "dark"; }
|
||||
function applyAdminTheme(value, cache = true) {
|
||||
const theme = normalizeAdminTheme(value);
|
||||
document.documentElement.dataset.theme = theme;
|
||||
const select = $("ui-theme"); if (select) select.value = theme;
|
||||
if (cache) { try { localStorage.setItem("rp-theme", theme); } catch { } }
|
||||
return theme;
|
||||
}
|
||||
try { applyAdminTheme(localStorage.getItem("rp-theme") || "dark", false); } catch { applyAdminTheme("dark", false); }
|
||||
async function loadAdminPreferences(force = false) {
|
||||
if (!authenticated) return adminPreferences;
|
||||
if (adminPreferences && !force) return adminPreferences;
|
||||
if (adminPreferencesLoading) return adminPreferencesLoading;
|
||||
const epoch = authEpoch;
|
||||
adminPreferencesLoading = (async () => {
|
||||
const value = await api("/api/preferences");
|
||||
if (!authenticated || epoch !== authEpoch) return adminPreferences;
|
||||
adminPreferences = { ...value, theme: applyAdminTheme(value?.theme) };
|
||||
return adminPreferences;
|
||||
})();
|
||||
try { return await adminPreferencesLoading; } finally { adminPreferencesLoading = null; }
|
||||
}
|
||||
function updateAdminPreferences(patch) {
|
||||
const epoch = authEpoch;
|
||||
const task = adminPreferencesWrite.then(async () => {
|
||||
if (!authenticated || epoch !== authEpoch) throw new Error(t("auth.required"));
|
||||
const current = await loadAdminPreferences();
|
||||
if (!current || !authenticated || epoch !== authEpoch) throw new Error(t("auth.required"));
|
||||
const next = { ...current, ...patch }; next.theme = normalizeAdminTheme(next.theme);
|
||||
const saved = await api("/api/preferences", { method: "PUT", body: JSON.stringify(next) });
|
||||
if (!authenticated || epoch !== authEpoch) return adminPreferences;
|
||||
adminPreferences = { ...saved, theme: applyAdminTheme(saved?.theme) };
|
||||
return adminPreferences;
|
||||
});
|
||||
adminPreferencesWrite = task.catch(() => undefined);
|
||||
return task;
|
||||
}
|
||||
function resetAdminPreferences() { adminPreferences = null; adminPreferencesLoading = null; adminPreferencesWrite = Promise.resolve(); }
|
||||
window.rustProxyPreferences = { load: loadAdminPreferences, update: updateAdminPreferences, current: () => adminPreferences, applyTheme: applyAdminTheme };
|
||||
const PANEL_PATHS = { overview: "/overview", vhosts: "/vhosts", "web-server": "/web-server", "docker-vhosts": "/docker-vhosts", redirects: "/redirects", notfound: "/404-hosts", streams: "/streams", "acl-profiles": "/acl-profiles", tls: "/tls", traffic: "/traffic", cache: "/cache", transform: "/transform", settings: "/settings" };
|
||||
function panelFromLocation() { if (typeof location === "undefined") return "overview"; if (location.pathname === "/configuration" || location.pathname === "/logging") return "settings"; const found = Object.entries(PANEL_PATHS).find(([, path]) => path === location.pathname); return found?.[0] || "overview"; }
|
||||
let envelope = null, busy = false, connected = false, currentPanel = panelFromLocation(), previous = null;
|
||||
let samples = [], protocolSamples = { h1: [], h2: [], h3: [] }, websocketProtocolSamples = { h1: [], h2: [], h3: [] }, transformSamples = [], polling = false, toastTimer, editingBackend = null, editingRoute = null;
|
||||
let liveEvents = null, liveConnected = false, authEpoch = 0, pendingAuthDestination = null;
|
||||
let lastApiLatencyMs = null;
|
||||
let adminAuthChannel = null; try { adminAuthChannel = new BroadcastChannel("rustproxy-admin-session"); adminAuthChannel.addEventListener("message", event => { if (event.data === "locked" && authenticated) lock(false); }); } catch { }
|
||||
// Language content is discovered by build.rs from langs/*.json, never compiled
|
||||
// into a two-language switch here. Locale metadata also drives Intl and RTL.
|
||||
let languagePacks = new Map(), defaultLanguage = null;
|
||||
function activePack() { return languagePacks.get(language) || languagePacks.get(defaultLanguage); }
|
||||
function locale() { try { return Intl.getCanonicalLocales(activePack()?.meta.locale || "und")[0]; } catch { return "und"; } }
|
||||
function t(key) { const pack = activePack()?.translations; const fallback = languagePacks.get(defaultLanguage)?.translations; return (pack && Object.hasOwn(pack, key) ? pack[key] : fallback?.[key]) ?? key; }
|
||||
async function loadLanguages() {
|
||||
const bundle = await api("/assets/langs.json");
|
||||
if (!Array.isArray(bundle.languages) || !bundle.languages.length) throw new Error("i18n.invalid_bundle");
|
||||
languagePacks = new Map(bundle.languages.map(pack => [pack.id, pack])); defaultLanguage = bundle.default;
|
||||
if (!languagePacks.has(defaultLanguage)) throw new Error("i18n.missing_fallback");
|
||||
const candidates = [language, ...(navigator.languages || [navigator.language])].filter(Boolean).map(v => v.replaceAll("_", "-").toLowerCase());
|
||||
const packs = [...languagePacks.values()];
|
||||
const selected = candidates.map(value => packs.find(p => p.id.replaceAll("_", "-").toLowerCase() === value || p.meta.locale.toLowerCase() === value) || packs.find(p => p.meta.locale.toLowerCase().split("-")[0] === value.split("-")[0])).find(Boolean);
|
||||
language = selected?.id || defaultLanguage;
|
||||
$("language").replaceChildren(...packs.map(pack => { const option = el("option", pack.meta.native_name); option.value = pack.id; return option; }));
|
||||
$("language").value = language;
|
||||
}
|
||||
let latestStatus = null, certificateInventory = new Map();
|
||||
let selectedCacheMemoryPreset = null, selectedCacheColdPreset = null, selectedTransformPreset = null;
|
||||
function translate() {
|
||||
clearTimeout(toastTimer); hideToast();
|
||||
document.documentElement.lang = locale(); document.documentElement.dir = activePack()?.meta.direction || "ltr";
|
||||
document.querySelectorAll("[data-i18n]").forEach(e => e.textContent = t(e.dataset.i18n));
|
||||
document.querySelectorAll("[data-i18n-placeholder]").forEach(e => e.placeholder = t(e.dataset.i18nPlaceholder));
|
||||
document.querySelectorAll("[data-i18n-aria]").forEach(e => e.setAttribute("aria-label", t(e.dataset.i18nAria)));
|
||||
$("language").value = language; setPanel(currentPanel, false); syncAuthForm(); renderSession();
|
||||
$("connection-label").textContent = t(connected ? "connected" : "disconnected");
|
||||
if (envelope) renderConfig(); if (latestStatus) { renderFooter(latestStatus); renderDiagnostics(latestStatus, false); window.rustProxyVhosts?.renderStatus(latestStatus); window.rustProxyStreams?.renderStatus(latestStatus); } if (lastLogs) renderLogs(lastLogs);
|
||||
syncSettingsDirtyIndicators();
|
||||
}
|
||||
function hideToast() { const node = $("toast"); if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover(); node.hidden = true; }
|
||||
function raiseToast() {
|
||||
const node = $("toast"); if (node.hidden) return;
|
||||
const modal = [...document.querySelectorAll("dialog[open]")].at(-1), parent = modal || document.body;
|
||||
// A popover outside a modal is still inert. Keep it inside the active dialog,
|
||||
// then lift it to the top layer so its backdrop cannot blur or cover it.
|
||||
if (typeof node.hidePopover === "function" && node.matches(":popover-open")) node.hidePopover();
|
||||
if (node.parentElement !== parent) parent.append(node);
|
||||
if (typeof node.showPopover === "function") node.showPopover();
|
||||
}
|
||||
function toast(message, error = false) { clearTimeout(toastTimer); const node = $("toast"); node.textContent = message; node.classList.toggle("error", error); node.hidden = false; raiseToast(); toastTimer = setTimeout(hideToast, error ? 10000 : 4000); }
|
||||
function authMessage(message = "") { const node = $("auth-message"); if (!node) return; node.textContent = message; node.classList.toggle("error", Boolean(message)); }
|
||||
function fieldError(target, message) { const error = new Error(message); error.fieldTarget = target; return error; }
|
||||
function clearFieldError(target) {
|
||||
if (!target) return; target.classList?.remove("field-invalid"); target.removeAttribute?.("aria-invalid");
|
||||
}
|
||||
function clearFormFieldErrors(form) { form?.querySelectorAll(".field-invalid,[aria-invalid=true]").forEach(clearFieldError); }
|
||||
function showFieldError(error, form = null) {
|
||||
let target = error?.fieldTarget; if (typeof target === "string") target = $(target);
|
||||
if (!target || (form && !form.contains(target))) return false;
|
||||
const focusTarget = target.matches?.("input,select,textarea,button") ? target : target.querySelector?.("input,select,textarea,button");
|
||||
target.classList?.add("field-invalid"); target.setAttribute?.("aria-invalid", "true");
|
||||
const clear = () => clearFieldError(target); target.addEventListener?.("input", clear, { once: true }); target.addEventListener?.("change", clear, { once: true });
|
||||
target.scrollIntoView?.({ block: "center", behavior: "smooth" }); focusTarget?.focus?.({ preventScroll: true });
|
||||
return true;
|
||||
}
|
||||
async function withFormSubmitState(form, submitter, task) {
|
||||
const button = submitter || form?.querySelector('button[type="submit"]');
|
||||
const originalText = button?.textContent ?? "", i18nKey = button?.dataset?.i18n || "";
|
||||
clearFormFieldErrors(form);
|
||||
if (button) { button.disabled = true; button.classList.add("is-saving"); button.textContent = t("saving"); }
|
||||
try { return await task(); }
|
||||
catch (error) { showFieldError(error, form); throw error; }
|
||||
finally { if (button) { button.disabled = false; button.classList.remove("is-saving"); button.textContent = i18nKey ? t(i18nKey) : originalText; } }
|
||||
}
|
||||
function appConfirm(message, options = {}) {
|
||||
const title = options.title || t("confirmTitle"), confirmLabel = options.confirmLabel || t("continueAction"), cancelLabel = options.cancelLabel || t("cancelAction");
|
||||
return new Promise(resolve => {
|
||||
const dialog = el("dialog", undefined, "app-confirm-dialog"); dialog.setAttribute("aria-modal", "true");
|
||||
const card = el("div", undefined, "card app-confirm-card"), heading = el("div", undefined, "card-heading"), headingText = el("div");
|
||||
headingText.append(el("span", options.danger ? t("warningLabel") : t("confirmationLabel"), `small-label${options.danger ? " status-error" : ""}`), el("h2", title));
|
||||
heading.append(headingText); card.append(heading, el("p", String(message ?? ""), "app-confirm-message"));
|
||||
const actions = el("div", undefined, "form-actions app-confirm-actions"), cancel = el("button", cancelLabel, "quiet"), accept = el("button", confirmLabel, options.danger ? "outline app-confirm-danger" : "primary");
|
||||
cancel.type = "button"; accept.type = "button"; actions.append(cancel, accept); card.append(actions); dialog.append(card); document.body.append(dialog);
|
||||
let settled = false;
|
||||
const finish = value => { if (settled) return; settled = true; if (dialog.open) dialog.close(); dialog.remove(); resolve(value); };
|
||||
cancel.addEventListener("click", () => finish(false)); accept.addEventListener("click", () => finish(true));
|
||||
dialog.addEventListener("cancel", event => { event.preventDefault(); finish(false); });
|
||||
dialog.addEventListener("close", () => finish(false));
|
||||
dialog.showModal(); cancel.focus();
|
||||
});
|
||||
}
|
||||
function apiArea(path) {
|
||||
const section = path.match(/^\/api\/config\/([^/?]+)/)?.[1];
|
||||
if (section) return ({ "listeners": "settings", "http2": "settings", "docker-agents": "dockerVhosts", "response": "settings", "pages": "settings", "cache": "cache", "transform": "settings", "logging": "settings", "forwarding": "settings", "headers": "settings", "prometheus": "settings", "administration": "administration", "tls": "tls", "vhosts": "vhosts", "redirects": "redirects", "not_found_hosts": "notfound", "streams": "streams", "acl-profiles": "aclProfiles", "host-policies": "security" })[section] || "configuration";
|
||||
if (path === "/api/config") return "configuration";
|
||||
if (path.startsWith("/api/traffic")) return "traffic";
|
||||
if (path.startsWith("/api/cache")) return "cache";
|
||||
if (path.startsWith("/api/tunnels")) return "overview";
|
||||
if (path.startsWith("/api/transform")) return "transform";
|
||||
if (path.startsWith("/api/certificates") || path.startsWith("/api/acme")) return "tls";
|
||||
if (path.startsWith("/api/certificate-api")) return "settings";
|
||||
if (path.startsWith("/api/docker")) return "dockerVhosts";
|
||||
if (path.startsWith("/api/logs")) return "logging";
|
||||
if (path.startsWith("/api/audit") || path.startsWith("/api/preferences") || path.startsWith("/api/auth/password") || path.startsWith("/api/auth/logout")) return "security";
|
||||
return null;
|
||||
}
|
||||
function apiErrorMessage(path, response, value) {
|
||||
const code = typeof value?.error === "string" ? value.error : "";
|
||||
const translated = code ? t(code) : "";
|
||||
const detail = response.status === 409 ? t("stale") : (translated && translated !== code ? translated : (code || `HTTP ${response.status}`));
|
||||
const area = apiArea(path);
|
||||
return area ? t("errorContext").replace("{area}", t(area)).replace("{message}", detail) : detail;
|
||||
}
|
||||
new MutationObserver(records => { if (!records.some(r => r.target.tagName === "DIALOG")) return; const node = $("toast"); if (!node.hidden) raiseToast(); }).observe(document.body, { subtree: true, attributes: true, attributeFilter: ["open"] });
|
||||
async function api(path, options = {}) {
|
||||
const started = performance.now(), trackLatency = path.startsWith("/api/");
|
||||
try {
|
||||
const response = await fetch(path, { ...options, cache: "no-store", credentials: "same-origin", headers: { "Content-Type": "application/json", "X-RustProxy-Request": "1", ...(csrf ? { "X-CSRF-Token": csrf } : {}), ...options.headers } });
|
||||
const text = await response.text(); let value; try { value = JSON.parse(text); } catch { value = { error: text }; }
|
||||
if (!response.ok) { if (response.status === 401 && authenticated) lock(); const error = new Error(apiErrorMessage(path, response, value)); error.status = response.status; error.code = value.error; throw error; } return value;
|
||||
} finally {
|
||||
if (trackLatency) { lastApiLatencyMs = performance.now() - started; if (latestStatus) renderFooter(latestStatus); }
|
||||
}
|
||||
}
|
||||
function setPanel(name, navigate = true) {
|
||||
if (name === "security") { setPanel("settings", navigate); window.rustProxySettings?.select("security"); return; }
|
||||
if (!Object.hasOwn(PANEL_PATHS, name)) name = "overview";
|
||||
currentPanel = name;
|
||||
document.querySelectorAll(".nav").forEach(e => e.classList.toggle("active", e.dataset.panel === name));
|
||||
document.querySelectorAll(".panel").forEach(e => e.hidden = e.id !== `panel-${name}`);
|
||||
$("page-label").textContent = t(name); $("page-title").textContent = t(`${name}Title`); $("page-description").textContent = t(`${name}Description`);
|
||||
if (navigate && typeof location !== "undefined" && ["http:", "https:"].includes(location.protocol) && location.pathname !== PANEL_PATHS[name]) history.pushState({ panel: name }, "", PANEL_PATHS[name]);
|
||||
if (name === "settings") window.rustProxySettings?.syncLocation?.();
|
||||
if (name === "tls") window.rustProxyAcme?.syncLocation?.();
|
||||
if (name === "traffic") void window.rustProxyTrafficPanel?.refresh(); if (name === "cache") void window.rustProxyCachePanel?.refresh(); if (name === "transform") void window.rustProxyTransformPanel?.refresh(); if (name === "docker-vhosts") void window.rustProxyDocker?.refresh(false); if (name === "tls") { void window.rustProxyAcme?.refresh(); void refreshCertificateInventory().then(() => { if (authenticated && currentPanel === "tls") renderCertificates(); }); }
|
||||
}
|
||||
function bytes(value) { if (value < 1024) return `${value} B`; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { value /= 1024; if (value < 1024 || unit === "TiB") return `${value.toFixed(value < 10 ? 1 : 0)} ${unit}`; } }
|
||||
function uptime(value) {
|
||||
let seconds = Math.max(0, Math.floor(Number(value) || 0));
|
||||
if (seconds < 60) return `${seconds}s`;
|
||||
const units = [[365 * 86400, "y"], [86400, "d"], [3600, "h"], [60, "m"]], parts = [];
|
||||
for (const [size, suffix] of units) {
|
||||
const count = Math.floor(seconds / size);
|
||||
if (count) { parts.push(`${count}${suffix}`); seconds %= size; }
|
||||
if (parts.length === 2) break;
|
||||
}
|
||||
return parts.join(" ");
|
||||
}
|
||||
function rateBytes(value) { value = Math.max(0, Number(value) || 0); if (value === 0) return "0.0 KiB"; value /= 1024; for (const unit of ["KiB", "MiB", "GiB", "TiB"]) { if (value < 1024 || unit === "TiB") { if (unit === "KiB" && value < 0.1) return "<0.1 KiB"; const digits = value < 10 ? 1 : 0; return `${Number(value.toFixed(digits))} ${unit}`; } value /= 1024; } }
|
||||
function el(tag, text, cls) { const e = document.createElement(tag); if (text !== undefined) e.textContent = text; if (cls) e.className = cls; return e; }
|
||||
function action(label, fn) { const b = el("button", label, "quiet"); b.type = "button"; b.addEventListener("click", fn); return b; }
|
||||
function renderStatus(s) {
|
||||
const firstStatus = latestStatus === null; latestStatus = 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;
|
||||
$("metric-ws-h2").textContent = m.websocket_h2_active || 0;
|
||||
$("metric-ws-h3").textContent = m.websocket_h3_active || 0;
|
||||
$("metric-ws-total").textContent = (m.websocket_total || 0).toLocaleString(locale());
|
||||
$("metric-h2").textContent = m.http2_requests || 0; $("metric-h2-active").textContent = m.http2_active_connections || 0; $("metric-tls-errors").textContent = m.tls_handshake_errors || 0;
|
||||
$("metric-requests").textContent = m.requests.toLocaleString(locale()); $("metric-active").textContent = m.active; $("metric-bytes").textContent = bytes(m.bytes_in + m.bytes_out); $("metric-bytes-down").textContent = bytes(m.bytes_out); $("metric-bytes-up").textContent = bytes(m.bytes_in); $("metric-errors").textContent = m.errors;
|
||||
const openConnections = Math.max(0, Number(m.active_connections) || 0), activeExchanges = Math.max(0, Number(m.active) || 0);
|
||||
connectionOpenSamples.push(openConnections); connectionOpenSamples = connectionOpenSamples.slice(-30);
|
||||
connectionExchangeSamples.push(activeExchanges); connectionExchangeSamples = connectionExchangeSamples.slice(-30);
|
||||
const connectionPeak = Math.max(1, ...connectionOpenSamples, ...connectionExchangeSamples);
|
||||
const connectionPoints = values => values.map((value, index) => `${index * 300 / Math.max(1, values.length - 1)},${95 - value / connectionPeak * 85}`).join(" ");
|
||||
$("connection-open-sparkline").setAttribute("points", connectionPoints(connectionOpenSamples));
|
||||
$("connection-exchange-sparkline").setAttribute("points", connectionPoints(connectionExchangeSamples));
|
||||
$("connection-current").textContent = fmt(openConnections); $("connection-open-value").textContent = fmt(openConnections); $("connection-exchange-value").textContent = fmt(activeExchanges); $("connection-backends").textContent = fmt(s.backends);
|
||||
const now = performance.now();
|
||||
const transformed = s.transform_metrics || {};
|
||||
const counters = { total: Number(m.requests) || 0, h1: Number(m.http1_requests) || 0, h2: Number(m.http2_requests) || 0, h3: Number(m.http3_requests) || 0, transform: (Number(transformed.applied) || 0) + (Number(transformed.cache_hits) || 0) };
|
||||
const rates = { total: 0, h1: 0, h2: 0, h3: 0, transform: 0 };
|
||||
if (previous) {
|
||||
const elapsed = Math.max(.001, (now - previous.time) / 1000);
|
||||
for (const key of ["total", "h1", "h2", "h3", "transform"]) if (counters[key] >= previous[key]) rates[key] = (counters[key] - previous[key]) / elapsed;
|
||||
}
|
||||
previous = { time: now, ...counters };
|
||||
samples.push(rates.total); samples = samples.slice(-30);
|
||||
for (const key of ["h1", "h2", "h3"]) { protocolSamples[key].push(rates[key]); protocolSamples[key] = protocolSamples[key].slice(-30); }
|
||||
websocketProtocolSamples.h1.push(Number(m.websocket_h1_active) || 0); websocketProtocolSamples.h1 = websocketProtocolSamples.h1.slice(-30);
|
||||
websocketProtocolSamples.h2.push(Number(m.websocket_h2_active) || 0); websocketProtocolSamples.h2 = websocketProtocolSamples.h2.slice(-30);
|
||||
websocketProtocolSamples.h3.push(Number(m.websocket_h3_active) || 0); websocketProtocolSamples.h3 = websocketProtocolSamples.h3.slice(-30);
|
||||
transformSamples.push(rates.transform); transformSamples = transformSamples.slice(-30);
|
||||
const peak = Math.max(1, ...samples, ...transformSamples);
|
||||
const points = values => values.map((n, i) => `${i * 600 / Math.max(1, values.length - 1)},${120 - n / peak * 105}`).join(" ");
|
||||
$("sparkline").setAttribute("points", points(samples));
|
||||
$("sparkline-h1").setAttribute("points", points(protocolSamples.h1));
|
||||
$("sparkline-h2").setAttribute("points", points(protocolSamples.h2));
|
||||
$("sparkline-h3").setAttribute("points", points(protocolSamples.h3));
|
||||
$("sparkline-transform").setAttribute("points", points(transformSamples));
|
||||
$("request-rate").textContent = `${rates.total.toFixed(1)} req/s`;
|
||||
$("rate-total").textContent = `${rates.total.toFixed(1)}/s`; $("rate-h1").textContent = `${rates.h1.toFixed(1)}/s`; $("rate-h2").textContent = `${rates.h2.toFixed(1)}/s`; $("rate-h3").textContent = `${rates.h3.toFixed(1)}/s`; $("rate-transform").textContent = `${rates.transform.toFixed(1)}/s`;
|
||||
$("metric-http-proto-total").textContent = `${rates.total.toFixed(1)}/s`;
|
||||
$("metric-http-proto-h1").textContent = `${rates.h1.toFixed(1)}/s`;
|
||||
$("metric-http-proto-h2").textContent = `${rates.h2.toFixed(1)}/s`;
|
||||
$("metric-http-proto-h3").textContent = `${rates.h3.toFixed(1)}/s`;
|
||||
$("http-protocol-h1").setAttribute("points", sparklinePoints(protocolSamples.h1, 300, 60, 5));
|
||||
$("http-protocol-h2").setAttribute("points", sparklinePoints(protocolSamples.h2, 300, 60, 5));
|
||||
$("http-protocol-h3").setAttribute("points", sparklinePoints(protocolSamples.h3, 300, 60, 5));
|
||||
const wsTotalActive = (Number(m.websocket_h1_active) || 0) + (Number(m.websocket_h2_active) || 0) + (Number(m.websocket_h3_active) || 0);
|
||||
$("metric-ws-proto-total").textContent = wsTotalActive.toLocaleString(locale());
|
||||
$("metric-ws-proto-h1").textContent = (Number(m.websocket_h1_active) || 0).toLocaleString(locale());
|
||||
$("metric-ws-proto-h2").textContent = (Number(m.websocket_h2_active) || 0).toLocaleString(locale());
|
||||
$("metric-ws-proto-h3").textContent = (Number(m.websocket_h3_active) || 0).toLocaleString(locale());
|
||||
$("ws-protocol-h1").setAttribute("points", sparklinePoints(websocketProtocolSamples.h1, 300, 60, 5));
|
||||
$("ws-protocol-h2").setAttribute("points", sparklinePoints(websocketProtocolSamples.h2, 300, 60, 5));
|
||||
$("ws-protocol-h3").setAttribute("points", sparklinePoints(websocketProtocolSamples.h3, 300, 60, 5));
|
||||
$("chart-peak").textContent = `max ${Math.max(0, ...samples).toFixed(1)} req/s`;
|
||||
renderFooter(s); renderDiagnostics(s); window.rustProxyVhosts?.renderStatus(s); window.rustProxyStreams?.renderStatus(s);
|
||||
}
|
||||
function renderFooter(s) {
|
||||
const apiLatency = lastApiLatencyMs == null ? "API -" : `API ${lastApiLatencyMs.toFixed(lastApiLatencyMs < 10 ? 1 : 0)} ms`;
|
||||
const footer = $("footer-status");
|
||||
footer.replaceChildren(document.createTextNode(`${apiLatency} / ${t("logsDropped")}: ${s.logs_dropped}`));
|
||||
const update = s.software_update;
|
||||
if (update?.update_available && update.latest_version) {
|
||||
footer.append(document.createTextNode(" / "));
|
||||
const label = `${t("updateAvailable")}: ${update.latest_version}`;
|
||||
if (update.repository_url) {
|
||||
const link = el("a", label, "footer-update");
|
||||
link.href = update.repository_url;
|
||||
link.target = "_blank";
|
||||
link.rel = "noopener noreferrer";
|
||||
footer.append(link);
|
||||
} else {
|
||||
footer.append(document.createTextNode(label));
|
||||
}
|
||||
}
|
||||
}
|
||||
let lastLogs = null;
|
||||
function renderLogs(data) { lastLogs = data; window.rustProxyLogs?.render(data); }
|
||||
async function refresh(force = false) { if (!authenticated || polling || (!force && liveConnected)) return; polling = true; const epoch = authEpoch; try { const [status, logs] = await Promise.all([api("/api/status"), api("/api/logs")]); if (!authenticated || epoch !== authEpoch) return; renderStatus(status); renderLogs(logs); } catch (e) { connected = false; $("connection-label").textContent = t("disconnected"); if (e.status === 401) lock(); } finally { polling = false; } }
|
||||
function stopLive() { if (liveEvents) liveEvents.close(); liveEvents = null; liveConnected = false; }
|
||||
function startLive() { stopLive(); if (!authenticated || document.hidden || typeof EventSource === "undefined") return; const epoch = authEpoch; const stream = new EventSource("/api/events"); liveEvents = stream; stream.addEventListener("snapshot", event => { if (!authenticated || epoch !== authEpoch) return; try { const payload = JSON.parse(event.data); liveConnected = true; renderStatus(payload.status); renderLogs(payload.logs); } catch { liveConnected = false; } }); stream.onerror = () => { if (epoch !== authEpoch) return; liveConnected = false; void refresh(); }; }
|
||||
async function refreshCertificateInventory() {
|
||||
if (!authenticated) return;
|
||||
const epoch = authEpoch;
|
||||
try {
|
||||
const value = await api("/api/certificates");
|
||||
if (!authenticated || epoch !== authEpoch) return;
|
||||
certificateInventory = new Map((value.certificates || []).map(cert => [cert.id, cert]));
|
||||
} catch (e) {
|
||||
if (e.status !== 401 && authenticated && epoch === authEpoch) certificateInventory = new Map();
|
||||
}
|
||||
}
|
||||
async function loadConfig() { const epoch = authEpoch; const value = await api("/api/config"); if (!authenticated || epoch !== authEpoch) return; envelope = value; await refreshCertificateInventory(); if (!authenticated || epoch !== authEpoch) return; renderConfig(); }
|
||||
function rulesText(acl) { return (acl?.rules || []).map(r => `${r.action} ${r.network}`).join("\n"); }
|
||||
function normalizeAclRuleText(value) {
|
||||
return String(value ?? "").normalize("NFKC")
|
||||
.replace(/[\u200B-\u200D\u2060\uFEFF]/g, "")
|
||||
.replace(/[\u00A0\u1680\u2000-\u200A\u202F\u205F\u3000]/g, " ");
|
||||
}
|
||||
function parseRules(text, target = null) {
|
||||
return normalizeAclRuleText(text).split(/\r?\n/).map((value, index) => ({ line: value.trim(), number: index + 1 })).filter(item => item.line).map(item => {
|
||||
const match = item.line.match(/^(allow|deny)\s+(\S+)$/i);
|
||||
if (!match) throw fieldError(target, `${t("ruleSyntaxLine")} ${item.number}: ${item.line}`);
|
||||
return { action: match[1].toLowerCase(), network: match[2] };
|
||||
});
|
||||
}
|
||||
function renderConfig() {
|
||||
if (!envelope) return; window.rustProxyCachePanel?.configure(); renderCertificates(); renderSettings(); window.rustProxyPages?.render(); window.rustProxyAclProfiles?.render(); window.rustProxyVhosts?.render(); window.rustProxyWebServer?.render(); window.rustProxyDocker?.render(); window.rustProxyRedirects?.render(); window.rustProxyNotFound?.render(); window.rustProxyStreams?.render(); const config = envelope.config; if (!settingsDirty.has("config-editor")) $("config-editor").value = JSON.stringify(config, null, 2);
|
||||
$("host-policy-list").replaceChildren(...(config.host_policies || []).map((policy, index) => { const box = el("div", undefined, "entity"); box.append(el("span", policy.hosts.join(", "), "entity-title"), el("p", `${t("aclDefault")}: ${t(policy.acl.default || "deny")}\n${rulesText(policy.acl)}`, "entity-details")); const buttons = el("div", undefined, "entity-actions"); buttons.append(action(t("edit"), () => { $("policy-hosts").value = policy.hosts.join(", "); $("policy-default").value = policy.acl.default || "deny"; $("policy-rules").value = rulesText(policy.acl); const editor = $("security-policy-editor"); if (editor) { editor.open = true; editor.scrollIntoView({ block: "nearest" }); } }), action(t("remove"), async () => { if (await appConfirm(t("confirmDelete"), { danger: true })) { const next = structuredClone(config); next.host_policies.splice(index, 1); await saveSection("host-policies", next.host_policies); } })); box.append(buttons); return box; }));
|
||||
if (!config.host_policies?.length) $("host-policy-list").append(el("p", t("noPolicies"), "empty"));
|
||||
if ($("security-policy-count")) $("security-policy-count").textContent = String((config.host_policies || []).length);
|
||||
}
|
||||
async function saveRequest(path, payload) { if (busy) { toast(t("busy"), true); return false; } if (!authenticated || !envelope) return false; busy = true; const epoch = authEpoch; try { const value = await api(path, { method: "PUT", body: JSON.stringify(payload) }); if (!authenticated || epoch !== authEpoch) return false; envelope = value; if (path === "/api/config" || path === "/api/config/tls") await refreshCertificateInventory(); if (!authenticated || epoch !== authEpoch || !envelope) return false; renderConfig(); toast(t("saved")); await refresh(true); return true; } catch (e) { toast(e.message, true); if (e.status === 401) lock(); return false; } finally { busy = false; } }
|
||||
async function save(config) { return saveRequest("/api/config", { expected_revision: envelope?.revision, config }); }
|
||||
async function saveSection(section, value) { return saveRequest(`/api/config/${section}`, { expected_revision: envelope?.revision, value }); }
|
||||
function syncAuthForm() { $("setup-fields").hidden = !setupRequired; $("confirm-field").hidden = !setupRequired; $("setup-token").required = setupRequired; $("password-confirm").required = setupRequired; $("password").minLength = setupRequired ? 12 : 1; $("password").autocomplete = setupRequired ? "new-password" : "current-password"; $("auth-title").textContent = t(setupRequired ? "setupTitle" : "signIn"); $("login-submit").textContent = t(setupRequired ? "createAndSignIn" : "signIn"); $("auth-badge").textContent = setupRequired ? "SETUP" : "LOCAL"; }
|
||||
function renderSession() { $("session-user").textContent = sessionInfo?.username || ""; $("session-expiry").textContent = sessionInfo ? t("expires") + new Date(sessionInfo.expires_at * 1000).toLocaleString(locale()) : ""; }
|
||||
function authReturnDestination() {
|
||||
if (typeof location === "undefined") return "/";
|
||||
if (location.pathname === "/logging") return "/settings?tab=logging";
|
||||
const allowed = new Set([...Object.values(PANEL_PATHS), "/configuration"]);
|
||||
const path = allowed.has(location.pathname) ? location.pathname : "/";
|
||||
if (path !== "/settings") return path;
|
||||
const tab = window.rustProxySettings?.current?.() || new URLSearchParams(location.search).get("tab");
|
||||
return tab ? `/settings?tab=${encodeURIComponent(tab)}` : "/settings";
|
||||
}
|
||||
function lock(notify = true) {
|
||||
const destination = pendingAuthDestination || authReturnDestination(), navigating = typeof location !== "undefined" && (location.protocol === "http:" || location.protocol === "https:");
|
||||
pendingAuthDestination = destination; if (notify) adminAuthChannel?.postMessage("locked"); $("api-docs").hidden = true; resetAdminPreferences(); window.rustProxyCertificates?.reset(); window.rustProxySettings?.reset(); window.rustProxyTrafficPanel?.reset(); window.rustProxyCachePanel?.reset(); window.rustProxyTransformPanel?.reset(); window.rustProxyAcme?.reset(); window.rustProxyIntegrations?.reset(); window.rustProxyLogs?.reset(); $("logs-open").hidden = true; window.rustProxyAclProfiles?.reset(); window.rustProxyVhosts?.reset(); window.rustProxyWebServer?.reset(); window.rustProxyDocker?.reset(); window.rustProxyRedirects?.reset(); window.rustProxyNotFound?.reset(); window.rustProxyStreams?.reset(); authEpoch++; stopLive(); csrf = ""; authenticated = false; sessionInfo = null; envelope = null; certificateInventory = new Map(); previous = null; samples = []; protocolSamples = { h1: [], h2: [], h3: [] }; transformSamples = []; systemSamples = []; connectionOpenSamples = []; connectionExchangeSamples = []; networkInterfaceSamples.clear(); lastLogs = null; latestStatus = null; clearSettingsDirty(); connected = false; busy = false; $("workspace").hidden = true; $("login").hidden = navigating; $("logout").hidden = true; $("password").value = ""; $("password-confirm").value = ""; $("setup-token").value = ""; $("password-form").reset(); $("config-editor").value = ""; $("logs").replaceChildren(); $("audit-rows").replaceChildren(); authMessage("");
|
||||
for (const id of ["certificate-list", "host-policy-list"]) { $(id)?.replaceChildren(); }
|
||||
$("workspace").querySelectorAll("form").forEach(form => form.reset());
|
||||
$("workspace").querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => { input.value = ""; });
|
||||
document.querySelectorAll("dialog").forEach(dialog => { dialog.close(); dialog.querySelectorAll("input:not([type=checkbox]):not([type=radio]),textarea").forEach(input => input.value = ""); });
|
||||
renderSession(); syncAuthForm();
|
||||
// Replace the authenticated document with the minimal public login document.
|
||||
// Keep the exact panel/settings tab so re-authentication resumes in place.
|
||||
if (navigating) { document.documentElement.classList.add("app-loading"); location.replace(destination); }
|
||||
}
|
||||
|
||||
async function activate(info) { pendingAuthDestination = null; authMessage(""); $("api-docs").hidden = false; $("logs-open").hidden = false; authEpoch++; sessionInfo = info; csrf = info.csrf_token; authenticated = true; const preferences = await loadAdminPreferences(); window.rustProxyLogs?.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 [new Date(item.time * 1000).toLocaleString(locale()), 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); }
|
||||
async function loadTunnels() {
|
||||
if (!authenticated) return;
|
||||
const epoch = authEpoch;
|
||||
try {
|
||||
const data = await api("/api/tunnels");
|
||||
if (!authenticated || epoch !== authEpoch) return;
|
||||
const rows = (data.tunnels || []).map(item => {
|
||||
const tr = el("tr");
|
||||
for (const value of [item.id, item.client, item.host, item.upstream, item.protocol, `${fmt((Number(item.duration_ms) || 0) / 1000, 1)} s`, fmtBytes(item.rx_bytes), fmtBytes(item.tx_bytes)]) {
|
||||
const td = el("td", String(value)); td.title = String(value); tr.append(td);
|
||||
}
|
||||
const actions = el("td");
|
||||
const kill = el("button", t("tunnelKill"), "quiet"); kill.type = "button";
|
||||
kill.addEventListener("click", async () => {
|
||||
if (!(await appConfirm(t("tunnelKillConfirm"), { danger: true }))) return;
|
||||
kill.disabled = true; tunnelMessage();
|
||||
try {
|
||||
const reason = $("tunnel-kill-reason")?.value.trim() || t("tunnelKillReasonDefault");
|
||||
await api(`/api/tunnels/${encodeURIComponent(item.id)}/kill`, { method: "POST", body: JSON.stringify({ reason }) });
|
||||
tunnelMessage(t("tunnelKilled"));
|
||||
await loadTunnels();
|
||||
} catch (error) { tunnelMessage(error.message, true); }
|
||||
finally { kill.disabled = false; }
|
||||
});
|
||||
actions.append(kill); tr.append(actions); return tr;
|
||||
});
|
||||
$("tunnels-rows").replaceChildren(...rows); $("tunnels-empty").hidden = rows.length > 0;
|
||||
} catch (error) { if (authenticated && epoch === authEpoch) tunnelMessage(error.message, true); }
|
||||
}
|
||||
function stopTunnelRefresh() { if (tunnelRefreshTimer) clearInterval(tunnelRefreshTimer); tunnelRefreshTimer = null; }
|
||||
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); } });
|
||||
$("language").addEventListener("change", () => { language = $("language").value; try { localStorage.setItem("rp-language", language); } catch { } translate(); });
|
||||
$("ui-theme").addEventListener("change", async () => {
|
||||
const select = $("ui-theme"), previousTheme = adminPreferences?.theme || document.documentElement.dataset.theme || "dark", nextTheme = normalizeAdminTheme(select.value);
|
||||
applyAdminTheme(nextTheme); select.disabled = true;
|
||||
try { await updateAdminPreferences({ theme: nextTheme }); }
|
||||
catch (error) { applyAdminTheme(previousTheme); if (error.status !== 401) toast(error.message, true); }
|
||||
finally { select.disabled = false; }
|
||||
});
|
||||
document.querySelectorAll(".nav").forEach(button => button.addEventListener("click", () => setPanel(button.dataset.panel)));
|
||||
window.addEventListener("popstate", () => setPanel(panelFromLocation(), false));
|
||||
$("host-policy-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { const hosts = [...new Set($("policy-hosts").value.split(",").map(h => h.trim().toLowerCase()).filter(Boolean))]; const acl = { default: $("policy-default").value, rules: parseRules($("policy-rules").value, $("policy-rules")) }; const next = structuredClone(envelope.config); next.host_policies = (next.host_policies || []).map(p => ({ ...p, hosts: p.hosts.filter(h => !hosts.includes(h)) })).filter(p => p.hosts.length); next.host_policies.push({ hosts, acl }); if (await saveSection("host-policies", next.host_policies)) { form.reset(); if ($("security-policy-editor")) $("security-policy-editor").open = false; } }); } catch (e) { toast(e.message, true); } });
|
||||
$("password-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; try { await withFormSubmitState(form, event.submitter, async () => { if ($("new-password").value !== $("new-password-confirm").value) throw fieldError("new-password-confirm", t("passwordMismatch")); await api("/api/auth/password", { method: "POST", body: JSON.stringify({ current_password: $("current-password").value, new_password: $("new-password").value }) }); lock(); toast(t("passwordChanged")); }); } catch (e) { toast(e.message, true); } });
|
||||
$("revoke-sessions").addEventListener("click", async () => { if (!(await appConfirm(t("confirmRevoke"), { danger: true }))) return; try { await api("/api/auth/logout-all", { method: "POST" }); lock(); toast(t("locked")); } catch (e) { toast(e.message, true); } });
|
||||
$("security-audit-open").addEventListener("click", () => { const dialog = $("audit-dialog"); if (!dialog.open) dialog.showModal(); void loadAudit(); });
|
||||
$("audit-dialog-close").addEventListener("click", () => $("audit-dialog").close());
|
||||
$("reload-audit").addEventListener("click", () => void loadAudit());
|
||||
$("tunnels-open").addEventListener("click", () => { const dialog = $("tunnels-dialog"); tunnelMessage(); if (!dialog.open) dialog.showModal(); void loadTunnels(); startTunnelRefresh(); });
|
||||
$("tunnels-close").addEventListener("click", () => { stopTunnelRefresh(); $("tunnels-dialog").close(); });
|
||||
$("tunnels-reload").addEventListener("click", () => void loadTunnels());
|
||||
$("tunnels-dialog").addEventListener("close", stopTunnelRefresh);
|
||||
$("config-editor").addEventListener("input", () => setSettingsDirty("config-editor"));
|
||||
$("save-json").addEventListener("click", async event => { const editor = $("config-editor"), page = editor.closest("[data-settings-page]"); if (!envelope || busy) return; try { await withFormSubmitState(page, event.currentTarget, async () => { let config; try { config = JSON.parse(editor.value); } catch { throw fieldError(editor, t("vhJsonInvalid")); } if (await save(config)) setSettingsDirty("config-editor", false); }); } catch (e) { toast(e.message, true); } });
|
||||
$("export-config").addEventListener("click", () => { const url = URL.createObjectURL(new Blob([JSON.stringify(envelope.config, null, 2) + "\n"], { type: "application/json" })); const a = el("a"); a.href = url; a.download = `RustProxy-config-r${envelope.revision}.json`; a.click(); setTimeout(() => URL.revokeObjectURL(url), 1000); });
|
||||
async function revalidateSession() {
|
||||
const epoch = authEpoch;
|
||||
try {
|
||||
const info = await api("/api/auth/session");
|
||||
if (epoch !== authEpoch) return;
|
||||
if (!authenticated) { await activate(info); return; }
|
||||
sessionInfo = info; csrf = info.csrf_token; renderSession(); await refresh(true); startLive();
|
||||
} catch (error) { if (error.status !== 401) toast(error.message, true); }
|
||||
}
|
||||
window.addEventListener("pageshow", event => { if (event.persisted) void revalidateSession(); });
|
||||
document.addEventListener("visibilitychange", () => { if (document.hidden) stopLive(); else if (authenticated) { void revalidateSession(); } });
|
||||
setInterval(() => { if (!document.hidden) void refresh(); }, 5000);
|
||||
function showStartupFailure(error) {
|
||||
// index.html is served only after the server has accepted the browser session.
|
||||
// A module/configuration error must therefore never masquerade as a logout.
|
||||
$("login").hidden = true;
|
||||
$("workspace").hidden = false;
|
||||
$("logout").hidden = !authenticated;
|
||||
document.documentElement.classList.remove("app-loading");
|
||||
toast(error?.message || String(error), true);
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
(async () => {
|
||||
try {
|
||||
// Revalidate the session first. The anonymous document has its own login.js;
|
||||
// app.js belongs to the authenticated shell and must not fall back to its
|
||||
// legacy embedded sign-in form when another startup step fails.
|
||||
const info = await api("/api/auth/session");
|
||||
await loadLanguages();
|
||||
setupRequired = false;
|
||||
translate();
|
||||
syncAuthForm();
|
||||
await activate(info);
|
||||
} catch (e) {
|
||||
if (e.status === 401) {
|
||||
location.replace(`${location.pathname}${location.search}`);
|
||||
return;
|
||||
}
|
||||
showStartupFailure(e);
|
||||
}
|
||||
})();
|
||||
|
||||
function renderCertificates() {
|
||||
if (!envelope) return;
|
||||
const settings = envelope.config.tls || {};
|
||||
const entries = (settings.certificates || []).map(cert => {
|
||||
const inventory = certificateInventory.get(cert.id);
|
||||
const coverage = (inventory?.covered_hosts?.length ? inventory.covered_hosts : (cert.covered_hosts || []));
|
||||
const bindings = cert.hosts || [];
|
||||
const used = [];
|
||||
for (const vhost of envelope.config.vhosts || []) if (vhost.certificate_id === cert.id) used.push(`vhost:${vhost.id}`);
|
||||
for (const redirect of envelope.config.redirects || []) if (redirect.certificate_id === cert.id) used.push(`redirect:${redirect.id}`);
|
||||
for (const host of envelope.config.not_found_hosts || []) if (host.certificate_id === cert.id) used.push(`404:${host.id}`);
|
||||
if (envelope.config.administration?.certificate_id === cert.id) used.push(t("administration"));
|
||||
if (settings.default_certificate === cert.id) used.push(t("defaultLabel"));
|
||||
const managed = !!cert.issuer, inUse = used.length > 0;
|
||||
const box = el("div", undefined, "entity certificate-card");
|
||||
|
||||
const head = el("div", undefined, "certificate-card-head");
|
||||
const identity = el("div", undefined, "certificate-card-identity");
|
||||
identity.append(el("span", cert.id, "entity-title"));
|
||||
const statePills = el("div", undefined, "vhost-policy-pills certificate-state-pills");
|
||||
if (managed) statePills.append(el("small", `${t("certificateManaged")} · ${cert.issuer.provider} / ${cert.issuer.environment}`, "pill"));
|
||||
if (settings.default_certificate === cert.id) statePills.append(el("small", t("defaultLabel"), "pill"));
|
||||
if (inventory?.expiry_status === "expired") statePills.append(el("small", t("certificateExpired"), "pill status-error"));
|
||||
else if (inventory?.expiry_status === "warning") statePills.append(el("small", t("certificateExpiresSoon"), "pill status-error"));
|
||||
if (statePills.children.length) identity.append(statePills);
|
||||
head.append(identity);
|
||||
box.append(head);
|
||||
|
||||
const coverageBlock = el("div", undefined, "certificate-coverage");
|
||||
coverageBlock.append(el("span", t("certificateCovers"), "small-label"));
|
||||
const domainPills = el("div", undefined, "vhost-policy-pills certificate-domain-pills");
|
||||
if (coverage.length) {
|
||||
for (const host of coverage) domainPills.append(el("small", host, "pill"));
|
||||
} else {
|
||||
domainPills.append(el("small", t("certificateCoverageUnknown"), "certificate-coverage-unknown"));
|
||||
}
|
||||
coverageBlock.append(domainPills);
|
||||
box.append(coverageBlock);
|
||||
|
||||
const details = el("details", undefined, "certificate-details");
|
||||
details.append(el("summary", t("certificateDetails")));
|
||||
const detailBody = el("div", undefined, "certificate-details-body");
|
||||
const stamp = value => Number(value) ? new Date(Number(value) * 1000).toLocaleString(locale()) : "-";
|
||||
detailBody.append(
|
||||
el("p", `${t("certificateBindings")}: ${bindings.join(", ") || "-"}`, "entity-details"),
|
||||
el("p", `${t("certificateFiles")}: ${cert.cert_file} / ${cert.key_file}`, "entity-details"),
|
||||
el("p", `${t("certAssigned")}: ${used.join(", ") || t("certUnassigned")}`, "entity-details"),
|
||||
el("p", `${t("certificateIssuerName")}: ${inventory?.certificate_issuer || "-"}`, "entity-details"),
|
||||
el("p", `${t("certificateValidFrom")}: ${stamp(inventory?.not_before_unix)}`, "entity-details"),
|
||||
el("p", `${t("renewalExpires")}: ${stamp(inventory?.not_after_unix)}`, "entity-details"),
|
||||
el("p", `${t("certificateDaysRemaining")}: ${inventory?.days_remaining ?? "-"}`, "entity-details"),
|
||||
el("p", `${t("certificateRenewalStatus")}: ${inventory?.renewal_status || "-"}`, "entity-details"),
|
||||
el("p", `${t("renewalLast")}: ${stamp(inventory?.last_renewal_attempt)}`, "entity-details"),
|
||||
el("p", `${t("certificateRenewalError")}: ${inventory?.last_renewal_error ? t(inventory.last_renewal_error) : "-"}`, "entity-details"),
|
||||
);
|
||||
details.append(detailBody);
|
||||
box.append(details);
|
||||
|
||||
const buttons = el("div", undefined, "entity-actions");
|
||||
if (!managed) {
|
||||
buttons.append(action(t("reloadCertificate"), async () => {
|
||||
if (busy) return;
|
||||
try {
|
||||
busy = true;
|
||||
const result = await api(`/api/certificates/${encodeURIComponent(cert.id)}/reload`, { method: "POST", body: "{}" });
|
||||
await refreshCertificateInventory();
|
||||
renderCertificates();
|
||||
toast(`${t("reloadCertificate")}: ${result.reloaded ? "OK" : "-"}`);
|
||||
if (result.administration_restart_required) toast(t("certificateAdminRestartRequired"), true);
|
||||
} catch (error) { toast(error.message, true); } finally { busy = false; }
|
||||
}));
|
||||
buttons.append(action(t("edit"), () => { window.rustProxyCertificates?.open("existing"); $("certificate-advanced").open = true; $("certificate-id").value = cert.id; $("certificate-hosts").value = cert.hosts.join(", "); $("certificate-file").value = cert.cert_file; $("certificate-key").value = cert.key_file; $("certificate-default").checked = settings.default_certificate === cert.id; }));
|
||||
}
|
||||
const remove = action(t("remove"), async () => { if (inUse) { toast(t("certInUse"), true); return; } if (!(await appConfirm(t("confirmDelete"), { danger: true }))) return; const next = structuredClone(envelope.config); next.tls.certificates = next.tls.certificates.filter(v => v.id !== cert.id); await saveSection("tls", next.tls); });
|
||||
remove.disabled = inUse; remove.title = inUse ? t("certInUse") : ""; buttons.append(remove);
|
||||
box.append(buttons);
|
||||
return box;
|
||||
});
|
||||
$("certificate-list").replaceChildren(...entries);
|
||||
if (!entries.length) $("certificate-list").append(el("p", t("emptyCertificates"), "empty"));
|
||||
}
|
||||
$("certificate-form").addEventListener("submit", async event => { event.preventDefault(); const form = event.currentTarget; if (!envelope || busy) return; try { await withFormSubmitState(form, event.submitter, async () => { const next = structuredClone(envelope.config); next.tls = next.tls || {}; next.tls.certificates = next.tls.certificates || []; const cert = { id: $("certificate-id").value.trim(), hosts: $("certificate-hosts").value.split(",").map(v => v.trim()).filter(Boolean), cert_file: $("certificate-file").value.trim(), key_file: $("certificate-key").value.trim() }; const index = next.tls.certificates.findIndex(v => v.id === cert.id); if (index >= 0 && next.tls.certificates[index].issuer) throw fieldError($("certificate-id"), t("certManagedReadOnly")); if (index < 0) next.tls.certificates.push(cert); else { const old = next.tls.certificates[index]; const sameFiles = old.cert_file === cert.cert_file && old.key_file === cert.key_file; next.tls.certificates[index] = { ...old, ...cert, covered_hosts: sameFiles ? (old.covered_hosts || []) : [], issuer: sameFiles ? old.issuer : null }; } if ($("certificate-default").checked) next.tls.default_certificate = cert.id; else if (next.tls.default_certificate === cert.id) delete next.tls.default_certificate; if (await saveSection("tls", next.tls)) { form.reset(); window.rustProxyCertificates?.close(); } }); } catch (error) { toast(error.message, true); } });
|
||||
$("reload-certificates").addEventListener("click", async () => {
|
||||
if (!envelope || busy) return;
|
||||
try {
|
||||
busy = true;
|
||||
const result = await api("/api/certificates/reload", { method: "POST", body: "{}" });
|
||||
await refreshCertificateInventory();
|
||||
renderCertificates();
|
||||
toast(`${t("reloadCertificates")}: ${result.reloaded ? "OK" : "-"}`);
|
||||
if (result.administration_restart_required) toast(t("certificateAdminRestartRequired"), true);
|
||||
} catch (error) { toast(error.message, true); }
|
||||
finally { busy = false; }
|
||||
});
|
||||
$("reset-certificate").addEventListener("click", () => $("certificate-form").reset());
|
||||
|
||||
// Dashboard metrics are actual server samples. Null means unavailable, not zero.
|
||||
let systemSamples = [], connectionOpenSamples = [], connectionExchangeSamples = [], cacheMemorySamples = [], cacheTransientSamples = [], cacheFillSamples = [], cacheColdQueueSamples = [], cacheColdIoSamples = [], cacheColdStorageSamples = [], cacheHitSamples = [], cacheMissSamples = [], cacheBypassSamples = [], lastCacheCounters = null,
|
||||
processCpuSamples = [], processRssSamples = [], networkRxSamples = [], networkTxSamples = [], responseLatencySamples = [], networkInterfaceSamples = new Map(), settingsDirty = new Set();
|
||||
const hiddenNetworkInterfaces = (() => {
|
||||
try {
|
||||
const parsed = JSON.parse(localStorage.getItem("rp-hidden-network-interfaces") || "[]");
|
||||
return new Set(Array.isArray(parsed) ? parsed.filter(value => typeof value === "string") : []);
|
||||
} catch { return new Set(); }
|
||||
})();
|
||||
function saveHiddenNetworkInterfaces() {
|
||||
try { localStorage.setItem("rp-hidden-network-interfaces", JSON.stringify([...hiddenNetworkInterfaces].sort())); } catch { }
|
||||
}
|
||||
function syncSettingsDirtyIndicators() {
|
||||
const dirtyTabs = new Set();
|
||||
for (const id of settingsDirty) {
|
||||
const page = $(id)?.closest?.("[data-settings-page]"); if (page?.dataset.settingsPage) dirtyTabs.add(page.dataset.settingsPage);
|
||||
}
|
||||
document.querySelectorAll("[data-settings-tab]").forEach(button => {
|
||||
const dirty = dirtyTabs.has(button.dataset.settingsTab); button.classList.toggle("has-unsaved", dirty);
|
||||
if (dirty) button.title = t("unsavedChanges"); else button.removeAttribute("title");
|
||||
});
|
||||
window.rustProxyIntegrations?.syncHttp2Availability?.();
|
||||
window.rustProxyIntegrations?.syncHttp3Availability?.();
|
||||
}
|
||||
function setSettingsDirty(id, dirty = true) { if (dirty) settingsDirty.add(id); else settingsDirty.delete(id); syncSettingsDirtyIndicators(); }
|
||||
function clearSettingsDirty() { settingsDirty.clear(); syncSettingsDirtyIndicators(); }
|
||||
const fmt = (value, digits = 0) => value == null ? t("unavailable") : Number(value).toLocaleString(locale(), { maximumFractionDigits: digits });
|
||||
const fmtBytes = value => value == null ? t("unavailable") : bytes(value);
|
||||
function setOptionalMetric(id, supported, text) {
|
||||
const node = $(id); if (!node) return;
|
||||
const row = node.closest("div"); if (row) row.hidden = !supported;
|
||||
if (supported) node.textContent = text;
|
||||
}
|
||||
function knownPair(left, right, format, zeroRight = null) {
|
||||
if (left == null && right == null) return null;
|
||||
if (left == null) return format(right);
|
||||
if (right == null) return format(left);
|
||||
const rightText = right === 0 && zeroRight ? zeroRight : format(right);
|
||||
return `${format(left)} / ${rightText}`;
|
||||
}
|
||||
function occupancyText(used, limit) {
|
||||
if (used == null) return "-";
|
||||
const usedNumber = Number(used), limitNumber = Number(limit);
|
||||
if (!Number.isFinite(limitNumber) || limitNumber <= 0) return fmtBytes(usedNumber);
|
||||
return `${fmtBytes(usedNumber)} / ${fmtBytes(limitNumber)} · ${fmt(usedNumber / limitNumber * 100, 1)}%`;
|
||||
}
|
||||
function sparklinePoints(values, width = 300, height = 60, padding = 5, peakOverride = null) {
|
||||
const peak = Math.max(1, peakOverride ?? Math.max(0, ...values));
|
||||
return values.map((value, index) => `${index * width / Math.max(1, values.length - 1)},${height - padding - Math.max(0, value) / peak * (height - padding * 2)}`).join(" ");
|
||||
}
|
||||
function svgNode(tag, attrs = {}) {
|
||||
const node = document.createElementNS("http://www.w3.org/2000/svg", tag);
|
||||
for (const [name, value] of Object.entries(attrs)) node.setAttribute(name, value);
|
||||
return node;
|
||||
}
|
||||
function renderNetworkInterfaceCharts(interfaces, appendSample) {
|
||||
const current = Array.isArray(interfaces) ? interfaces.slice().sort((a, b) => String(a.name || "").localeCompare(String(b.name || ""))) : [];
|
||||
const names = new Set(current.map(item => item.name));
|
||||
for (const name of [...networkInterfaceSamples.keys()]) if (!names.has(name)) networkInterfaceSamples.delete(name);
|
||||
if (appendSample) {
|
||||
for (const item of current) {
|
||||
if (!item?.name) continue;
|
||||
const history = networkInterfaceSamples.get(item.name) || { rx: [], tx: [] };
|
||||
const rx = Number(item.rx_bytes_per_second), tx = Number(item.tx_bytes_per_second);
|
||||
if (Number.isFinite(rx)) { history.rx.push(rx); history.rx = history.rx.slice(-60); }
|
||||
if (Number.isFinite(tx)) { history.tx.push(tx); history.tx = history.tx.slice(-60); }
|
||||
networkInterfaceSamples.set(item.name, history);
|
||||
}
|
||||
}
|
||||
const target = $("network-interface-charts");
|
||||
if (!target) return;
|
||||
target.replaceChildren();
|
||||
const visible = current.filter(item => item?.name && !hiddenNetworkInterfaces.has(item.name));
|
||||
$("network-interface-chart-value").textContent = t("networkInterfacesVisibleCount").replace("{visible}", visible.length).replace("{total}", current.length);
|
||||
if (!current.length) { target.append(el("p", t("networkNoInterfaces"), "empty network-interface-empty")); return; }
|
||||
for (const item of visible) {
|
||||
const history = networkInterfaceSamples.get(item.name) || { rx: [], tx: [] };
|
||||
const peak = Math.max(1, ...history.rx, ...history.tx);
|
||||
const row = el("div", undefined, "network-interface-row");
|
||||
const head = el("div", undefined, "mini-live-chart-head network-interface-row-head");
|
||||
const rate = el("span", `${t("networkInterfaceRate")}: ${fmtBytes(item.rx_bytes_per_second)} / ${fmtBytes(item.tx_bytes_per_second)}`, "fine");
|
||||
const hide = action(t("hideMetric"), () => { hiddenNetworkInterfaces.add(item.name); saveHiddenNetworkInterfaces(); renderNetworkInterfaceCharts(current, false); });
|
||||
hide.classList.add("network-interface-hide");
|
||||
const actions = el("span", undefined, "network-interface-head-actions"); actions.append(rate, hide);
|
||||
head.append(el("b", item.name || "-", "mono"), actions);
|
||||
const svg = svgNode("svg", { viewBox: "0 0 300 60", role: "img", "aria-label": `${item.name} ${t("networkInterfacesHistory")}` });
|
||||
svg.append(svgNode("path", { class: "chart-grid", d: "M0 15H300 M0 30H300 M0 45H300" }));
|
||||
svg.append(svgNode("polyline", { class: "diagnostic-line network-rx-line", points: sparklinePoints(history.rx, 300, 60, 5, peak) || "0,55 300,55" }));
|
||||
svg.append(svgNode("polyline", { class: "diagnostic-line network-tx-line", points: sparklinePoints(history.tx, 300, 60, 5, peak) || "0,55 300,55" }));
|
||||
row.append(head, svg); target.append(row);
|
||||
}
|
||||
const hidden = current.filter(item => item?.name && hiddenNetworkInterfaces.has(item.name));
|
||||
if (hidden.length) {
|
||||
const hiddenRow = el("div", undefined, "network-interface-hidden");
|
||||
hiddenRow.append(el("span", t("hiddenInterfaces"), "fine"));
|
||||
for (const item of hidden) {
|
||||
const show = action(`${t("showMetric")} ${item.name}`, () => { hiddenNetworkInterfaces.delete(item.name); saveHiddenNetworkInterfaces(); renderNetworkInterfaceCharts(current, false); });
|
||||
hiddenRow.append(show);
|
||||
}
|
||||
target.append(hiddenRow);
|
||||
} else if (!visible.length) {
|
||||
target.append(el("p", t("networkNoVisibleInterfaces"), "empty network-interface-empty"));
|
||||
}
|
||||
}
|
||||
function renderDiagnostics(status, appendSample = true) {
|
||||
const d = status.system || {}, c = status.cache || {}, m = status.metrics || {};
|
||||
$("metric-h3").textContent = fmt(m.http3_requests);
|
||||
$("metric-cache-ratio").textContent = c.hit_ratio == null ? "-" : `${fmt(c.hit_ratio * 100, 1)}%`;
|
||||
$("cache-summary").textContent = `${fmt(c.hits)} HIT / ${fmt(c.misses)} MISS`;
|
||||
$("metric-rss").closest("article.stat").hidden = d.process_rss_bytes == null;
|
||||
if (d.process_rss_bytes != null) $("metric-rss").textContent = fmtBytes(d.process_rss_bytes);
|
||||
$("process-summary").hidden = d.process_threads == null;
|
||||
if (d.process_threads != null) $("process-summary").textContent = `${fmt(d.process_threads)} ${t("threads")}`;
|
||||
const systemCpuSupported = d.cpu_percent != null;
|
||||
setOptionalMetric("system-cpu", systemCpuSupported, systemCpuSupported ? `${fmt(d.cpu_percent, 1)}%` : "");
|
||||
$("system-chart").hidden = !systemCpuSupported;
|
||||
const sourceSupported = !!d.source && d.source !== "unsupported";
|
||||
$("system-source").hidden = !sourceSupported;
|
||||
if (sourceSupported) $("system-source").textContent = t("realSample");
|
||||
if (appendSample && d.cpu_percent != null) { systemSamples.push(d.cpu_percent); systemSamples = systemSamples.slice(-30); }
|
||||
$("system-sparkline").setAttribute("points", systemSamples.map((n, i) => `${i * 300 / Math.max(1, systemSamples.length - 1)},${95 - Math.max(0, Math.min(100, n)) * .9}`).join(" "));
|
||||
const systemMemory = knownPair(d.memory_used_bytes, d.memory_total_bytes, value => fmtBytes(value));
|
||||
setOptionalMetric("system-memory", systemMemory != null, systemMemory);
|
||||
$("memory-meter").hidden = systemMemory == null;
|
||||
if (d.memory_used_bytes != null && d.memory_total_bytes > 0) $("memory-meter").value = d.memory_used_bytes / d.memory_total_bytes * 100; else $("memory-meter").removeAttribute("value");
|
||||
const systemSwap = knownPair(d.system_swap_used_bytes, d.system_swap_total_bytes, value => fmtBytes(value));
|
||||
setOptionalMetric("system-swap", systemSwap != null, systemSwap);
|
||||
const loadSupported = Array.isArray(d.load_average) && d.load_average.length > 0;
|
||||
setOptionalMetric("system-load", loadSupported, loadSupported ? d.load_average.map(n => fmt(n, 2)).join(" / ") : "");
|
||||
setOptionalMetric("system-uptime", d.system_uptime_seconds != null, d.system_uptime_seconds == null ? "" : uptime(d.system_uptime_seconds));
|
||||
$("cache-state").textContent = t(envelope?.config.cache?.enabled ? "enabled" : "notEnabled");
|
||||
$("cache-counters").textContent = [c.hits, c.misses, c.bypasses].map(n => fmt(n)).join(" / ");
|
||||
$("cache-objects").textContent = `${fmt(c.entries)} / ${fmtBytes(c.resident_bytes)}`;
|
||||
$("cache-memory-breakdown").textContent = `${fmtBytes(c.resident_bytes)} / ${fmtBytes(c.transient_bytes)}`;
|
||||
$("cache-transient-breakdown").textContent = `${fmtBytes(c.fill_bytes_active)} / ${fmtBytes(c.cold_queue_bytes)} / ${fmtBytes((Number(c.cold_write_bytes_active) || 0) + (Number(c.cold_read_bytes_active) || 0))}`;
|
||||
const coldStorage = knownPair(c.cold_bytes, c.cold_limit_bytes, value => fmtBytes(value));
|
||||
const coldObjects = knownPair(c.cold_entries, c.cold_limit_entries, value => fmt(value));
|
||||
$("cache-cold-storage").textContent = envelope?.config.cache?.cold_enabled === true
|
||||
? `${coldStorage ?? "-"}${coldObjects == null ? "" : ` · ${coldObjects} ${t("cacheColdStorageObjects")}`}`
|
||||
: t("notEnabled");
|
||||
$("cache-evictions").textContent = `${fmt(c.evictions)} / ${fmt(c.expired)}`;
|
||||
if (appendSample) {
|
||||
if (c.resident_bytes != null) { cacheMemorySamples.push(Number(c.resident_bytes)); cacheMemorySamples = cacheMemorySamples.slice(-60); }
|
||||
if (c.transient_bytes != null) { cacheTransientSamples.push(Number(c.transient_bytes)); cacheTransientSamples = cacheTransientSamples.slice(-60); }
|
||||
if (c.fill_bytes_active != null) { cacheFillSamples.push(Number(c.fill_bytes_active)); cacheFillSamples = cacheFillSamples.slice(-60); }
|
||||
if (c.cold_queue_bytes != null) { cacheColdQueueSamples.push(Number(c.cold_queue_bytes)); cacheColdQueueSamples = cacheColdQueueSamples.slice(-60); }
|
||||
const coldIoBytes = (Number(c.cold_write_bytes_active) || 0) + (Number(c.cold_read_bytes_active) || 0);
|
||||
if (c.cold_write_bytes_active != null || c.cold_read_bytes_active != null) { cacheColdIoSamples.push(coldIoBytes); cacheColdIoSamples = cacheColdIoSamples.slice(-60); }
|
||||
if (c.cold_bytes != null) { cacheColdStorageSamples.push(Number(c.cold_bytes)); cacheColdStorageSamples = cacheColdStorageSamples.slice(-60); }
|
||||
const currentCounters = [c.hits, c.misses, c.bypasses].map(Number);
|
||||
if (currentCounters.every(Number.isFinite)) {
|
||||
const delta = lastCacheCounters ? currentCounters.map((value, index) => value >= lastCacheCounters[index] ? value - lastCacheCounters[index] : 0) : [0, 0, 0];
|
||||
cacheHitSamples.push(delta[0]); cacheMissSamples.push(delta[1]); cacheBypassSamples.push(delta[2]);
|
||||
cacheHitSamples = cacheHitSamples.slice(-60); cacheMissSamples = cacheMissSamples.slice(-60); cacheBypassSamples = cacheBypassSamples.slice(-60);
|
||||
lastCacheCounters = currentCounters;
|
||||
}
|
||||
if (d.process_cpu_percent != null) { processCpuSamples.push(Number(d.process_cpu_percent)); processCpuSamples = processCpuSamples.slice(-60); }
|
||||
if (d.process_rss_bytes != null) { processRssSamples.push(Number(d.process_rss_bytes)); processRssSamples = processRssSamples.slice(-60); }
|
||||
}
|
||||
const cacheOutcomePeak = Math.max(1, ...cacheHitSamples, ...cacheMissSamples, ...cacheBypassSamples);
|
||||
$("cache-hit-sparkline").setAttribute("points", sparklinePoints(cacheHitSamples, 300, 60, 5, cacheOutcomePeak));
|
||||
$("cache-miss-sparkline").setAttribute("points", sparklinePoints(cacheMissSamples, 300, 60, 5, cacheOutcomePeak));
|
||||
$("cache-bypass-sparkline").setAttribute("points", sparklinePoints(cacheBypassSamples, 300, 60, 5, cacheOutcomePeak));
|
||||
const latestCacheDelta = [cacheHitSamples.at(-1), cacheMissSamples.at(-1), cacheBypassSamples.at(-1)];
|
||||
$("cache-outcome-chart-value").textContent = latestCacheDelta.every(value => value != null) ? latestCacheDelta.map(value => fmt(value)).join(" / ") : "-";
|
||||
const hotLimit = Number(c.hot_limit_bytes), hotPeak = Number.isFinite(hotLimit) && hotLimit > 0 ? Math.max(hotLimit, ...cacheMemorySamples) : null;
|
||||
$("cache-memory-sparkline").setAttribute("points", sparklinePoints(cacheMemorySamples, 300, 60, 5, hotPeak));
|
||||
$("cache-chart-value").textContent = occupancyText(c.resident_bytes, c.hot_limit_bytes);
|
||||
const transientLimit = Number(c.transient_limit_bytes);
|
||||
const transientPeak = Number.isFinite(transientLimit) && transientLimit > 0
|
||||
? Math.max(transientLimit, ...cacheTransientSamples, ...cacheFillSamples, ...cacheColdQueueSamples, ...cacheColdIoSamples)
|
||||
: Math.max(1, ...cacheTransientSamples, ...cacheFillSamples, ...cacheColdQueueSamples, ...cacheColdIoSamples);
|
||||
$("cache-transient-sparkline").setAttribute("points", sparklinePoints(cacheTransientSamples, 300, 60, 5, transientPeak));
|
||||
$("cache-fill-sparkline").setAttribute("points", sparklinePoints(cacheFillSamples, 300, 60, 5, transientPeak));
|
||||
$("cache-queue-sparkline").setAttribute("points", sparklinePoints(cacheColdQueueSamples, 300, 60, 5, transientPeak));
|
||||
$("cache-io-sparkline").setAttribute("points", sparklinePoints(cacheColdIoSamples, 300, 60, 5, transientPeak));
|
||||
$("cache-transient-chart-value").textContent = occupancyText(c.transient_bytes, c.transient_limit_bytes);
|
||||
const coldEnabled = envelope?.config.cache?.cold_enabled === true;
|
||||
const coldLimit = Number(c.cold_limit_bytes), coldPeak = Number.isFinite(coldLimit) && coldLimit > 0 ? Math.max(coldLimit, ...cacheColdStorageSamples) : null;
|
||||
$("cache-cold-sparkline").setAttribute("points", sparklinePoints(cacheColdStorageSamples, 300, 60, 5, coldPeak));
|
||||
$("cache-cold-chart-value").textContent = coldEnabled ? occupancyText(c.cold_bytes, c.cold_limit_bytes) : t("notEnabled");
|
||||
setOptionalMetric("process-platform", !!d.platform && d.platform !== "Unsupported", d.platform || "");
|
||||
setOptionalMetric("process-cpu", d.process_cpu_percent != null, `${fmt(d.process_cpu_percent, 1)}%`);
|
||||
const fds = knownPair(d.open_fds, d.max_fds, value => fmt(value), t("unlimited"));
|
||||
setOptionalMetric("process-fds", fds != null, fds);
|
||||
setOptionalMetric("process-threads", d.process_threads != null, fmt(d.process_threads));
|
||||
const virtualMemory = knownPair(d.process_virtual_memory_bytes, d.process_virtual_memory_max_bytes, value => fmtBytes(value), t("unlimited"));
|
||||
setOptionalMetric("process-virtual-memory", virtualMemory != null, virtualMemory);
|
||||
setOptionalMetric("process-start-time", d.process_start_time_seconds != null, d.process_start_time_seconds == null ? "" : new Date(Number(d.process_start_time_seconds) * 1000).toLocaleString(locale()));
|
||||
setOptionalMetric("process-uptime", status.uptime_seconds != null, status.uptime_seconds == null ? "" : uptime(status.uptime_seconds));
|
||||
setOptionalMetric("process-rss-total", d.process_rss_bytes != null, fmtBytes(d.process_rss_bytes));
|
||||
const memory = status.memory_accounting || {};
|
||||
const hotMemory = knownPair(memory.cache_hot_bytes, memory.cache_hot_limit_bytes, value => fmtBytes(value));
|
||||
const transientMemory = knownPair(memory.cache_transient_bytes, memory.cache_transient_limit_bytes, value => fmtBytes(value));
|
||||
const transformMemory = knownPair(memory.transform_reserved_bytes, memory.transform_budget_bytes, value => fmtBytes(value));
|
||||
const transformInflight = knownPair(memory.transform_inflight, memory.transform_max_inflight, value => fmt(value));
|
||||
setOptionalMetric("memory-cache-hot", hotMemory != null, hotMemory);
|
||||
setOptionalMetric("memory-cache-transient", transientMemory != null, transientMemory);
|
||||
setOptionalMetric("memory-transform", transformMemory != null, transformMemory);
|
||||
setOptionalMetric("memory-transform-inflight", transformInflight != null, transformInflight);
|
||||
setOptionalMetric("memory-non-cache-rss", memory.non_cache_rss_bytes != null, fmtBytes(memory.non_cache_rss_bytes));
|
||||
setOptionalMetric("process-rss-anon", d.process_rss_anon_bytes != null, fmtBytes(d.process_rss_anon_bytes));
|
||||
const rssFile = knownPair(d.process_rss_file_bytes, d.process_rss_shmem_bytes, value => fmtBytes(value));
|
||||
setOptionalMetric("process-rss-file", rssFile != null, rssFile);
|
||||
const dataSwap = knownPair(d.process_data_bytes, d.process_swap_bytes, value => fmtBytes(value));
|
||||
setOptionalMetric("process-data-swap", dataSwap != null, dataSwap);
|
||||
const cgroupMemory = knownPair(d.cgroup_memory_current_bytes, d.cgroup_memory_max_bytes, value => fmtBytes(value), t("unlimited"));
|
||||
setOptionalMetric("cgroup-memory", cgroupMemory != null, cgroupMemory);
|
||||
$("process-cpu-sparkline").setAttribute("points", sparklinePoints(processCpuSamples));
|
||||
$("process-cpu-chart-value").textContent = d.process_cpu_percent == null ? "" : `${fmt(d.process_cpu_percent, 1)}%`;
|
||||
$("process-rss-sparkline").setAttribute("points", sparklinePoints(processRssSamples));
|
||||
$("process-rss-chart-value").textContent = d.process_rss_bytes == null ? "" : fmtBytes(d.process_rss_bytes);
|
||||
$("process-cpu-sparkline").closest(".mini-live-chart").hidden = d.process_cpu_percent == null;
|
||||
$("process-rss-sparkline").closest(".mini-live-chart").hidden = d.process_rss_bytes == null;
|
||||
updateCacheMemorySizing();
|
||||
const networkRateSupported = d.network_rx_bytes_per_second != null && d.network_tx_bytes_per_second != null;
|
||||
setOptionalMetric("system-network", networkRateSupported, networkRateSupported ? `${fmtBytes(d.network_rx_bytes_per_second)} / ${fmtBytes(d.network_tx_bytes_per_second)}` : "");
|
||||
setOptionalMetric("mean-latency", m.mean_duration_ms != null, m.mean_duration_ms == null ? "" : `${fmt(m.mean_duration_ms, 2)} ms`);
|
||||
if (appendSample) {
|
||||
if (d.network_rx_bytes_per_second != null && d.network_tx_bytes_per_second != null) {
|
||||
networkRxSamples.push(Number(d.network_rx_bytes_per_second)); networkTxSamples.push(Number(d.network_tx_bytes_per_second));
|
||||
networkRxSamples = networkRxSamples.slice(-60); networkTxSamples = networkTxSamples.slice(-60);
|
||||
}
|
||||
if (m.mean_duration_ms != null) { responseLatencySamples.push(Number(m.mean_duration_ms)); responseLatencySamples = responseLatencySamples.slice(-60); }
|
||||
}
|
||||
const networkPeak = Math.max(1, ...networkRxSamples, ...networkTxSamples);
|
||||
$("network-rx-sparkline").setAttribute("points", sparklinePoints(networkRxSamples, 300, 60, 5, networkPeak));
|
||||
$("network-tx-sparkline").setAttribute("points", sparklinePoints(networkTxSamples, 300, 60, 5, networkPeak));
|
||||
$("network-chart-value").textContent = networkRateSupported ? `${fmtBytes(d.network_rx_bytes_per_second)} / ${fmtBytes(d.network_tx_bytes_per_second)}` : "";
|
||||
$("network-rx-sparkline").closest(".mini-live-chart").hidden = !networkRateSupported;
|
||||
renderNetworkInterfaceCharts(d.network_interfaces || [], appendSample);
|
||||
$("latency-sparkline").setAttribute("points", sparklinePoints(responseLatencySamples));
|
||||
$("latency-chart-value").textContent = m.mean_duration_ms == null ? "" : `${fmt(m.mean_duration_ms, 2)} ms`;
|
||||
$("latency-sparkline").closest(".mini-live-chart").hidden = m.mean_duration_ms == null;
|
||||
$("compression-counters").textContent = [m.compression_gzip, m.compression_br, m.compression_zstd, m.compression_deflate].map(n => fmt(n)).join(" / ");
|
||||
const age = d.sampled_at_ms ? Math.max(0, Math.floor((Date.now() - d.sampled_at_ms) / 1000)) : null;
|
||||
$("system-sample-age").hidden = age == null;
|
||||
if (age != null) $("system-sample-age").textContent = `${fmt(age)} s`;
|
||||
$("system-sample-age").classList.toggle("status-error", age != null && age > 10);
|
||||
const l = status.listeners || {};
|
||||
$("listener-pending").hidden = !l.restart_required;
|
||||
$("listener-pending").textContent = t("restartPending");
|
||||
$("listener-active").textContent = `${t("activeListeners")}: ${l.active_endpoints?.map(e => `${e.tls ? "HTTPS" : "HTTP"} ${e.address}${e.http3 ? " + H3/UDP" : ""}`).join(" | ") || `HTTP ${status.proxy_listen} / HTTPS ${status.tls_listen || t("notEnabled")}`}`;
|
||||
}
|
||||
function renderHeaderRules(direction, rules) {
|
||||
const target = $(`${direction}-header-rules`); target.replaceChildren();
|
||||
for (const rule of rules) appendHeaderRule(direction, rule, false);
|
||||
}
|
||||
function appendHeaderRule(direction, rule = { op: "set", name: "", value: "" }, mark = true) {
|
||||
const target = $(`${direction}-header-rules`); if (target.children.length >= 32) { toast(t("headerLimit"), true); return; }
|
||||
const row = el("div", undefined, "header-rule");
|
||||
const op = el("select"); op.className = "header-op"; op.setAttribute("aria-label", t("headerOperation"));
|
||||
for (const value of ["set", "append", "remove"]) { const option = el("option", t(`header_${value}`)); option.value = value; op.append(option); } op.value = rule.op;
|
||||
const name = el("input"); name.className = "header-name"; name.value = rule.name; name.required = true; name.maxLength = 128; name.placeholder = t("headerName"); name.setAttribute("aria-label", t("headerName")); name.autocomplete = "off"; name.spellcheck = false;
|
||||
const value = el("input"); value.className = "header-value"; value.value = rule.value ?? ""; value.maxLength = 8192; value.placeholder = t("headerValue"); value.setAttribute("aria-label", t("headerValue")); value.autocomplete = "off"; value.spellcheck = false;
|
||||
function sync() { value.disabled = op.value === "remove"; } sync(); op.addEventListener("change", sync);
|
||||
const remove = action(t("remove"), () => { row.remove(); setSettingsDirty("headers-form"); });
|
||||
row.append(op, name, value, remove); target.append(row); if (mark) setSettingsDirty("headers-form");
|
||||
}
|
||||
function collectHeaderRules(direction) {
|
||||
return [...$(`${direction}-header-rules`).children].map(row => {
|
||||
const op = row.querySelector(".header-op").value, name = row.querySelector(".header-name").value.trim(), value = row.querySelector(".header-value").value;
|
||||
return op === "remove" ? { op, name } : { op, name, value };
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
function renderSettings() {
|
||||
if (!envelope) return; const config = envelope.config; window.rustProxySettings?.render();
|
||||
if (!settingsDirty.has("listeners-form")) window.rustProxyIntegrations?.renderListeners();
|
||||
if (!settingsDirty.has("http2-form")) window.rustProxyIntegrations?.renderHttp2Settings();
|
||||
if (!settingsDirty.has("http3-form")) window.rustProxyIntegrations?.renderHttp3Settings();
|
||||
if (!settingsDirty.has("response-form")) {
|
||||
const server = config.server_header || {}, compression = config.compression || {}, diagnostics = config.diagnostic_headers || {};
|
||||
$("expose-request-id").checked = !!diagnostics.request_id;
|
||||
$("server-mode").value = server.mode || "preserve"; $("server-value").value = server.value || "RustProxy/v0.25.1";
|
||||
for (const name of ["gzip", "br", "zstd", "deflate"]) $(`compress-${name}`).checked = !!compression[name];
|
||||
$("compress-min").value = compression.min_bytes ?? 1024; syncServerMode();
|
||||
}
|
||||
if (!settingsDirty.has("cache-form")) {
|
||||
const c = config.cache || {}; $("cache-enabled").checked = !!c.enabled; $("cache-memory").value = (c.max_bytes ?? 268435456) / 1048576;
|
||||
$("cache-transient-memory").value = (c.transient_max_bytes ?? 67108864) / 1048576;
|
||||
$("cache-object").value = (c.max_object_bytes ?? 16777216) / 1024; $("cache-entries").value = c.max_entries ?? 4096;
|
||||
$("cache-eviction-limit").value = c.hot_eviction_max_objects ?? 64;
|
||||
$("cache-auto-trim-enabled").checked = c.hot_auto_trim_enabled !== false;
|
||||
$("cache-auto-trim-after").value = c.hot_auto_trim_after_seconds ?? 300;
|
||||
$("cache-auto-trim-target").value = c.hot_auto_trim_target_percent ?? 80;
|
||||
$("cache-cold-enabled").checked = !!c.cold_enabled; $("cache-cold-path").value = c.cold_path || "cache/cold";
|
||||
$("cache-cold-bytes").value = (c.cold_max_bytes ?? 4294967296) / 1048576; $("cache-cold-entries").value = c.cold_max_entries ?? 50000; $("cache-cold-after").value = c.cold_after_seconds ?? 300;
|
||||
$("cache-cold-queue-bytes").value = (c.cold_queue_max_bytes ?? 33554432) / 1048576;
|
||||
$("cache-cold-gc-interval").value = c.cold_gc_interval_seconds ?? 60; $("cache-cold-gc-max-delete").value = c.cold_gc_max_delete ?? 256;
|
||||
$("cache-cold-on-idle").checked = c.cold_on_idle !== false; $("cache-cold-on-age").checked = c.cold_on_age === true; $("cache-cold-on-lru").checked = c.cold_on_lru !== false; $("cache-cold-on-shutdown").checked = c.cold_on_shutdown !== false; $("cache-cold-max-hot").value = c.cold_max_hot_seconds ?? 3600;
|
||||
$("cache-ttl").value = c.max_ttl_seconds ?? 86400; $("cache-default-ttl").value = c.default_ttl_seconds ?? 0;
|
||||
$("cache-collapse-enabled").checked = c.collapse_enabled !== false; $("cache-collapse-timeout").value = c.collapse_wait_timeout_ms ?? 1500; $("cache-collapse-fallback").value = c.collapse_timeout_fallback || "origin";
|
||||
$("cache-sie-timeout").checked = c.stale_if_error_timeout !== false; $("cache-sie-connection").checked = c.stale_if_error_connection !== false; $("cache-sie-dns").checked = c.stale_if_error_dns !== false; $("cache-sie-tls").checked = c.stale_if_error_tls !== false; $("cache-sie-statuses").value = (c.stale_if_error_statuses || [500, 502, 503, 504]).join(", ");
|
||||
$("cache-proactive-enabled").checked = c.proactive_refresh_enabled === true; $("cache-proactive-before").value = c.proactive_refresh_before_expiry_seconds ?? 15; $("cache-proactive-percent").value = c.proactive_refresh_percent ?? 10; $("cache-proactive-inflight").value = c.proactive_refresh_max_inflight ?? 4;
|
||||
$("cache-query").checked = !!c.bypass_query; $("cache-prefixes").value = (c.bypass_prefixes || []).join("\n");
|
||||
$("cache-static-cookie").checked = c.static_ignore_cookies !== false; $("cache-static-extensions").value = (c.static_extensions || []).join("\n");
|
||||
$("cache-status-header").checked = !!c.expose_status; $("cache-hits-header").checked = !!c.expose_hits; $("cache-rfc-header").checked = !!c.expose_cache_status; $("cache-resident-age-header").checked = !!c.expose_resident_age;
|
||||
setCacheMemoryPresetSelection(["conservative", "recommended", "performance"].includes(c.memory_preset) ? c.memory_preset : null);
|
||||
setCacheColdPresetSelection(c.cold_preset === "recommended" ? "recommended" : null);
|
||||
syncColdCachePolicy();
|
||||
syncAdvancedCachePolicy();
|
||||
updateCacheMemorySizing();
|
||||
}
|
||||
if (!settingsDirty.has("transform-form")) {
|
||||
const tr = config.transform || {};
|
||||
setTransformPresetSelection(["conservative", "recommended", "performance", "uber"].includes(tr.preset) ? tr.preset : null);
|
||||
$("transform-enabled").checked = !!tr.enabled;
|
||||
$("transform-javascript").checked = tr.javascript !== false;
|
||||
$("transform-css").checked = tr.css !== false;
|
||||
$("transform-html").checked = !!tr.html;
|
||||
$("transform-svg").checked = !!tr.svg;
|
||||
$("transform-json").checked = !!tr.json;
|
||||
$("transform-extensions").value = (tr.extensions || ["js", "mjs", "css", "html", "htm", "svg", "json"]).join("\n");
|
||||
$("transform-max-body").value = (tr.max_body_bytes ?? 2097152) / 1024;
|
||||
$("transform-memory-budget").value = (tr.memory_budget_bytes ?? 134217728) / 1048576;
|
||||
$("transform-max-inflight").value = tr.max_inflight ?? 32;
|
||||
updateTransformResourceSizing();
|
||||
}
|
||||
if (!settingsDirty.has("logging-form")) {
|
||||
const l = config.logging || {}, file = l.file || {}, logstash = l.logstash || {}, loki = l.loki || {}, format = l.format || {};
|
||||
$("log-capture-user-agent").checked = l.capture_user_agent !== false;
|
||||
$("log-capture-country").checked = l.capture_country === true;
|
||||
$("log-file-enabled").checked = !!file.enabled; $("log-file-per-vhost").checked = !!file.per_vhost; $("log-file-path").value = file.path || "/var/log/rustproxy/access.jsonl"; $("log-file-directory").value = file.directory || "/var/log/rustproxy/vhosts"; syncLoggingFileMode();
|
||||
$("log-logstash-enabled").checked = !!logstash.enabled; $("log-logstash-address").value = logstash.address || "127.0.0.1:5044";
|
||||
$("log-loki-enabled").checked = !!loki.enabled; $("log-loki-url").value = loki.url || "http://127.0.0.1:3100/loki/api/v1/push"; $("log-loki-token").value = loki.bearer_token_env || "";
|
||||
$("log-loki-labels").value = JSON.stringify(loki.labels || { job: "rustproxy" }, null, 2); $("log-format-fields").value = JSON.stringify(format.fields || {}, null, 2); $("log-format-static").value = JSON.stringify(format.static_fields || {}, null, 2);
|
||||
$("log-format-preset").value = detectLoggingPreset(format.fields || {});
|
||||
}
|
||||
if (!settingsDirty.has("error-log-form")) {
|
||||
const errorLog = config.error_log || {};
|
||||
$("error-log-enabled").checked = !!errorLog.enabled;
|
||||
$("error-log-path").value = errorLog.path || "/var/log/rustproxy/error.log";
|
||||
$("error-log-format").value = ["JSON", "RAW"].includes(errorLog.format) ? errorLog.format : "JSON";
|
||||
syncErrorLogMode();
|
||||
}
|
||||
const sink = latestStatus?.logging?.sinks, errorSink = latestStatus?.logging?.error_file;
|
||||
$("logging-status").textContent = sink ? `${t("loggingQueue")}: ${fmt(sink.queue_dropped)} ${t("loggingDropped")} / ${fmt(sink.sent)} ${t("loggingSent")} / ${fmt(sink.errors)} ${t("errors")} · ${t("loggingCapacity")}: ${fmt(sink.queue_capacity)}` : "";
|
||||
$("error-log-status").textContent = errorSink ? `${t("loggingQueue")}: ${fmt(errorSink.queue_dropped)} ${t("loggingDropped")} / ${fmt(errorSink.written)} ${t("loggingSent")} / ${fmt(errorSink.errors)} ${t("errors")} · ${t("loggingCapacity")}: ${fmt(errorSink.queue_capacity)}` : "";
|
||||
if (!settingsDirty.has("forwarding-form")) { $("trusted-proxies").value = (config.forwarding?.trusted_proxies || []).join("\n"); $("trusted-proto").checked = !!config.forwarding?.trust_forwarded_proto; }
|
||||
if (!settingsDirty.has("headers-form")) { for (const direction of ["request", "response"]) renderHeaderRules(direction, config.headers?.[direction] || []); }
|
||||
}
|
||||
function effectiveCacheMemory() {
|
||||
const d = latestStatus?.system || {}, host = Number(d.memory_total_bytes), cgroup = Number(d.cgroup_memory_max_bytes);
|
||||
const candidates = [host, cgroup].filter(value => Number.isFinite(value) && value > 0);
|
||||
if (!candidates.length) return null;
|
||||
const bytes = Math.min(...candidates);
|
||||
return { bytes, cgroupLimited: Number.isFinite(cgroup) && cgroup > 0 && (!Number.isFinite(host) || cgroup < host) };
|
||||
}
|
||||
function cacheMemoryHeadroom() {
|
||||
const d = latestStatus?.system || {};
|
||||
const hostTotal = Number(d.memory_total_bytes), hostUsed = Number(d.memory_used_bytes);
|
||||
const cgroupMax = Number(d.cgroup_memory_max_bytes), cgroupCurrent = Number(d.cgroup_memory_current_bytes);
|
||||
const candidates = [];
|
||||
if (Number.isFinite(hostTotal) && hostTotal > 0 && Number.isFinite(hostUsed) && hostUsed >= 0) candidates.push(Math.max(0, hostTotal - hostUsed));
|
||||
if (Number.isFinite(cgroupMax) && cgroupMax > 0 && Number.isFinite(cgroupCurrent) && cgroupCurrent >= 0) candidates.push(Math.max(0, cgroupMax - cgroupCurrent));
|
||||
return candidates.length ? Math.min(...candidates) : null;
|
||||
}
|
||||
function cachePresetValues(kind, bytes) {
|
||||
const totalMiB = bytes / 1048576, profile = {
|
||||
conservative: { ratio: .125, objectMiB: 8, eviction: 32 },
|
||||
recommended: { ratio: .20, objectMiB: 16, eviction: 64 },
|
||||
performance: { ratio: .30, objectMiB: 32, eviction: 128 }
|
||||
}[kind] || { ratio: .20, objectMiB: 16, eviction: 64 };
|
||||
const round16 = value => Math.max(16, Math.floor(value / 16) * 16);
|
||||
const hot = Math.max(64, Math.min(round16(totalMiB * profile.ratio), round16(totalMiB * .5)));
|
||||
const transient = Math.max(16, round16(hot * .25));
|
||||
const queue = Math.max(8, Math.floor((transient * .5) / 8) * 8);
|
||||
const objectMiB = Math.max(1, Math.min(profile.objectMiB, Math.floor(hot / 16)));
|
||||
const entries = Math.max(1024, Math.min(16384, Math.round(hot * 16)));
|
||||
return { hot, transient, queue, objectKiB: objectMiB * 1024, entries, eviction: profile.eviction };
|
||||
}
|
||||
function cacheMemoryPresetName(kind) {
|
||||
return kind ? t({ conservative: "cachePresetConservative", recommended: "cachePresetRecommended", performance: "cachePresetPerformance" }[kind]) : t("cachePresetCustom");
|
||||
}
|
||||
function setCacheMemoryPresetSelection(kind) {
|
||||
selectedCacheMemoryPreset = ["conservative", "recommended", "performance"].includes(kind) ? kind : null;
|
||||
document.querySelectorAll("[data-cache-memory-preset]").forEach(button => {
|
||||
const active = button.dataset.cacheMemoryPreset === selectedCacheMemoryPreset;
|
||||
button.classList.toggle("primary", active); button.classList.toggle("outline", !active);
|
||||
button.setAttribute("aria-pressed", String(active));
|
||||
});
|
||||
const badge = $("cache-memory-preset-selected");
|
||||
if (badge) badge.textContent = `${t("cachePresetSelected")}: ${cacheMemoryPresetName(selectedCacheMemoryPreset)}`;
|
||||
}
|
||||
function setCacheColdPresetSelection(kind) {
|
||||
selectedCacheColdPreset = kind === "recommended" ? "recommended" : null;
|
||||
document.querySelectorAll("[data-cache-cold-preset]").forEach(button => {
|
||||
const active = button.dataset.cacheColdPreset === selectedCacheColdPreset;
|
||||
button.classList.toggle("primary", active); button.classList.toggle("outline", !active);
|
||||
button.setAttribute("aria-pressed", String(active));
|
||||
});
|
||||
const badge = $("cache-cold-preset-selected");
|
||||
if (badge) badge.textContent = `${t("cachePresetSelected")}: ${selectedCacheColdPreset ? t("cachePresetRecommended") : t("cachePresetCustom")}`;
|
||||
}
|
||||
function updateCacheMemorySizing() {
|
||||
const detected = effectiveCacheMemory(), badge = $("cache-memory-detected"), warning = $("cache-memory-warning");
|
||||
if (!badge || !warning) return;
|
||||
const buttons = document.querySelectorAll("[data-cache-memory-preset]");
|
||||
if (!detected) {
|
||||
badge.textContent = t("cacheMemoryNotDetected"); buttons.forEach(button => { button.disabled = true; });
|
||||
$("cache-memory-preset-summary").textContent = t("cacheMemoryPresetUnavailable"); warning.hidden = true; return;
|
||||
}
|
||||
if (detected.bytes < 256 * 1048576) {
|
||||
badge.textContent = `${detected.cgroupLimited ? t("cacheMemoryCgroup") : t("cacheMemoryDetected")}: ${fmtBytes(detected.bytes)}`;
|
||||
buttons.forEach(button => { button.disabled = true; });
|
||||
$("cache-memory-preset-summary").textContent = t("cacheMemoryPresetTooSmall");
|
||||
warning.hidden = false; warning.textContent = t("cacheMemoryPresetTooSmall"); return;
|
||||
}
|
||||
buttons.forEach(button => { button.disabled = false; });
|
||||
badge.textContent = `${detected.cgroupLimited ? t("cacheMemoryCgroup") : t("cacheMemoryDetected")}: ${fmtBytes(detected.bytes)}`;
|
||||
const recommended = cachePresetValues("recommended", detected.bytes);
|
||||
$("cache-memory-preset-summary").textContent = `${t("cacheMemoryRecommendedFor")} HOT ${recommended.hot} MiB · ${t("cachePresetTransientShort")} ${recommended.transient} MiB · ${t("cachePresetObjectShort")} ${recommended.objectKiB / 1024} MiB · ${t("cachePresetQueueShort")} ${recommended.queue} MiB`;
|
||||
const hot = Number($("cache-memory")?.value) * 1048576, transient = Number($("cache-transient-memory")?.value) * 1048576;
|
||||
const queue = Number($("cache-cold-queue-bytes")?.value) * 1048576;
|
||||
const object = Number($("cache-object")?.value) * 1024;
|
||||
const high = Number.isFinite(hot + transient) && hot + transient > detected.bytes * .35;
|
||||
const invalidQueue = Number.isFinite(queue) && Number.isFinite(transient) && queue > transient;
|
||||
const objectOverTransient = Number.isFinite(object) && Number.isFinite(transient) && object > transient;
|
||||
const headroom = cacheMemoryHeadroom();
|
||||
const currentCache = (Number(latestStatus?.cache?.hot_allocated_bytes ?? latestStatus?.cache?.resident_bytes) || 0) + (Number(latestStatus?.cache?.transient_bytes) || 0);
|
||||
const additionalBudget = Math.max(0, hot + transient - currentCache);
|
||||
const lowHeadroom = headroom != null && Number.isFinite(additionalBudget) && additionalBudget > headroom * .75;
|
||||
warning.hidden = !(high || invalidQueue || objectOverTransient || lowHeadroom);
|
||||
if (invalidQueue) warning.textContent = t("cacheQueueOverTransientWarning");
|
||||
else if (objectOverTransient) warning.textContent = t("cacheObjectOverTransientWarning");
|
||||
else if (lowHeadroom) warning.textContent = `${t("cacheMemoryHeadroomWarning")} ${fmtBytes(additionalBudget)} / ${fmtBytes(headroom)}.`;
|
||||
else if (high) warning.textContent = `${t("cacheMemoryHighWarning")} ${fmtBytes(hot + transient)} / ${fmtBytes(detected.bytes)}.`;
|
||||
}
|
||||
function applyCacheMemoryPreset(kind) {
|
||||
const detected = effectiveCacheMemory(); if (!detected || detected.bytes < 256 * 1048576) return;
|
||||
const preset = cachePresetValues(kind, detected.bytes);
|
||||
$("cache-memory").value = preset.hot; $("cache-transient-memory").value = preset.transient;
|
||||
$("cache-object").value = preset.objectKiB; $("cache-entries").value = preset.entries;
|
||||
$("cache-eviction-limit").value = preset.eviction; $("cache-cold-queue-bytes").value = preset.queue;
|
||||
setCacheMemoryPresetSelection(kind);
|
||||
setSettingsDirty("cache-form"); updateCacheMemorySizing();
|
||||
}
|
||||
function applyRecommendedColdCachePreset() {
|
||||
const transient = Number($("cache-transient-memory").value);
|
||||
const queue = Number.isFinite(transient) && transient > 0 ? Math.max(0.0625, Math.floor((transient * .5) / 8) * 8 || Math.min(transient, 8)) : 32;
|
||||
$("cache-cold-enabled").checked = true;
|
||||
if (!$("cache-cold-path").value.trim()) $("cache-cold-path").value = "cache/cold";
|
||||
$("cache-cold-on-idle").checked = true; $("cache-cold-on-age").checked = false;
|
||||
$("cache-cold-on-lru").checked = true; $("cache-cold-on-shutdown").checked = true;
|
||||
$("cache-cold-after").value = 300; $("cache-cold-max-hot").value = 3600;
|
||||
$("cache-cold-gc-interval").value = 60; $("cache-cold-gc-max-delete").value = 256;
|
||||
$("cache-cold-queue-bytes").value = Math.min(queue, transient || queue);
|
||||
setCacheColdPresetSelection("recommended");
|
||||
syncColdCachePolicy(); setSettingsDirty("cache-form"); updateCacheMemorySizing();
|
||||
}
|
||||
function transformPresetValues(kind) {
|
||||
return {
|
||||
conservative: { memoryMiB: 32, bodyKiB: 1024, inflight: 8 },
|
||||
recommended: { memoryMiB: 128, bodyKiB: 2048, inflight: 32 },
|
||||
performance: { memoryMiB: 512, bodyKiB: 8192, inflight: 64 },
|
||||
uber: { memoryMiB: 1024, bodyKiB: 32768, inflight: 128 }
|
||||
}[kind] || { memoryMiB: 128, bodyKiB: 2048, inflight: 32 };
|
||||
}
|
||||
function transformPresetName(kind) {
|
||||
return kind ? t({ conservative: "transformPresetConservative", recommended: "transformPresetRecommended", performance: "transformPresetPerformance", uber: "transformPresetUber" }[kind]) : t("cachePresetCustom");
|
||||
}
|
||||
function setTransformPresetSelection(kind) {
|
||||
selectedTransformPreset = ["conservative", "recommended", "performance", "uber"].includes(kind) ? kind : null;
|
||||
document.querySelectorAll("[data-transform-preset]").forEach(button => {
|
||||
const active = button.dataset.transformPreset === selectedTransformPreset;
|
||||
button.classList.toggle("primary", active); button.classList.toggle("outline", !active);
|
||||
button.setAttribute("aria-pressed", String(active));
|
||||
});
|
||||
const badge = $("transform-preset-selected");
|
||||
if (badge) badge.textContent = `${t("cachePresetSelected")}: ${transformPresetName(selectedTransformPreset)}`;
|
||||
}
|
||||
function updateTransformResourceSizing() {
|
||||
const warning = $("transform-memory-warning"); if (!warning) return;
|
||||
const memoryMiB = Number($("transform-memory-budget")?.value), bodyKiB = Number($("transform-max-body")?.value), inflight = Number($("transform-max-inflight")?.value);
|
||||
const detected = effectiveCacheMemory();
|
||||
const budgetBytes = memoryMiB * 1048576, bodyBytes = bodyKiB * 1024;
|
||||
const worstEncodedEstimate = bodyBytes * 3;
|
||||
const budgetCannotFitMax = Number.isFinite(budgetBytes) && Number.isFinite(worstEncodedEstimate) && budgetBytes < worstEncodedEstimate;
|
||||
const highHostShare = detected && Number.isFinite(budgetBytes) && budgetBytes > detected.bytes * .5;
|
||||
warning.hidden = !(budgetCannotFitMax || highHostShare);
|
||||
if (budgetCannotFitMax) warning.textContent = t("transformBudgetTooSmallWarning");
|
||||
else if (highHostShare) warning.textContent = `${t("transformBudgetHighWarning")} ${fmtBytes(budgetBytes)} / ${fmtBytes(detected.bytes)}.`;
|
||||
const summary = $("transform-preset-summary");
|
||||
if (summary) summary.textContent = `${t("transformResourceCurrent")}: ${Number.isFinite(memoryMiB) ? memoryMiB : "-"} MiB · ${Number.isFinite(bodyKiB) ? bodyKiB : "-"} KiB · ${t("transformMaxInflightShort")} ${Number.isFinite(inflight) ? inflight : "-"}`;
|
||||
}
|
||||
function applyTransformPreset(kind) {
|
||||
const preset = transformPresetValues(kind);
|
||||
$("transform-memory-budget").value = preset.memoryMiB;
|
||||
$("transform-max-body").value = preset.bodyKiB;
|
||||
$("transform-max-inflight").value = preset.inflight;
|
||||
setTransformPresetSelection(kind);
|
||||
setSettingsDirty("transform-form"); updateTransformResourceSizing();
|
||||
}
|
||||
function syncServerMode() { $("server-value").disabled = $("server-mode").value !== "custom"; $("server-value").required = !$("server-value").disabled; }
|
||||
function syncLoggingFileMode() {
|
||||
const enabled = $("log-file-enabled").checked, perVhost = $("log-file-per-vhost").checked;
|
||||
$("log-file-per-vhost").disabled = !enabled;
|
||||
$("log-file-path").disabled = !enabled || perVhost;
|
||||
$("log-file-directory").disabled = !enabled || !perVhost;
|
||||
}
|
||||
function syncErrorLogMode() {
|
||||
const disabled = !$("error-log-enabled").checked;
|
||||
$("error-log-path").disabled = disabled;
|
||||
$("error-log-format").disabled = disabled;
|
||||
}
|
||||
|
||||
function syncColdCachePolicy() {
|
||||
const enabled = $("cache-cold-enabled").checked;
|
||||
for (const id of ["cache-cold-path", "cache-cold-bytes", "cache-cold-entries", "cache-cold-queue-bytes", "cache-cold-gc-interval", "cache-cold-gc-max-delete", "cache-cold-on-idle", "cache-cold-on-age", "cache-cold-on-lru", "cache-cold-on-shutdown"]) $(id).disabled = !enabled;
|
||||
$("cache-cold-after").disabled = !enabled || !$("cache-cold-on-idle").checked;
|
||||
$("cache-cold-max-hot").disabled = !enabled || !$("cache-cold-on-age").checked;
|
||||
}
|
||||
function syncAdvancedCachePolicy() {
|
||||
const collapse = $("cache-collapse-enabled").checked;
|
||||
$("cache-collapse-timeout").disabled = !collapse;
|
||||
$("cache-collapse-fallback").disabled = !collapse;
|
||||
const proactive = $("cache-proactive-enabled").checked;
|
||||
for (const id of ["cache-proactive-before", "cache-proactive-percent", "cache-proactive-inflight"]) $(id).disabled = !proactive;
|
||||
}
|
||||
function bindSettingsForm(id, apply, section, selectValue = next => next[section]) {
|
||||
const form = $(id); for (const event of ["input", "change"]) form.addEventListener(event, () => setSettingsDirty(id));
|
||||
form.addEventListener("submit", async event => {
|
||||
event.preventDefault(); if (!envelope || busy) return;
|
||||
try {
|
||||
await withFormSubmitState(form, event.submitter, async () => {
|
||||
const next = structuredClone(envelope.config); apply(next);
|
||||
if (await saveSection(section, selectValue(next))) { setSettingsDirty(id, false); renderSettings(); }
|
||||
});
|
||||
} catch (error) { toast(error.message, true); }
|
||||
});
|
||||
}
|
||||
bindSettingsForm("listeners-form", next => { next.listeners = window.rustProxyIntegrations.collectListeners(); }, "listeners");
|
||||
bindSettingsForm("http2-form", next => {
|
||||
if (settingsDirty.has("listeners-form")) throw new Error(t("http2ListenersDirty"));
|
||||
next.listeners = window.rustProxyIntegrations.collectHttp2Listeners();
|
||||
}, "listeners", next => next.listeners);
|
||||
bindSettingsForm("http3-form", next => {
|
||||
if (settingsDirty.has("listeners-form")) throw new Error(t("http3ListenersDirty"));
|
||||
next.listeners = window.rustProxyIntegrations.collectHttp3Listeners();
|
||||
}, "listeners", next => next.listeners);
|
||||
bindSettingsForm("response-form", next => {
|
||||
next.server_header = { mode: $("server-mode").value, value: $("server-value").value };
|
||||
next.diagnostic_headers = { request_id: $("expose-request-id").checked };
|
||||
next.compression = { min_bytes: Number($("compress-min").value) }; for (const name of ["gzip", "br", "zstd", "deflate"]) next.compression[name] = $(`compress-${name}`).checked;
|
||||
}, "response", next => ({ server_header: next.server_header, diagnostic_headers: next.diagnostic_headers, compression: next.compression }));
|
||||
bindSettingsForm("cache-form", next => {
|
||||
next.cache = { ...(next.cache || {}), enabled: $("cache-enabled").checked, memory_preset: selectedCacheMemoryPreset, max_bytes: Math.round(Number($("cache-memory").value) * 1048576), transient_max_bytes: Math.round(Number($("cache-transient-memory").value) * 1048576), max_object_bytes: Number($("cache-object").value) * 1024, max_entries: Number($("cache-entries").value), hot_eviction_max_objects: Number($("cache-eviction-limit").value), hot_auto_trim_enabled: $("cache-auto-trim-enabled").checked, hot_auto_trim_after_seconds: Number($("cache-auto-trim-after").value), hot_auto_trim_target_percent: Number($("cache-auto-trim-target").value), cold_enabled: $("cache-cold-enabled").checked, cold_preset: selectedCacheColdPreset, cold_path: $("cache-cold-path").value.trim(), cold_max_bytes: Math.round(Number($("cache-cold-bytes").value) * 1048576), cold_max_entries: Number($("cache-cold-entries").value), cold_queue_max_bytes: Math.round(Number($("cache-cold-queue-bytes").value) * 1048576), cold_gc_interval_seconds: Number($("cache-cold-gc-interval").value), cold_gc_max_delete: Number($("cache-cold-gc-max-delete").value), cold_after_seconds: Number($("cache-cold-after").value), cold_max_hot_seconds: Number($("cache-cold-max-hot").value), cold_on_idle: $("cache-cold-on-idle").checked, cold_on_age: $("cache-cold-on-age").checked, cold_on_lru: $("cache-cold-on-lru").checked, cold_on_shutdown: $("cache-cold-on-shutdown").checked, max_ttl_seconds: Number($("cache-ttl").value), default_ttl_seconds: Number($("cache-default-ttl").value), collapse_enabled: $("cache-collapse-enabled").checked, collapse_wait_timeout_ms: Number($("cache-collapse-timeout").value), collapse_timeout_fallback: $("cache-collapse-fallback").value, stale_if_error_timeout: $("cache-sie-timeout").checked, stale_if_error_connection: $("cache-sie-connection").checked, stale_if_error_dns: $("cache-sie-dns").checked, stale_if_error_tls: $("cache-sie-tls").checked, stale_if_error_statuses: $("cache-sie-statuses").value.split(/[\s,]+/).filter(Boolean).map(Number), proactive_refresh_enabled: $("cache-proactive-enabled").checked, proactive_refresh_before_expiry_seconds: Number($("cache-proactive-before").value), proactive_refresh_percent: Number($("cache-proactive-percent").value), proactive_refresh_max_inflight: Number($("cache-proactive-inflight").value), bypass_query: $("cache-query").checked, bypass_prefixes: $("cache-prefixes").value.split(/\r?\n/).map(v => v.trim()).filter(Boolean), static_ignore_cookies: $("cache-static-cookie").checked, static_extensions: $("cache-static-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean), expose_status: $("cache-status-header").checked, expose_hits: $("cache-hits-header").checked, expose_cache_status: $("cache-rfc-header").checked, expose_resident_age: $("cache-resident-age-header").checked };
|
||||
}, "cache");
|
||||
bindSettingsForm("transform-form", next => {
|
||||
next.transform = {
|
||||
enabled: $("transform-enabled").checked,
|
||||
preset: selectedTransformPreset,
|
||||
javascript: $("transform-javascript").checked,
|
||||
css: $("transform-css").checked,
|
||||
html: $("transform-html").checked,
|
||||
svg: $("transform-svg").checked,
|
||||
json: $("transform-json").checked,
|
||||
extensions: $("transform-extensions").value.split(/[\n,]+/).map(v => v.trim().replace(/^\./, "").toLowerCase()).filter(Boolean),
|
||||
max_body_bytes: Math.round(Number($("transform-max-body").value) * 1024),
|
||||
memory_budget_bytes: Math.round(Number($("transform-memory-budget").value) * 1048576),
|
||||
max_inflight: Number($("transform-max-inflight").value)
|
||||
};
|
||||
}, "transform");
|
||||
const LOGGING_SOURCE_FIELDS = ["vhost", "route", "cache_effective_enabled", "transform_effective_enabled", "backend", "id", "time_ms", "method", "host", "path", "http_version", "transport", "websocket_bootstrap", "websocket_phase", "websocket_error", "h3_reset_code", "upstream_status", "upstream_error", "client_ip", "user_agent", "country", "status", "duration_ms", "request_bytes", "response_bytes", "outcome", "cache", "cache_reason"];
|
||||
const LOGGING_PRESETS = {
|
||||
standard: {},
|
||||
minimal: { time_ms: "time_ms", method: "method", host: "host", path: "path", status: "status", duration_ms: "duration_ms" },
|
||||
nginx: { time_ms: "time_ms", remote_addr: "client_ip", http_user_agent: "user_agent", country: "country", request_method: "method", host: "host", request_uri: "path", status: "status", request_time_ms: "duration_ms", request_length: "request_bytes", bytes_sent: "response_bytes", upstream: "backend", cache: "cache", cache_reason: "cache_reason" },
|
||||
ecs: { "event.created_ms": "time_ms", "client.address": "client_ip", "user_agent.original": "user_agent", "client.geo.country_iso_code": "country", "http.request.method": "method", "server.address": "host", "url.path": "path", "http.response.status_code": "status", "event.duration_ms": "duration_ms", "http.request.body.bytes": "request_bytes", "http.response.body.bytes": "response_bytes", "service.target.name": "backend", "event.outcome": "outcome", "cache.result": "cache", "cache.reason": "cache_reason" }
|
||||
};
|
||||
const LOGGING_PREVIEW_VALUES = { vhost: "app", route: "root", cache_effective_enabled: true, transform_effective_enabled: false, backend: "origin-1", id: 1842, time_ms: 1769328123456, method: "CONNECT", host: "app.example", path: "/socket.io/", http_version: "3", transport: "h3", websocket_bootstrap: "h3_extended_connect", websocket_phase: "tunnel_closed", websocket_error: null, h3_reset_code: null, upstream_status: 101, client_ip: "203.0.113.24", user_agent: "Mozilla/5.0 RustProxy-Test", country: "PL", status: 200, duration_ms: 12, request_bytes: 0, response_bytes: 4821, outcome: "websocket_closed", cache: "BYPASS", cache_reason: "request_upgrade" };
|
||||
function parseJsonObjectValue(value, target = null) { let parsed; try { parsed = JSON.parse(value || "{}"); } catch { throw fieldError(target, t("loggingJsonInvalid")); } if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") throw fieldError(target, t("loggingJsonInvalid")); return parsed; }
|
||||
function loggingMappingKey(mapping) { return JSON.stringify(Object.entries(mapping || {}).sort(([a], [b]) => a.localeCompare(b))); }
|
||||
function detectLoggingPreset(mapping) { const key = loggingMappingKey(mapping); for (const [name, preset] of Object.entries(LOGGING_PRESETS)) if (loggingMappingKey(preset) === key) return name; return "custom"; }
|
||||
function selectedLoggingMapping() {
|
||||
const rows = [...$("logging-field-rows").querySelectorAll("tr")], mapping = {};
|
||||
for (const row of rows) {
|
||||
const enabled = row.querySelector("input[type=checkbox]").checked;
|
||||
const source = row.dataset.source;
|
||||
const output = row.querySelector("input[type=text]").value.trim();
|
||||
if (enabled) {
|
||||
if (!output) throw new Error(t("loggingOutputRequired"));
|
||||
if (Object.hasOwn(mapping, output)) throw new Error(t("loggingOutputDuplicate"));
|
||||
mapping[output] = source;
|
||||
}
|
||||
}
|
||||
if (Object.keys(mapping).length === LOGGING_SOURCE_FIELDS.length && LOGGING_SOURCE_FIELDS.every(name => mapping[name] === name)) return {};
|
||||
return mapping;
|
||||
}
|
||||
function renderLoggingPreview() {
|
||||
let mapping;
|
||||
try { mapping = selectedLoggingMapping(); } catch (error) { $("logging-format-preview").textContent = error.message; return; }
|
||||
let staticFields = {}; try { staticFields = parseJsonObjectValue($("log-format-static").value, $("log-format-static")); } catch { }
|
||||
const source = Object.keys(mapping).length ? mapping : Object.fromEntries(LOGGING_SOURCE_FIELDS.map(name => [name, name]));
|
||||
const preview = { ...staticFields };
|
||||
for (const [output, name] of Object.entries(source)) preview[output] = LOGGING_PREVIEW_VALUES[name];
|
||||
$("logging-format-preview").textContent = JSON.stringify(preview, null, 2);
|
||||
}
|
||||
function openLoggingBuilder() {
|
||||
let mapping; try { mapping = parseJsonObjectValue($("log-format-fields").value, $("log-format-fields")); } catch (error) { toast(error.message, true); return; }
|
||||
const identity = !Object.keys(mapping).length;
|
||||
const bySource = new Map();
|
||||
for (const [output, source] of Object.entries(mapping)) if (!bySource.has(source)) bySource.set(source, output);
|
||||
const rows = LOGGING_SOURCE_FIELDS.map(source => {
|
||||
const row = el("tr"); row.dataset.source = source;
|
||||
const checkCell = el("td"), sourceCell = el("td", source), outputCell = el("td");
|
||||
const check = document.createElement("input"); check.type = "checkbox"; check.checked = identity || bySource.has(source);
|
||||
const output = document.createElement("input"); output.type = "text"; output.maxLength = 128; output.value = bySource.get(source) || source; output.disabled = !check.checked;
|
||||
check.addEventListener("change", () => { output.disabled = !check.checked; renderLoggingPreview(); });
|
||||
output.addEventListener("input", renderLoggingPreview);
|
||||
checkCell.append(check); outputCell.append(output); row.append(checkCell, sourceCell, outputCell); return row;
|
||||
});
|
||||
$("logging-field-rows").replaceChildren(...rows); renderLoggingPreview();
|
||||
const dialog = $("logging-format-dialog"); if (!dialog.open) dialog.showModal();
|
||||
}
|
||||
function applyLoggingPreset(name) {
|
||||
if (name === "custom") return;
|
||||
$("log-format-fields").value = JSON.stringify(LOGGING_PRESETS[name], null, 2);
|
||||
setSettingsDirty("logging-form");
|
||||
}
|
||||
const selectLoggingSection = next => ({ logging: next.logging, error_log: next.error_log });
|
||||
bindSettingsForm("logging-form", next => {
|
||||
const parseObject = id => parseJsonObjectValue($(id).value, $(id));
|
||||
next.logging = {
|
||||
capture_user_agent: $("log-capture-user-agent").checked,
|
||||
capture_country: $("log-capture-country").checked,
|
||||
file: { enabled: $("log-file-enabled").checked, path: $("log-file-path").value.trim(), per_vhost: $("log-file-per-vhost").checked, directory: $("log-file-directory").value.trim() },
|
||||
logstash: { enabled: $("log-logstash-enabled").checked, address: $("log-logstash-address").value.trim() },
|
||||
loki: { enabled: $("log-loki-enabled").checked, url: $("log-loki-url").value.trim(), bearer_token_env: $("log-loki-token").value.trim() || null, labels: parseObject("log-loki-labels") },
|
||||
format: { fields: parseObject("log-format-fields"), static_fields: parseObject("log-format-static") }
|
||||
};
|
||||
}, "logging", selectLoggingSection);
|
||||
bindSettingsForm("error-log-form", next => {
|
||||
next.error_log = {
|
||||
enabled: $("error-log-enabled").checked,
|
||||
path: $("error-log-path").value.trim(),
|
||||
format: $("error-log-format").value
|
||||
};
|
||||
}, "logging", selectLoggingSection);
|
||||
bindSettingsForm("forwarding-form", next => { next.forwarding = { trusted_proxies: $("trusted-proxies").value.split(/[\n,]/).map(s => s.trim()).filter(Boolean), trust_forwarded_proto: $("trusted-proto").checked }; }, "forwarding");
|
||||
bindSettingsForm("headers-form", next => { next.headers = { request: collectHeaderRules("request"), response: collectHeaderRules("response") }; }, "headers");
|
||||
$("log-format-preset").addEventListener("change", event => applyLoggingPreset(event.target.value));
|
||||
$("error-log-enabled").addEventListener("change", syncErrorLogMode);
|
||||
$("log-format-open").addEventListener("click", openLoggingBuilder);
|
||||
$("logging-format-close").addEventListener("click", () => $("logging-format-dialog").close());
|
||||
$("logging-format-cancel").addEventListener("click", () => $("logging-format-dialog").close());
|
||||
$("logging-format-apply").addEventListener("click", () => { try { const mapping = selectedLoggingMapping(); $("log-format-fields").value = JSON.stringify(mapping, null, 2); $("log-format-preset").value = detectLoggingPreset(mapping); setSettingsDirty("logging-form"); $("logging-format-dialog").close(); } catch (error) { toast(error.message, true); } });
|
||||
$("server-mode").addEventListener("change", syncServerMode);
|
||||
$("log-file-enabled").addEventListener("change", syncLoggingFileMode);
|
||||
$("log-file-per-vhost").addEventListener("change", syncLoggingFileMode);
|
||||
for (const id of ["cache-cold-enabled", "cache-cold-on-idle", "cache-cold-on-age"]) $(id).addEventListener("change", syncColdCachePolicy);
|
||||
document.querySelectorAll("[data-cache-memory-preset]").forEach(button => button.addEventListener("click", () => applyCacheMemoryPreset(button.dataset.cacheMemoryPreset)));
|
||||
document.querySelectorAll("[data-cache-cold-preset]").forEach(button => button.addEventListener("click", applyRecommendedColdCachePreset));
|
||||
document.querySelectorAll("[data-transform-preset]").forEach(button => button.addEventListener("click", () => applyTransformPreset(button.dataset.transformPreset)));
|
||||
for (const id of ["cache-memory", "cache-transient-memory", "cache-object", "cache-entries", "cache-eviction-limit"]) $(id).addEventListener("input", () => { setCacheMemoryPresetSelection(null); updateCacheMemorySizing(); });
|
||||
$("cache-cold-queue-bytes").addEventListener("input", () => { setCacheMemoryPresetSelection(null); setCacheColdPresetSelection(null); updateCacheMemorySizing(); });
|
||||
for (const id of ["cache-cold-enabled", "cache-cold-on-idle", "cache-cold-on-age", "cache-cold-on-lru", "cache-cold-on-shutdown", "cache-cold-after", "cache-cold-max-hot", "cache-cold-gc-interval", "cache-cold-gc-max-delete"]) {
|
||||
for (const event of ["input", "change"]) $(id).addEventListener(event, () => setCacheColdPresetSelection(null));
|
||||
}
|
||||
for (const id of ["transform-memory-budget", "transform-max-body", "transform-max-inflight"]) $(id).addEventListener("input", () => { setTransformPresetSelection(null); updateTransformResourceSizing(); });
|
||||
for (const id of ["cache-collapse-enabled", "cache-proactive-enabled"]) $(id).addEventListener("change", syncAdvancedCachePolicy);
|
||||
syncColdCachePolicy();
|
||||
syncAdvancedCachePolicy();
|
||||
$("purge-cache").addEventListener("click", async () => { if (!(await appConfirm(t("confirmPurge"), { danger: true }))) return; const button = $("purge-cache"); button.disabled = true; try { const result = await api("/api/cache/purge", { method: "POST", body: "{}" }); toast(`${t("cachePurged")}: ${result.purged_entries}`); await refresh(true); } catch (error) { toast(error.message, true); } finally { button.disabled = false; } });
|
||||
$("add-response-header").addEventListener("click", () => appendHeaderRule("response"));
|
||||
$("add-request-header").addEventListener("click", () => appendHeaderRule("request"));
|
||||
window.addEventListener("beforeunload", event => { if (settingsDirty.size) { event.preventDefault(); event.returnValue = ""; } });
|
||||
+44
-28
@@ -967,34 +967,50 @@
|
||||
<label class="check cache-live-toggle"><input checked="" id="traffic-live" type="checkbox" />
|
||||
<span data-i18n="cacheLive">Live (3 s)</span></label></div>
|
||||
</div>
|
||||
<div class="stats-grid cache-stats">
|
||||
<article class="stat"><span data-i18n="requests">Requests</span><strong id="traffic-requests">-</strong><small data-i18n="trafficSinceStart">Since process start</small></article>
|
||||
<article class="stat"><span data-i18n="errors">Errors</span><strong id="traffic-errors">-</strong><small id="traffic-error-ratio">-</small></article>
|
||||
<article class="stat"><span data-i18n="trafficResponseBytes">Response bytes</span><strong id="traffic-response-bytes">-</strong><small id="traffic-request-bytes">-</small></article>
|
||||
<article class="stat"><span data-i18n="trafficMeanLatency">Mean latency</span><strong id="traffic-latency">-</strong><small id="traffic-dropped">-</small></article>
|
||||
<nav aria-label="Traffic analytics" class="settings-tabs sub-tabs traffic-tabs" id="traffic-tabs" role="tablist">
|
||||
<button aria-selected="true" class="active" data-i18n="trafficTabOverview" data-traffic-tab="overview" role="tab" type="button">Overview</button>
|
||||
<button aria-selected="false" data-i18n="trafficTabClients" data-traffic-tab="clients" role="tab" type="button">Clients</button>
|
||||
<button aria-selected="false" data-i18n="trafficTabRequests" data-traffic-tab="requests" role="tab" type="button">Requests</button>
|
||||
<button aria-selected="false" data-i18n="trafficTabDelivery" data-traffic-tab="delivery" role="tab" type="button">Delivery</button>
|
||||
</nav>
|
||||
<div data-traffic-page="overview">
|
||||
<div class="stats-grid cache-stats">
|
||||
<article class="stat"><span data-i18n="requests">Requests</span><strong id="traffic-requests">-</strong><small data-i18n="trafficSinceStart">Since process start</small></article>
|
||||
<article class="stat"><span data-i18n="errors">Errors</span><strong id="traffic-errors">-</strong><small id="traffic-error-ratio">-</small></article>
|
||||
<article class="stat"><span data-i18n="trafficResponseBytes">Response bytes</span><strong id="traffic-response-bytes">-</strong><small id="traffic-request-bytes">-</small></article>
|
||||
<article class="stat"><span data-i18n="trafficMeanLatency">Mean latency</span><strong id="traffic-latency">-</strong><small id="traffic-dropped">-</small></article>
|
||||
</div>
|
||||
<article class="card">
|
||||
<div class="card-heading"><div><span class="small-label" data-i18n="trafficBoundedLabel">BOUNDED / ASYNC</span><h2 data-i18n="trafficDimensionsTitle">Traffic dimensions</h2></div></div>
|
||||
<p class="fine" data-i18n="trafficApproxHelp">Top lists are approximate and bounded so high-cardinality traffic cannot grow proxy memory without limit.</p>
|
||||
<p class="fine" id="traffic-capture-status"></p>
|
||||
</article>
|
||||
<div class="traffic-chart-grid traffic-overview-charts">
|
||||
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficMethods">Methods</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-methods" role="img"><span id="traffic-chart-methods-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-methods-legend"></ol></div></article>
|
||||
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficStatusClasses">Status classes</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-statuses" role="img"><span id="traffic-chart-statuses-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-statuses-legend"></ol></div></article>
|
||||
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficCacheOutcomes">Cache outcomes</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-cache" role="img"><span id="traffic-chart-cache-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-cache-legend"></ol></div></article>
|
||||
</div>
|
||||
</div>
|
||||
<article class="card">
|
||||
<div class="card-heading"><div><span class="small-label" data-i18n="trafficBoundedLabel">BOUNDED / ASYNC</span><h2 data-i18n="trafficDimensionsTitle">Traffic dimensions</h2></div></div>
|
||||
<p class="fine" data-i18n="trafficApproxHelp">Top lists are approximate and bounded so high-cardinality traffic cannot grow proxy memory without limit.</p>
|
||||
<p class="fine" id="traffic-capture-status"></p>
|
||||
</article>
|
||||
<div class="traffic-chart-grid">
|
||||
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficTopCountries">Top countries</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-countries" role="img"><span id="traffic-chart-countries-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-countries-legend"></ol></div></article>
|
||||
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficTopUserAgents">Top User-Agents</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-user-agents" role="img"><span id="traffic-chart-user-agents-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-user-agents-legend"></ol></div></article>
|
||||
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficTopClientIps">Top client IPs</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-client-ips" role="img"><span id="traffic-chart-client-ips-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-client-ips-legend"></ol></div></article>
|
||||
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficMethods">Methods</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-methods" role="img"><span id="traffic-chart-methods-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-methods-legend"></ol></div></article>
|
||||
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficStatusClasses">Status classes</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-statuses" role="img"><span id="traffic-chart-statuses-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-statuses-legend"></ol></div></article>
|
||||
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficCacheOutcomes">Cache outcomes</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-cache" role="img"><span id="traffic-chart-cache-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-cache-legend"></ol></div></article>
|
||||
<div data-traffic-page="clients" hidden>
|
||||
<div class="traffic-chart-grid traffic-clients-grid">
|
||||
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficTopCountries">Top countries</h3><div class="traffic-donut-layout"><div class="traffic-donut" id="traffic-chart-countries" role="img"><span id="traffic-chart-countries-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-countries-legend"></ol></div><p class="empty" id="traffic-chart-countries-empty" data-i18n="trafficNoCountryData">Country collection is disabled or no GeoIP data is available.</p></article>
|
||||
<article class="card traffic-chart-card traffic-wide-chart"><h3 class="subheading" data-i18n="trafficTopUserAgents">Top User-Agents</h3><div class="traffic-bar-chart" id="traffic-chart-user-agents" role="img"></div><p class="empty" id="traffic-chart-user-agents-empty" data-i18n="trafficNoUserAgentData">No User-Agent data collected.</p></article>
|
||||
<article class="card traffic-chart-card traffic-wide-chart"><h3 class="subheading" data-i18n="trafficTopClientIps">Top client IPs</h3><div class="traffic-bar-chart" id="traffic-chart-client-ips" role="img"></div><p class="empty" id="traffic-chart-client-ips-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
|
||||
</div>
|
||||
</div>
|
||||
<div class="logging-grid">
|
||||
<article class="card"><h3 class="subheading" data-i18n="trafficTopCountries">Top countries</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficValue">Value</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-countries"></tbody></table></div><p class="empty" id="traffic-countries-empty" data-i18n="trafficNoCountryData">Country collection is disabled or no GeoIP data is available.</p></article>
|
||||
<article class="card"><h3 class="subheading" data-i18n="trafficTopUserAgents">Top User-Agents</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th>User-Agent</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-user-agents"></tbody></table></div><p class="empty" id="traffic-user-agents-empty" data-i18n="trafficNoUserAgentData">No User-Agent data collected.</p></article>
|
||||
<article class="card"><h3 class="subheading" data-i18n="trafficTopClientIps">Top client IPs</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficClientIp">Client IP</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-client-ips"></tbody></table></div><p class="empty" id="traffic-client-ips-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
|
||||
<article class="card"><h3 class="subheading" data-i18n="trafficTopPaths">Top paths</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficValue">Value</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-paths"></tbody></table></div><p class="empty" id="traffic-paths-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
|
||||
<article class="card"><h3 class="subheading" data-i18n="trafficTopRoutes">Top routes</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficValue">Value</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-routes"></tbody></table></div><p class="empty" id="traffic-routes-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
|
||||
<article class="card"><h3 class="subheading" data-i18n="trafficMethods">Methods</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficValue">Value</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-methods"></tbody></table></div><p class="empty" id="traffic-methods-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
|
||||
<article class="card"><h3 class="subheading" data-i18n="trafficStatusClasses">Status classes</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficValue">Value</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-statuses"></tbody></table></div><p class="empty" id="traffic-statuses-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
|
||||
<article class="card"><h3 class="subheading" data-i18n="trafficCacheOutcomes">Cache outcomes</h3><div class="table-scroll"><table class="cache-table"><thead><tr><th data-i18n="trafficValue">Value</th><th data-i18n="requests">Requests</th></tr></thead><tbody id="traffic-cache"></tbody></table></div><p class="empty" id="traffic-cache-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
|
||||
<div data-traffic-page="requests" hidden>
|
||||
<div class="traffic-chart-grid traffic-request-grid">
|
||||
<article class="card traffic-chart-card traffic-wide-chart"><h3 class="subheading" data-i18n="trafficTopPaths">Top paths</h3><div class="traffic-bar-chart" id="traffic-chart-paths" role="img"></div><p class="empty" id="traffic-chart-paths-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
|
||||
<article class="card traffic-chart-card traffic-wide-chart"><h3 class="subheading" data-i18n="trafficTopRoutes">Top routes</h3><div class="traffic-bar-chart" id="traffic-chart-routes" role="img"></div><p class="empty" id="traffic-chart-routes-empty" data-i18n="trafficNoData">No traffic data yet.</p></article>
|
||||
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficMethods">Methods</h3><div class="traffic-donut-layout"><div class="traffic-donut traffic-donut-mirror" id="traffic-chart-methods-detail" role="img"><span id="traffic-chart-methods-detail-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-methods-detail-legend"></ol></div></article>
|
||||
</div>
|
||||
</div>
|
||||
<div data-traffic-page="delivery" hidden>
|
||||
<div class="traffic-chart-grid traffic-delivery-grid">
|
||||
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficStatusClasses">Status classes</h3><div class="traffic-donut-layout"><div class="traffic-donut traffic-donut-mirror" id="traffic-chart-statuses-detail" role="img"><span id="traffic-chart-statuses-detail-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-statuses-detail-legend"></ol></div></article>
|
||||
<article class="card traffic-chart-card"><h3 class="subheading" data-i18n="trafficCacheOutcomes">Cache outcomes</h3><div class="traffic-donut-layout"><div class="traffic-donut traffic-donut-mirror" id="traffic-chart-cache-detail" role="img"><span id="traffic-chart-cache-detail-total">-</span></div><ol class="traffic-chart-legend" id="traffic-chart-cache-detail-legend"></ol></div></article>
|
||||
<article class="card traffic-chart-card traffic-wide-chart"><h3 class="subheading" data-i18n="trafficVolume">Traffic volume</h3><div class="traffic-bar-chart" id="traffic-chart-volume" role="img"></div></article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section class="panel" hidden="" id="panel-cache">
|
||||
@@ -1456,7 +1472,7 @@
|
||||
<option data-i18n="serverCustom" 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/v0.25.0"
|
||||
id="server-value" maxlength="256" placeholder="RustProxy/v0.25.1"
|
||||
spellcheck="false" />
|
||||
<h3 class="subheading" data-i18n="diagnosticHeaders">Diagnostic headers</h3>
|
||||
<div class="switch-grid">
|
||||
@@ -2203,7 +2219,7 @@
|
||||
</section>
|
||||
</div>
|
||||
</section>
|
||||
<footer class="page-footer"><span>RustProxy <b>0.25.0</b></span><span id="footer-status">API - /
|
||||
<footer class="page-footer"><span>RustProxy <b>0.25.1</b></span><span id="footer-status">API - /
|
||||
dropped: 0</span></footer>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
@@ -4637,6 +4637,68 @@ dialog .window-heading {
|
||||
flex: 0 0 auto
|
||||
}
|
||||
|
||||
#panel-traffic .traffic-tabs {
|
||||
margin: 12px 0 14px
|
||||
}
|
||||
|
||||
#panel-traffic [data-traffic-page] {
|
||||
min-width: 0
|
||||
}
|
||||
|
||||
.traffic-wide-chart {
|
||||
grid-column: span 2
|
||||
}
|
||||
|
||||
.traffic-bar-chart {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
min-width: 0
|
||||
}
|
||||
|
||||
.traffic-bar-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 1.5fr) minmax(120px, 3fr) minmax(72px, auto);
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
min-width: 0
|
||||
}
|
||||
|
||||
.traffic-bar-label {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font: 11px var(--mono)
|
||||
}
|
||||
|
||||
.traffic-bar-track {
|
||||
height: 10px;
|
||||
border-radius: 999px;
|
||||
background: var(--panel2);
|
||||
box-shadow: inset 0 0 0 1px var(--line);
|
||||
overflow: hidden
|
||||
}
|
||||
|
||||
.traffic-bar-fill {
|
||||
display: block;
|
||||
height: 100%;
|
||||
min-width: 2px;
|
||||
border-radius: inherit;
|
||||
background: var(--accent)
|
||||
}
|
||||
|
||||
.traffic-bar-value {
|
||||
text-align: right;
|
||||
font: 700 10px var(--mono)
|
||||
}
|
||||
|
||||
@media(max-width:1100px) {
|
||||
.traffic-wide-chart { grid-column: span 1 }
|
||||
}
|
||||
|
||||
@media(max-width:650px) {
|
||||
.traffic-bar-row { grid-template-columns: minmax(88px, 1.2fr) minmax(90px, 2fr) minmax(60px, auto); gap: 7px }
|
||||
}
|
||||
|
||||
.logging-grid label {
|
||||
min-width: 0
|
||||
}
|
||||
|
||||
@@ -1,135 +1,127 @@
|
||||
"use strict";
|
||||
(() => {
|
||||
let loading = false, timer = null, lastData = null;
|
||||
let loading = false, timer = null, lastData = null, activeTab = "overview";
|
||||
const chartPalette = ["var(--accent)", "var(--green)", "var(--blue)", "var(--transform-chart)", "var(--red)", "var(--notice-text)", "var(--border-hover)", "var(--separator)"];
|
||||
const scope = () => $("traffic-scope")?.value || "*";
|
||||
const number = value => fmt(Number(value) || 0);
|
||||
|
||||
function selectTab(name) {
|
||||
activeTab = name;
|
||||
document.querySelectorAll("[data-traffic-tab]").forEach(button => {
|
||||
const active = button.dataset.trafficTab === name;
|
||||
button.classList.toggle("active", active);
|
||||
button.setAttribute("aria-selected", String(active));
|
||||
button.tabIndex = active ? 0 : -1;
|
||||
});
|
||||
document.querySelectorAll("[data-traffic-page]").forEach(page => { page.hidden = page.dataset.trafficPage !== name; });
|
||||
}
|
||||
|
||||
function configure(data) {
|
||||
const selected = scope(), options = [el("option", t("logAllVhosts"))];
|
||||
options[0].value = "*";
|
||||
for (const vhost of data.vhosts || []) {
|
||||
const option = el("option", `${vhost.id} / ${(vhost.hosts || []).join(", ")}`);
|
||||
option.value = vhost.id;
|
||||
options.push(option);
|
||||
option.value = vhost.id; options.push(option);
|
||||
}
|
||||
$("traffic-scope").replaceChildren(...options);
|
||||
$("traffic-scope").value = options.some(option => option.value === selected) ? selected : "*";
|
||||
}
|
||||
function renderTop(id, entries, enabled = true) {
|
||||
const body = $(`traffic-${id}`), empty = $(`traffic-${id}-empty`);
|
||||
const values = enabled ? (entries || []) : [];
|
||||
body.replaceChildren(...values.map(item => {
|
||||
const row = el("tr");
|
||||
const value = el("td", item.value || "-"); value.title = item.value || "-";
|
||||
row.append(value, el("td", number(item.count)));
|
||||
return row;
|
||||
}));
|
||||
empty.hidden = values.length > 0;
|
||||
|
||||
function chartEntries(entries, enabled = true) {
|
||||
return (enabled ? (entries || []) : []).filter(item => Number(item.count) > 0);
|
||||
}
|
||||
|
||||
function renderDonut(id, entries, enabled = true) {
|
||||
const chart = $(`traffic-chart-${id}`), legend = $(`traffic-chart-${id}-legend`), totalNode = $(`traffic-chart-${id}-total`);
|
||||
if (!chart || !legend || !totalNode) return;
|
||||
const values = (enabled ? (entries || []) : []).filter(item => Number(item.count) > 0);
|
||||
const empty = $(`traffic-chart-${id}-empty`), values = chartEntries(entries, enabled);
|
||||
const total = values.reduce((sum, item) => sum + Number(item.count || 0), 0);
|
||||
totalNode.textContent = number(total);
|
||||
totalNode.textContent = number(total); if (empty) empty.hidden = total > 0;
|
||||
if (!total) {
|
||||
chart.style.background = "var(--panel2)";
|
||||
chart.setAttribute("aria-label", t("trafficNoData"));
|
||||
legend.replaceChildren(el("li", t("trafficNoData"), "fine"));
|
||||
return;
|
||||
chart.style.background = "var(--panel2)"; chart.setAttribute("aria-label", t("trafficNoData"));
|
||||
legend.replaceChildren(el("li", t("trafficNoData"), "fine")); return;
|
||||
}
|
||||
const shown = values.slice(0, 7).map(item => ({ value: item.value || "-", count: Number(item.count) || 0 }));
|
||||
if (values.length > shown.length) {
|
||||
shown.push({ value: t("trafficOther"), count: values.slice(shown.length).reduce((sum, item) => sum + Number(item.count || 0), 0) });
|
||||
}
|
||||
if (values.length > shown.length) shown.push({ value: t("trafficOther"), count: values.slice(shown.length).reduce((sum, item) => sum + Number(item.count || 0), 0) });
|
||||
let cursor = 0;
|
||||
const segments = shown.map((item, index) => {
|
||||
const start = cursor / total * 100;
|
||||
cursor += item.count;
|
||||
const end = cursor / total * 100;
|
||||
chart.style.background = `conic-gradient(${shown.map((item, index) => {
|
||||
const start = cursor / total * 100; cursor += item.count; const end = cursor / total * 100;
|
||||
return `${chartPalette[index % chartPalette.length]} ${start.toFixed(4)}% ${end.toFixed(4)}%`;
|
||||
});
|
||||
chart.style.background = `conic-gradient(${segments.join(", ")})`;
|
||||
}).join(", ")})`;
|
||||
const heading = chart.closest(".traffic-chart-card")?.querySelector("h3")?.textContent || t("trafficDimensionsTitle");
|
||||
chart.setAttribute("aria-label", `${heading}: ${shown.map(item => `${item.value} ${Math.round(item.count / total * 100)}%`).join(", ")}`);
|
||||
legend.replaceChildren(...shown.map((item, index) => {
|
||||
const row = el("li"), label = el("span"), swatch = el("i", undefined, "traffic-chart-swatch");
|
||||
swatch.style.background = chartPalette[index % chartPalette.length];
|
||||
const text = el("span", item.value || "-", "traffic-chart-label"); text.title = item.value || "-";
|
||||
label.append(swatch, text);
|
||||
row.append(label, el("b", `${(item.count / total * 100).toFixed(1)}%`));
|
||||
return row;
|
||||
label.append(swatch, text); row.append(label, el("b", `${(item.count / total * 100).toFixed(1)}%`)); return row;
|
||||
}));
|
||||
}
|
||||
|
||||
function renderBars(id, entries, enabled = true, formatter = number) {
|
||||
const chart = $(`traffic-chart-${id}`); if (!chart) return;
|
||||
const empty = $(`traffic-chart-${id}-empty`), values = chartEntries(entries, enabled).slice(0, 12);
|
||||
const total = values.reduce((sum, item) => sum + Number(item.count || 0), 0), peak = Math.max(1, ...values.map(item => Number(item.count) || 0));
|
||||
if (empty) empty.hidden = values.length > 0;
|
||||
chart.replaceChildren(...values.map(item => {
|
||||
const count = Number(item.count) || 0, row = el("div", undefined, "traffic-bar-row");
|
||||
const label = el("span", item.value || "-", "traffic-bar-label"); label.title = item.value || "-";
|
||||
const track = el("span", undefined, "traffic-bar-track"), fill = el("i", undefined, "traffic-bar-fill"); fill.style.width = `${Math.max(1.5, count / peak * 100)}%`; track.append(fill);
|
||||
const value = el("span", formatter(count), "traffic-bar-value"); value.title = total ? `${(count / total * 100).toFixed(1)}%` : "";
|
||||
row.append(label, track, value); return row;
|
||||
}));
|
||||
chart.setAttribute("aria-label", values.length ? values.map(item => `${item.value}: ${formatter(Number(item.count) || 0)}`).join(", ") : t("trafficNoData"));
|
||||
}
|
||||
|
||||
function render(data) {
|
||||
lastData = data;
|
||||
configure(data);
|
||||
lastData = data; configure(data);
|
||||
const analytics = data.analytics || {}, stats = analytics.data || {}, settings = data.settings || {};
|
||||
$("traffic-requests").textContent = number(stats.requests);
|
||||
$("traffic-errors").textContent = number(stats.errors);
|
||||
$("traffic-requests").textContent = number(stats.requests); $("traffic-errors").textContent = number(stats.errors);
|
||||
const requests = Number(stats.requests) || 0, errors = Number(stats.errors) || 0;
|
||||
$("traffic-error-ratio").textContent = `${requests ? (errors / requests * 100).toFixed(2) : "0.00"}%`;
|
||||
$("traffic-response-bytes").textContent = fmtBytes(Number(stats.response_bytes) || 0);
|
||||
$("traffic-request-bytes").textContent = `${t("trafficRequestBytes")}: ${fmtBytes(Number(stats.request_bytes) || 0)}`;
|
||||
$("traffic-latency").textContent = stats.mean_duration_ms == null ? "-" : `${fmt(stats.mean_duration_ms, 2)} ms`;
|
||||
$("traffic-dropped").textContent = `${t("trafficDroppedSamples")}: ${number(analytics.queue_dropped)}`;
|
||||
const capture = [];
|
||||
capture.push(settings.capture_user_agent ? t("trafficUserAgentEnabled") : t("trafficUserAgentDisabled"));
|
||||
const capture = [settings.capture_user_agent ? t("trafficUserAgentEnabled") : t("trafficUserAgentDisabled")];
|
||||
capture.push(settings.capture_country ? (data.geoip?.loaded ? t("trafficCountryEnabled") : t("trafficCountryNoDb")) : t("trafficCountryDisabled"));
|
||||
$("traffic-capture-status").textContent = capture.join(" · ");
|
||||
renderTop("countries", stats.countries, !!settings.capture_country);
|
||||
renderTop("user-agents", stats.user_agents, !!settings.capture_user_agent);
|
||||
renderTop("client-ips", stats.client_ips);
|
||||
renderTop("paths", stats.paths);
|
||||
renderTop("routes", stats.routes);
|
||||
renderTop("methods", stats.methods);
|
||||
renderTop("statuses", stats.statuses);
|
||||
renderTop("cache", stats.cache);
|
||||
|
||||
renderDonut("countries", stats.countries, !!settings.capture_country);
|
||||
renderDonut("user-agents", stats.user_agents, !!settings.capture_user_agent);
|
||||
renderDonut("client-ips", stats.client_ips);
|
||||
renderDonut("methods", stats.methods);
|
||||
renderDonut("statuses", stats.statuses);
|
||||
renderDonut("cache", stats.cache);
|
||||
renderBars("user-agents", stats.user_agents, !!settings.capture_user_agent);
|
||||
renderBars("client-ips", stats.client_ips);
|
||||
renderBars("paths", stats.paths); renderBars("routes", stats.routes);
|
||||
renderDonut("methods", stats.methods); renderDonut("methods-detail", stats.methods);
|
||||
renderDonut("statuses", stats.statuses); renderDonut("statuses-detail", stats.statuses);
|
||||
renderDonut("cache", stats.cache); renderDonut("cache-detail", stats.cache);
|
||||
renderBars("volume", [
|
||||
{ value: t("trafficRequestBytes"), count: Number(stats.request_bytes) || 0 },
|
||||
{ value: t("trafficResponseBytes"), count: Number(stats.response_bytes) || 0 },
|
||||
], true, fmtBytes);
|
||||
$("traffic-panel-state").textContent = `${t("trafficUpdated")}: ${new Date().toLocaleTimeString(locale())}`;
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
if (!authenticated || loading) return;
|
||||
loading = true;
|
||||
try {
|
||||
const selected = scope();
|
||||
const data = await api(`/api/traffic?vhost=${encodeURIComponent(selected)}`);
|
||||
render(data);
|
||||
} catch (error) {
|
||||
$("traffic-panel-state").textContent = error.message;
|
||||
} finally { loading = false; }
|
||||
if (!authenticated || loading) return; loading = true;
|
||||
try { render(await api(`/api/traffic?vhost=${encodeURIComponent(scope())}`)); }
|
||||
catch (error) { $("traffic-panel-state").textContent = error.message; }
|
||||
finally { loading = false; }
|
||||
}
|
||||
|
||||
function reset() {
|
||||
lastData = null;
|
||||
if ($("traffic-panel-state")) $("traffic-panel-state").textContent = "";
|
||||
for (const id of ["countries", "user-agents", "client-ips", "paths", "routes", "methods", "statuses", "cache"]) {
|
||||
$(`traffic-${id}`)?.replaceChildren();
|
||||
}
|
||||
for (const id of ["countries", "user-agents", "client-ips", "methods", "statuses", "cache"]) {
|
||||
lastData = null; if ($("traffic-panel-state")) $("traffic-panel-state").textContent = "";
|
||||
document.querySelectorAll(".traffic-bar-chart").forEach(chart => chart.replaceChildren());
|
||||
for (const id of ["countries", "methods", "methods-detail", "statuses", "statuses-detail", "cache", "cache-detail"]) {
|
||||
const chart = $(`traffic-chart-${id}`), legend = $(`traffic-chart-${id}-legend`), total = $(`traffic-chart-${id}-total`);
|
||||
if (chart) chart.style.background = "var(--panel2)";
|
||||
legend?.replaceChildren();
|
||||
if (total) total.textContent = "-";
|
||||
if (chart) chart.style.background = "var(--panel2)"; legend?.replaceChildren(); if (total) total.textContent = "-";
|
||||
}
|
||||
selectTab("overview");
|
||||
}
|
||||
function startTimer() {
|
||||
if (timer) return;
|
||||
timer = setInterval(() => {
|
||||
if (!authenticated || !$("traffic-live")?.checked || $("panel-traffic")?.hidden) return;
|
||||
void refresh();
|
||||
}, 3000);
|
||||
}
|
||||
$("traffic-refresh")?.addEventListener("click", () => void refresh());
|
||||
$("traffic-scope")?.addEventListener("change", () => void refresh());
|
||||
$("traffic-settings")?.addEventListener("click", () => {
|
||||
setPanel("settings");
|
||||
window.rustProxySettings?.select("logging");
|
||||
});
|
||||
startTimer();
|
||||
window.rustProxyTrafficPanel = { refresh, reset, rerender: () => lastData && render(lastData) };
|
||||
|
||||
document.querySelectorAll("[data-traffic-tab]").forEach(button => button.addEventListener("click", () => selectTab(button.dataset.trafficTab)));
|
||||
$("traffic-refresh")?.addEventListener("click", () => void refresh()); $("traffic-scope")?.addEventListener("change", () => void refresh());
|
||||
$("traffic-settings")?.addEventListener("click", () => { setPanel("settings"); window.rustProxySettings?.select("logging"); });
|
||||
timer = setInterval(() => { if (authenticated && $("traffic-live")?.checked && !$("panel-traffic")?.hidden) void refresh(); }, 3000);
|
||||
selectTab(activeTab);
|
||||
window.rustProxyTrafficPanel = { refresh, reset, rerender: () => lastData && render(lastData), select: selectTab };
|
||||
})();
|
||||
@@ -1,981 +0,0 @@
|
||||
"use strict";
|
||||
// Vhost UI: language content comes from langs/*.json, never a locale switch.
|
||||
// Drafts are isolated from SSE/config rendering; writes share revision+CSRF CAS.
|
||||
(() => {
|
||||
let basicEditor = null, locationsReader = () => [], aclControl = null, editing = null, revision = null, dirty = false, initialized = false, readBindings = () => [], readHosts = () => [], countryRatesReader = () => [], pagesEditor = null, stagedCertificates = [], uploading = false, draftEpoch = 0;
|
||||
const chartHistory = new Map();
|
||||
const backendTrafficHistory = new Map();
|
||||
const vhostTrafficHistory = new Map();
|
||||
const dialog = $("vhost-dialog");
|
||||
const form = $("vhost-form"), container = $("vhost-fields");
|
||||
const text = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; };
|
||||
const field = (parent, name, key, value, type = "text", options = {}) => {
|
||||
const label = el("label", undefined, type === "checkbox" ? "check vh-check" : "vh-field");
|
||||
const input = document.createElement(type === "textarea" ? "textarea" : type === "select" ? "select" : "input");
|
||||
input.name = name;
|
||||
if (type === "select") for (const [val, key] of options.values || []) { const option = text("option", key); option.value = val; input.append(option); }
|
||||
else if (type !== "textarea") input.type = type;
|
||||
if (type === "checkbox") input.checked = Boolean(value); else input.value = value ?? "";
|
||||
for (const attr of ["min", "max", "step", "maxLength", "rows", "required", "pattern", "readOnly"]) if (Object.hasOwn(options, attr)) input[attr] = options[attr];
|
||||
if (type === "textarea") input.spellcheck = false;
|
||||
if (type === "checkbox") label.append(input, text("span", key)); else label.append(text("span", key), input);
|
||||
parent.append(label); return input;
|
||||
};
|
||||
const group = (key, advanced = false) => {
|
||||
const box = el(advanced ? "details" : "fieldset", undefined, "vh-group");
|
||||
box.dataset.tab = ({ vhEditor: "details", vhFrontends: "details", vhBackendPool: "backends", vhBackendDiagnostics: "backends", vhProxyTimeouts: "backends", vhBalancing: "backends", vhSticky: "backends", vhHealth: "health", vhPassiveHealth: "health", vhSecurity: "security", vhTrafficPolicy: "security", vhTinyauth: "auth", vhResponsePolicy: "security", vhHeaders: "security", vhAdvanced: "advanced", vhLocations: "locations", vhSsl: "ssl", vhCache: "cache", vhTransform: "transform", vhPages: "pages" })[key] || "advanced"; box.append(text(advanced ? "summary" : "legend", key));
|
||||
const body = el("div", undefined, "vh-grid"); box.append(body); container.append(box); return body;
|
||||
};
|
||||
const note = (parent, key) => { const node = text("p", key, "fine vh-wide"); parent.append(node); return node; };
|
||||
const button = (key, callback, cls = "quiet") => { const b = text("button", key, cls); b.type = "button"; b.addEventListener("click", callback); return b; };
|
||||
const read = (name, scope = form) => scope.querySelector(`[name="${name}"]`);
|
||||
const str = (name, scope) => read(name, scope).value.trim();
|
||||
const num = (name, scope) => { const value = Number(str(name, scope)); if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; };
|
||||
const yes = (name, scope) => read(name, scope).checked;
|
||||
const lines = value => value.split(/[\n,]+/).map(s => s.trim()).filter(Boolean);
|
||||
const nullable = (name, scope) => str(name, scope) || null;
|
||||
const defaultProxyTimeouts = () => ({ connect_timeout_seconds: 10, response_header_timeout_seconds: 120, read_timeout_seconds: 60, send_timeout_seconds: 60 });
|
||||
const changed = () => { dirty = true; };
|
||||
const fillWebServices = (select, chosen = "") => {
|
||||
const first = el("option", t("webServiceSelect")); first.value = "";
|
||||
select.replaceChildren(first, ...(envelope?.config.web_services || []).map(service => { const option = el("option", service.name || service.id); option.value = service.id; return option; }));
|
||||
if (chosen && !Array.from(select.options).some(option => option.value === chosen)) { const missing = el("option", `${chosen} / ${t("locationMissing")}`); missing.value = chosen; select.append(missing); }
|
||||
select.value = chosen;
|
||||
};
|
||||
const allowDiscard = async () => { if (!dirty) return true; if (!(await appConfirm(t("vhUnsaved"), { danger: true, confirmLabel: t("discardAction") }))) return false; dirty = false; return true; };
|
||||
const defaults = () => ({
|
||||
id: "", enabled: true, force_ssl: false, forward_client_ip: true, websockets: false, websocket_legacy_support: false, websocket_rfc8441: false, websocket_rfc9220: false, http2_enabled: true, http3_enabled: true, cloudflare_client_ip: false, timing_headers: false, backend_header: false, backend_timing_header: false,
|
||||
frontend: { hosts: [], listener_ids: [] },
|
||||
source: { type: "reverse_proxy" },
|
||||
backends: [{ id: "origin-1", url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }],
|
||||
cache: { enabled: false }, transform: { enabled: false }, rate_limit: {}, routes: [], headers: {}, acl_profile_id: null, acl: null, tls_policy: null, proxy_timeouts: defaultProxyTimeouts()
|
||||
});
|
||||
|
||||
function activateTab(name) { document.querySelectorAll("#vhost-tabs button").forEach(b => { const active = b.dataset.tab === name; b.classList.toggle("active", active); b.setAttribute("aria-pressed", String(active)); b.setAttribute("aria-selected", String(active)); b.tabIndex = active ? 0 : -1; }); container.querySelectorAll(".vh-group").forEach(g => g.hidden = g.dataset.tab !== name); }
|
||||
function initTabs() { const tabs = $("vhost-tabs"); tabs.replaceChildren(); tabs.setAttribute("role", "tablist"); for (const [name, key] of [["details", "vhDetailsTab"], ["backends", "vhBackendPool"], ["health", "vhHealth"], ["locations", "vhLocations"], ["auth", "vhTabAuth"], ["ssl", "vhSsl"], ["cache", "vhCache"], ["transform", "vhTransform"], ["security", "vhSecurity"], ["pages", "vhPages"], ["advanced", "advanced"]]) { const b = button(key, () => activateTab(name)); b.dataset.tab = name; b.setAttribute("role", "tab"); tabs.append(b); } activateTab("details"); }
|
||||
function collectCache() { const tri = name => str(name) === "" ? null : str(name) === "on", number = (name, mul = 1) => { if (str(name) === "") return null; const value = Number(str(name)) * mul; if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; }; return { enabled: tri("vh-cache-enabled"), max_ttl_seconds: number("vh-cache-ttl"), default_ttl_seconds: number("vh-cache-default"), max_object_bytes: number("vh-cache-object", 1024), bypass_query: tri("vh-cache-query"), static_ignore_cookies: tri("vh-cache-static-cookie"), static_extensions: yes("vh-cache-static-custom") ? lines(str("vh-cache-static-extensions")).map(v => v.replace(/^\./, "").toLowerCase()) : null, expose_status: tri("vh-cache-status"), expose_hits: tri("vh-cache-hits"), expose_cache_status: tri("vh-cache-rfc"), expose_resident_age: tri("vh-cache-resident-age"), bypass_prefixes: yes("vh-cache-prefix-custom") ? lines(str("vh-cache-prefixes")) : null }; }
|
||||
function collectTransform() {
|
||||
const tri = name => str(name) === "" ? null : str(name) === "on";
|
||||
return {
|
||||
enabled: yes("vh-transform-enabled"),
|
||||
javascript: tri("vh-transform-javascript"),
|
||||
css: tri("vh-transform-css"),
|
||||
html: tri("vh-transform-html"),
|
||||
svg: tri("vh-transform-svg"),
|
||||
json: tri("vh-transform-json"),
|
||||
extensions: yes("vh-transform-extensions-custom") ? lines(str("vh-transform-extensions")).map(v => v.replace(/^\./, "").toLowerCase()) : null
|
||||
};
|
||||
}
|
||||
async function purge(id) { if (!(await appConfirm(t("vhPurgeConfirm"), { danger: true }))) return; try { const result = await api(`/api/cache/purge?vhost=${encodeURIComponent(id)}`, { method: "POST", body: "{}" }); toast(`${t("cachePurged")}: ${result.purged_entries}`); await refresh(true); } catch (error) { toast(error.message, true); } }
|
||||
function validateHeaders(value) {
|
||||
const fail = () => { throw new Error(t("headersInvalid")); };
|
||||
if (!value || Array.isArray(value) || typeof value !== "object" || Object.keys(value).some(k => !["request", "response"].includes(k))) fail();
|
||||
for (const direction of ["request", "response"]) {
|
||||
const rules = value[direction] || []; if (!Array.isArray(rules) || rules.length > 32) fail(); for (const rule of rules) {
|
||||
if (!rule || typeof rule !== "object" || Object.keys(rule).some(k => !["op", "name", "value"].includes(k)) || !["set", "append", "remove"].includes(rule.op) || typeof rule.name !== "string" || rule.name.length > 128 || !/^[-!#$%&'*+.^_`|~0-9A-Za-z]+$/.test(rule.name)) fail();
|
||||
const name = rule.name.toLowerCase(); if (["host", "connection", "content-length", "transfer-encoding", "te", "trailer", "content-encoding", "content-range", "upgrade", "keep-alive", "proxy-connection", "proxy-authorization", "proxy-authenticate", "forwarded", "x-real-ip", "x-request-id", "x-rust-proxy-backend", "x-rust-proxy-backend-time", "x-rustproxy-backend-time"].includes(name) || name.startsWith("x-forwarded-") || name.startsWith("sec-websocket-")) throw new Error(t("headersProtected"));
|
||||
if (rule.op === "remove") { if (Object.hasOwn(rule, "value") && rule.value !== null) fail(); } else if (typeof rule.value !== "string" || rule.value.length > 8192 || /[\r\n\0]/.test(rule.value)) fail();
|
||||
}
|
||||
} return value;
|
||||
}
|
||||
function headerEditors(parent, initial, target = read("headers"), onChange = changed) {
|
||||
const holders = {}, adders = {}, emptyStates = {};
|
||||
const renumber = direction => {
|
||||
const rows = holders[direction];
|
||||
[...rows.children].forEach((row, index) => {
|
||||
const title = row.querySelector(".vh-header-rule-title");
|
||||
if (title) title.textContent = `${t("headerRule")} ${index + 1}`;
|
||||
});
|
||||
if (emptyStates[direction]) emptyStates[direction].hidden = rows.children.length > 0;
|
||||
};
|
||||
const sync = () => {
|
||||
const result = {};
|
||||
for (const direction of ["request", "response"]) result[direction] = [...holders[direction].children].map(row => {
|
||||
const op = str("rule-op", row), name = str("rule-name", row), value = read("rule-value", row).value;
|
||||
return op === "remove" ? { op, name } : { op, name, value };
|
||||
});
|
||||
target.value = JSON.stringify(result, null, 2); target.setCustomValidity(""); onChange();
|
||||
};
|
||||
for (const direction of ["request", "response"]) {
|
||||
const block = el("section", undefined, "vh-wide location-header-direction"); block.dataset.headerDirection = direction;
|
||||
const heading = el("div", undefined, "location-header-direction-head");
|
||||
heading.append(text("h3", direction === "response" ? "locationResponseHeaders" : "locationProxyHeaders"), text("p", "headerRuleHelp", "fine"));
|
||||
block.append(heading);
|
||||
const empty = text("p", "headerRulesEmpty", "fine vh-header-empty"); emptyStates[direction] = empty; block.append(empty);
|
||||
const rows = el("div", undefined, "vh-header-rows"); holders[direction] = rows; block.append(rows); parent.append(block);
|
||||
const add = (value = { op: "set", name: "", value: "" }) => {
|
||||
if (rows.children.length >= 32) { toast(t("headerLimit"), true); return; }
|
||||
const row = el("article", undefined, "vh-header-row");
|
||||
const rowHead = el("div", undefined, "vh-header-rule-head"), rowTitle = el("strong", "", "vh-header-rule-title");
|
||||
rowHead.append(rowTitle, button("remove", () => { row.remove(); renumber(direction); sync(); })); row.append(rowHead);
|
||||
const fields = el("div", undefined, "vh-header-rule-fields");
|
||||
field(fields, "rule-op", "headerOperation", value.op, "select", { values: [["set", "header_set"], ["append", "header_append"], ["remove", "header_remove"]] });
|
||||
const name = field(fields, "rule-name", "headerName", value.name, "text", { maxLength: 128 }); name.placeholder = "X-App-Header";
|
||||
const ruleValue = field(fields, "rule-value", "headerValue", value.value ?? "", "text", { maxLength: 8192 }); ruleValue.placeholder = "value";
|
||||
row.append(fields);
|
||||
const enabled = () => { ruleValue.disabled = str("rule-op", row) === "remove"; row.classList.toggle("is-remove", ruleValue.disabled); };
|
||||
row.addEventListener("change", () => { enabled(); sync(); }); row.addEventListener("input", sync); rows.append(row); enabled(); renumber(direction);
|
||||
};
|
||||
adders[direction] = add; for (const rule of initial[direction] || []) add(rule); renumber(direction);
|
||||
block.append(button("addHeader", () => { add(); sync(); }, "quiet location-add-header"));
|
||||
}
|
||||
target.addEventListener("input", () => {
|
||||
try {
|
||||
const value = validateHeaders(JSON.parse(target.value));
|
||||
for (const direction of ["request", "response"]) {
|
||||
holders[direction].replaceChildren();
|
||||
for (const rule of value[direction] || []) adders[direction](rule);
|
||||
renumber(direction);
|
||||
}
|
||||
target.setCustomValidity(""); onChange();
|
||||
} catch (error) { target.setCustomValidity(error.message || t("headersInvalid")); }
|
||||
});
|
||||
note(parent, "locationHeadersHelp");
|
||||
return { read: () => validateHeaders(JSON.parse(target.value)) };
|
||||
}
|
||||
async function closeEditor() { if (!(await allowDiscard())) return false; draftEpoch++; form.querySelectorAll('[type="password"]').forEach(input => input.value = ""); dialog.close(); return true; }
|
||||
$("vhost-close").addEventListener("click", () => void closeEditor()); dialog.addEventListener("cancel", event => { event.preventDefault(); void closeEditor(); });
|
||||
function administrationListenCandidates() {
|
||||
return [
|
||||
envelope?.config.administration?.listen,
|
||||
latestStatus?.administration?.locked_listen,
|
||||
latestStatus?.administration?.desired?.listen,
|
||||
latestStatus?.administration?.active?.listen,
|
||||
latestStatus?.admin_listen,
|
||||
].map(value => String(value || "").trim()).filter((value, index, values) => value && values.indexOf(value) === index);
|
||||
}
|
||||
function adminListenerBackendCandidate(raw) {
|
||||
const splitSocket = value => {
|
||||
const textValue = String(value || "").trim();
|
||||
if (!textValue) return null;
|
||||
if (textValue.startsWith("[")) {
|
||||
const match = textValue.match(/^\[([^\]]+)\]:(\d+)$/); return match ? { host: match[1].toLowerCase(), port: Number(match[2]) } : null;
|
||||
}
|
||||
const at = textValue.lastIndexOf(":"); if (at <= 0) return null;
|
||||
const port = Number(textValue.slice(at + 1)); return Number.isInteger(port) ? { host: textValue.slice(0, at).toLowerCase(), port } : null;
|
||||
};
|
||||
const adminListeners = administrationListenCandidates().map(splitSocket).filter(Boolean);
|
||||
if (!adminListeners.length) return null;
|
||||
let parsed; try { parsed = new URL(String(raw || "").trim()); } catch { return false; }
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
|
||||
const port = parsed.port ? Number(parsed.port) : parsed.protocol === 'https:' ? 443 : 80;
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
||||
const loopback = value => value === 'localhost' || value === '::1' || /^127(?:\.\d{1,3}){3}$/.test(value);
|
||||
return adminListeners.some(admin => {
|
||||
if (port !== admin.port) return false;
|
||||
const wildcard = admin.host === '0.0.0.0' || admin.host === '::' || admin.host === '[::]';
|
||||
return host === admin.host || (wildcard && loopback(host)) || (loopback(admin.host) && loopback(host));
|
||||
});
|
||||
}
|
||||
|
||||
function memberRow(parent, value = { id: `origin-${parent.children.length + 1}`, url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }) {
|
||||
if (parent.children.length >= 32) { toast(t("vhRowLimit"), true); return; }
|
||||
const row = el("article", undefined, "vh-member vh-row vh-backend-card");
|
||||
const head = el("div", undefined, "vh-backend-head"), identity = el("div", undefined, "vh-backend-identity");
|
||||
const title = el("strong", value.id || t("backend"), "vh-backend-title"), endpoint = el("span", value.url || "-", "fine vh-backend-endpoint");
|
||||
identity.append(title, endpoint); head.append(identity); const remove = button("remove", () => { row.remove(); changed(); }); head.append(remove); row.append(head);
|
||||
|
||||
const main = el("div", undefined, "vh-grid vh-backend-main"); row.append(main);
|
||||
const id = field(main, "member-id", "vhMemberId", value.id, "text", { pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
|
||||
const url = field(main, "member-url", "vhUrl", value.url, "url", { maxLength: 512 });
|
||||
field(main, "member-weight", "vhWeight", value.weight ?? 1, "number", { required: true, min: 1, max: 256 });
|
||||
const protocol = field(main, "member-protocol", "vhProtocol", value.protocol || "http1", "select", { values: [["http1", "vhProtocolHttp1"], ["auto", "vhProtocolAuto"], ["http2", "vhProtocolHttp2"]] });
|
||||
|
||||
const routing = el("div", undefined, "vh-wide vh-backend-switches"); main.append(routing);
|
||||
const controls = {};
|
||||
for (const [name, key, checked] of [["member-enabled", "vhEnabled", value.enabled !== false], ["member-pool", "locationPoolEligible", value.pool_eligible !== false], ["member-backup", "vhBackup", value.backup], ["member-drain", "vhDrain", value.drain]]) controls[name] = field(routing, name, key, checked, "checkbox");
|
||||
const connection = el("div", undefined, "vh-wide vh-backend-switches vh-backend-connection"); main.append(connection);
|
||||
controls["member-private"] = field(connection, "member-private", "vhPrivate", value.allow_private, "checkbox");
|
||||
const preserve = field(connection, "member-host", "vhPreserveHost", value.preserve_host === true, "checkbox");
|
||||
const syncProtocol = () => { preserve.disabled = protocol.value !== "http1"; if (preserve.disabled) preserve.checked = false; }; protocol.addEventListener("change", syncProtocol); syncProtocol();
|
||||
|
||||
const advanced = el("details", undefined, "vh-wide vh-backend-advanced"), extra = el("div", undefined, "vh-grid"); advanced.append(text("summary", "advanced"), extra);
|
||||
field(extra, "member-allowlist", "vhAddressAllowlist", (value.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
|
||||
const riskGate = el("div", undefined, "vh-wide vh-local-risk-gate"); extra.append(riskGate);
|
||||
const localRisk = field(riskGate, "member-local-risk", "vhLocalListenerRisk", value.accept_local_listener_risk === true, "checkbox");
|
||||
localRisk.addEventListener("change", async () => { if (localRisk.checked && !(await appConfirm(t("vhLocalListenerRiskConfirm"), { danger: true }))) localRisk.checked = false; });
|
||||
note(riskGate, "vhLocalListenerRiskHelp");
|
||||
field(extra, "member-tls-name", "vhTlsServerName", value.tls_server_name || "", "text", { maxLength: 253, placeholder: "backend.example.internal" });
|
||||
const tlsVerify = field(extra, "member-tls-verify", "vhTlsVerify", value.tls_verify === true, "checkbox"); note(extra, "vhTlsVerifyHelp");
|
||||
field(extra, "member-ca", "vhCa", value.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 });
|
||||
field(extra, "member-max-concurrent", "vhMaxConcurrentRequests", value.max_concurrent_requests ?? 0, "number", { required: true, min: 0, max: 1000000 });
|
||||
field(extra, "member-queue-timeout", "vhBackendQueueTimeout", value.queue_timeout_ms ?? 0, "number", { required: true, min: 0, max: 60000 });
|
||||
note(extra, "vhBackendCapacityHelp");
|
||||
row.append(advanced);
|
||||
|
||||
const actions = el("div", undefined, "vh-backend-actions");
|
||||
actions.append(button("vhDebugOriginPreset", async () => {
|
||||
if (!(await appConfirm(t("vhDebugOriginTlsWarning"), { danger: true }))) return;
|
||||
if (!url.value.trim()) url.value = "https://127.0.0.1:9000";
|
||||
controls["member-pool"].checked = true; controls["member-private"].checked = true;
|
||||
protocol.value = "http1"; preserve.checked = true; tlsVerify.checked = false; syncProtocol(); syncRisk(); changed();
|
||||
}));
|
||||
actions.append(text("span", "vhOriginPresetHelp", "fine")); row.append(actions);
|
||||
|
||||
const syncIdentity = () => { title.textContent = id.value.trim() || t("backend"); endpoint.textContent = url.value.trim() || "-"; };
|
||||
const syncRisk = (clearWhenHidden = false) => {
|
||||
const show = adminListenerBackendCandidate(url.value);
|
||||
riskGate.hidden = show !== true;
|
||||
if (clearWhenHidden && show === false) localRisk.checked = false;
|
||||
};
|
||||
id.addEventListener("input", syncIdentity); url.addEventListener("input", () => { syncIdentity(); syncRisk(true); }); url.addEventListener("change", () => syncRisk(true));
|
||||
row.syncLocalRisk = () => syncRisk(false);
|
||||
syncIdentity(); syncRisk(false); parent.append(row);
|
||||
}
|
||||
function countryRateEditor(parent, values = []) {
|
||||
const rows = el("div", undefined, "vh-wide vh-country-rates"); parent.append(rows);
|
||||
const add = (value = {}) => {
|
||||
if (rows.children.length >= 64) { toast(t("vhCountryRateLimit"), true); return; }
|
||||
const row = el("div", undefined, "vh-row vh-grid");
|
||||
field(row, "country-rate-country", "vhCountry", value.country || "", "text", { required: true, maxLength: 2, pattern: "[A-Za-z]{2}" });
|
||||
field(row, "country-rate-requests", "vhRateRequests", value.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
|
||||
field(row, "country-rate-window", "vhRateWindow", value.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 });
|
||||
field(row, "country-rate-burst", "vhRateBurst", value.burst ?? 30, "number", { required: true, min: 0, max: 1000000 });
|
||||
row.append(button("remove", () => { row.remove(); changed(); })); rows.append(row);
|
||||
};
|
||||
for (const value of values) add(value);
|
||||
parent.append(button("vhAddCountryRate", () => { add(); changed(); }));
|
||||
return () => [...rows.children].map(row => ({
|
||||
country: str("country-rate-country", row).toUpperCase(), requests: num("country-rate-requests", row),
|
||||
window_seconds: num("country-rate-window", row), burst: num("country-rate-burst", row)
|
||||
}));
|
||||
}
|
||||
function debugOriginIssues(members) {
|
||||
return members.filter(member => {
|
||||
let parsed; try { parsed = new URL(member.url); } catch { return false; }
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, ""), ipHost = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host) || host.includes(":");
|
||||
if (parsed.protocol !== "https:" || !ipHost) return false;
|
||||
return !member.pool_eligible || !member.allow_private || member.protocol !== "http1" || !member.preserve_host || member.tls_verify;
|
||||
});
|
||||
}
|
||||
function aclEditor(parent, initial, onChange = () => { }) {
|
||||
const enabled = field(parent, "acl-use", "aclUse", !!initial, "checkbox"), body = el("div", undefined, "vh-wide vh-grid"); parent.append(body);
|
||||
const policy = field(body, "acl-default", "aclDefault", initial?.default || "deny", "select", { values: [["deny", "vhDeny"], ["allow", "aclAllow"]] }), rows = el("div", undefined, "vh-wide"); body.append(rows);
|
||||
function add(value = { network: "", action: "allow" }) { const row = el("div", undefined, "acl-rule"); field(row, "network", "aclNetwork", value.network, "text", { maxLength: 64 }); field(row, "action", "aclAction", value.action, "select", { values: [["allow", "aclAllow"], ["deny", "vhDeny"]] }); row.append(button("remove", () => { row.remove(); onChange(); })); rows.append(row); }
|
||||
body.append(button("aclAdd", () => { add(); onChange(); })); for (const rule of initial?.rules || []) add(rule);
|
||||
const sync = () => { body.hidden = !enabled.checked; onChange(); }; enabled.addEventListener("change", sync); body.addEventListener("input", onChange); body.addEventListener("change", onChange); body.hidden = !enabled.checked;
|
||||
note(parent, "aclHelp"); return { read: () => enabled.checked ? { default: policy.value, rules: [...rows.children].map(r => ({ network: str("network", r), action: str("action", r) })) } : null };
|
||||
}
|
||||
function openLocationHelp(topic = "locations") {
|
||||
const dialog = $("location-help-dialog"), title = $("location-help-title"), body = $("location-help-body");
|
||||
if (!dialog || !title || !body) return;
|
||||
title.textContent = t(topic === "headers" ? "headersDocsTitle" : "locationDocsTitle");
|
||||
const section = (headingKey, keys, cls = "") => {
|
||||
const box = el("section", undefined, `location-doc-section ${cls}`.trim());
|
||||
if (headingKey) box.append(text("h3", headingKey));
|
||||
for (const key of keys) box.append(el("p", t(key)));
|
||||
return box;
|
||||
};
|
||||
if (topic === "headers") {
|
||||
body.replaceChildren(
|
||||
el("p", t("headersDocsIntro"), "location-doc-intro"),
|
||||
section("locationHeaders", ["headersDocsSet", "headersDocsAppend", "headersDocsRemove"]),
|
||||
section("advanced", ["headersDocsProtected", "headersDocsOrder"])
|
||||
);
|
||||
} else {
|
||||
body.replaceChildren(
|
||||
el("p", t("locationDocsIntro"), "location-doc-intro"),
|
||||
section("locationDocsMatchHeading", ["locationDocsPrefix", "locationDocsExact", "locationDocsRegex"]),
|
||||
section("locationDocsActionHeading", ["locationDocsReturn", "locationDocsRewrite"]),
|
||||
section("locationDocsPolicyHeading", ["locationDocsAccess", "locationDocsLimits"]),
|
||||
section("locationDocsExamplesHeading", ["locationDocsExampleExact", "locationDocsExampleStatic", "locationDocsExampleRegex"], "location-doc-examples")
|
||||
);
|
||||
}
|
||||
dialog.showModal();
|
||||
}
|
||||
function locationsEditor(parent, values) {
|
||||
const toolbar = el("div", undefined, "vh-wide location-toolbar"), rows = el("div", undefined, "vh-wide vh-locations");
|
||||
const addTop = button("locationAdd", () => { add(); sync(); }, "primary");
|
||||
toolbar.append(addTop, button("documentation", () => openLocationHelp("locations")));
|
||||
parent.append(toolbar); note(parent, "locationHelp"); parent.append(rows); let reloading = false;
|
||||
function readRows() {
|
||||
return [...rows.children].map(r => {
|
||||
const action = str("location-action", r), rewriteMode = str("location-rewrite-mode", r);
|
||||
const handled = action !== "return";
|
||||
return {
|
||||
id: str("location-id", r), path_prefix: str("location-path", r), match_type: str("location-match", r),
|
||||
source: action === "web_server" ? { type: "web_server", web_service_id: str("location-web-service", r) } : { type: "reverse_proxy" },
|
||||
backend_id: action === "proxy" ? (str("location-backend", r) || null) : null,
|
||||
enabled: yes("location-enabled", r), cache: action === "proxy" ? (str("location-cache", r) === "" ? null : str("location-cache", r) === "on") : false,
|
||||
acl: r.readAcl(), headers: r.readHeaders(),
|
||||
rewrite: { mode: handled ? rewriteMode : "preserve", value: handled && rewriteMode !== "preserve" ? read("location-rewrite-value", r).value.trim() : "" },
|
||||
response: action === "return" ? { status: Number(str("location-response-status", r)), content_type: str("location-response-type", r), body: read("location-response-body", r).value } : null
|
||||
};
|
||||
});
|
||||
}
|
||||
function sync() { if (reloading) return; try { read("paths").value = JSON.stringify(readRows(), null, 2); changed(); } catch { } }
|
||||
function memberOptions(select, chosen = select.value) { const base = text("option", "locationPool"); base.value = ""; select.replaceChildren(base, ...[...form.querySelectorAll(".vh-member")].map(m => { const id = str("member-id", m), o = el("option", `${id} / ${str("member-url", m)}`); o.value = id; return o; })); if (chosen && !Array.from(select.options).some(o => o.value === chosen)) { const o = el("option", `${chosen} / ${t("locationMissing")}`); o.value = chosen; select.append(o); } select.value = chosen; }
|
||||
function add(value = {}) {
|
||||
if (rows.children.length >= 64) { toast(t("locationLimit"), true); return; }
|
||||
const row = el("details", undefined, "vh-location"); row.open = true;
|
||||
const summary = el("summary", undefined, "location-summary"), body = el("div", undefined, "vh-grid location-body"); row.append(summary, body);
|
||||
const used = new Set([...rows.children].map(r => str("location-id", r))); let suffix = 1; while (used.has(`location-${suffix}`)) suffix++;
|
||||
field(body, "location-id", "locationId", value.id || `location-${suffix}`, "text", { required: true, pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
|
||||
const match = field(body, "location-match", "locationMatchType", value.match_type || "prefix", "select", { values: [["prefix", "locationMatchPrefix"], ["prefix_priority", "locationMatchPriority"], ["exact", "locationMatchExact"], ["regex", "locationMatchRegex"], ["regex_insensitive", "locationMatchRegexI"]] });
|
||||
const path = field(body, "location-path", "locationPathPattern", value.path_prefix || "/", "text", { required: true, maxLength: 1024 }); path.placeholder = "/api or ^/api/v[0-9]+/";
|
||||
const initialAction = value.response ? "return" : value.source?.type === "web_server" ? "web_server" : "proxy";
|
||||
const action = field(body, "location-action", "locationAction", initialAction, "select", { values: [["proxy", "locationProxyAction"], ["web_server", "locationWebServerAction"], ["return", "locationReturnAction"]] });
|
||||
const backend = field(body, "location-backend", "backend", value.backend_id || "", "select"); memberOptions(backend, value.backend_id || "");
|
||||
const webService = field(body, "location-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(webService, value.source?.web_service_id || "");
|
||||
field(body, "location-enabled", "enabled", value.enabled !== false, "checkbox");
|
||||
const cache = field(body, "location-cache", "cacheEnabled", value.cache === true ? "on" : value.cache === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
|
||||
|
||||
const handling = el("details", undefined, "vh-wide location-section"), handlingBody = el("div", undefined, "vh-grid"); handling.append(text("summary", "locationHandling"), handlingBody); body.append(handling);
|
||||
const rewriteMode = field(handlingBody, "location-rewrite-mode", "locationRewriteMode", value.rewrite?.mode || "preserve", "select", { values: [["preserve", "locationRewritePreserve"], ["strip_prefix", "locationRewriteStrip"], ["replace_prefix", "locationRewriteReplace"], ["regex", "locationRewriteRegex"]] });
|
||||
const rewriteValue = field(handlingBody, "location-rewrite-value", "locationRewriteValue", value.rewrite?.value || "", "text", { maxLength: 2048 });
|
||||
const returnBox = el("div", undefined, "vh-wide vh-grid location-return-box"); handlingBody.append(returnBox);
|
||||
field(returnBox, "location-response-status", "locationResponseStatus", value.response?.status ?? 200, "number", { min: 200, max: 599, required: true });
|
||||
field(returnBox, "location-response-type", "locationResponseType", value.response?.content_type || "text/plain; charset=utf-8", "text", { maxLength: 256, required: true });
|
||||
field(returnBox, "location-response-body", "locationResponseBody", value.response?.body || "", "textarea", { rows: 5, maxLength: 65536 });
|
||||
note(handlingBody, "locationRewriteHelp");
|
||||
|
||||
const extra = el("details", undefined, "vh-wide location-section"), accessBody = el("div", undefined, "vh-grid"); extra.append(text("summary", "locationAccess"), accessBody); body.append(extra); const acl = aclEditor(accessBody, value.acl, sync); row.readAcl = () => acl.read();
|
||||
const headerBox = el("details", undefined, "vh-wide location-section location-header-editor"), headerBody = el("div", undefined, "vh-grid"); headerBox.append(text("summary", "locationHeaders"), headerBody); body.append(headerBox);
|
||||
const headerToolbar = el("div", undefined, "vh-wide location-header-toolbar"), visualPane = el("div", undefined, "vh-wide vh-grid"), jsonPane = el("div", undefined, "vh-wide vh-grid"); jsonPane.hidden = true;
|
||||
const visualButton = button("headersVisualBuilder", () => { visualPane.hidden = false; jsonPane.hidden = true; }), jsonButton = button("headersAdvancedJson", () => { visualPane.hidden = true; jsonPane.hidden = false; }), docsButton = button("documentation", () => openLocationHelp("headers"));
|
||||
headerToolbar.append(visualButton, jsonButton, docsButton); headerBody.append(headerToolbar, visualPane, jsonPane);
|
||||
const raw = field(jsonPane, "location-headers", "headersAdvancedJson", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 8, maxLength: 65536 });
|
||||
const editor = headerEditors(visualPane, value.headers || {}, raw, sync); row.readHeaders = () => editor.read();
|
||||
|
||||
const preview = el("section", undefined, "vh-wide location-preview");
|
||||
preview.append(text("h4", "locationPreviewTitle"));
|
||||
const previewStatus = text("p", "locationPreviewDisabled", "fine location-preview-disabled"); preview.append(previewStatus);
|
||||
const previewGrid = el("dl", undefined, "location-preview-grid"), previewValues = {};
|
||||
for (const [name, key] of [["match", "locationPreviewMatch"], ["handling", "locationPreviewHandling"], ["rewrite", "locationPreviewRewrite"], ["cache", "locationPreviewCache"], ["access", "locationPreviewAccess"], ["headers", "locationPreviewHeaders"]]) {
|
||||
const item = el("div"); item.append(text("dt", key)); const output = el("dd", "-"); previewValues[name] = output; item.append(output); previewGrid.append(item);
|
||||
}
|
||||
preview.append(previewGrid); body.append(preview);
|
||||
|
||||
const actions = el("div", undefined, "vh-wide location-actions");
|
||||
const dedicated = button("locationAddBackend", () => { const members = form.querySelector(".vh-members"); let i = 1; const ids = new Set([...members.children].map(r => str("member-id", r))); while (ids.has(`location-origin-${i}`)) i++; const id = `location-origin-${i}`; memberRow(members, { id, url: "http://127.0.0.1:9001", weight: 1, enabled: true, pool_eligible: false, allow_private: false }); memberOptions(backend, id); sync(); activateTab("backends"); });
|
||||
actions.append(dedicated, button("remove", () => { row.remove(); sync(); })); body.append(actions);
|
||||
|
||||
const syncUi = () => {
|
||||
const isReturn = action.value === "return", isWeb = action.value === "web_server", isProxy = action.value === "proxy", isRegex = match.value === "regex" || match.value === "regex_insensitive";
|
||||
backend.disabled = !isProxy; cache.disabled = !isProxy; dedicated.disabled = !isProxy;
|
||||
webService.disabled = !isWeb; webService.required = isWeb;
|
||||
returnBox.hidden = !isReturn; rewriteMode.disabled = isReturn; rewriteValue.disabled = isReturn || rewriteMode.value === "preserve" || rewriteMode.value === "strip_prefix";
|
||||
for (const option of rewriteMode.options) if (option.value === "regex") option.disabled = !isRegex;
|
||||
if (!isRegex && rewriteMode.value === "regex") rewriteMode.value = "preserve";
|
||||
path.placeholder = isRegex ? "^/api/v[0-9]+/" : "/api/";
|
||||
const id = str("location-id", row) || t("locationId"), pattern = str("location-path", row) || "/";
|
||||
summary.textContent = `${id} · ${t(`locationMatch_${match.value}`)} · ${pattern} · ${t(isReturn ? "locationReturnShort" : isWeb ? "locationWebServerShort" : "locationProxyShort")}`;
|
||||
|
||||
previewStatus.hidden = yes("location-enabled", row);
|
||||
previewValues.match.textContent = `${t(`locationMatch_${match.value}`)} · ${pattern}`;
|
||||
if (isReturn) {
|
||||
const status = str("location-response-status", row) || "200", type = str("location-response-type", row) || "text/plain";
|
||||
previewValues.handling.textContent = `${t("locationReturnAction")}: HTTP ${status} · ${type} · ${t("locationPreviewBackendSkipped")}`;
|
||||
previewValues.rewrite.textContent = "-";
|
||||
previewValues.cache.textContent = t("notEnabled");
|
||||
} else {
|
||||
if (isWeb) {
|
||||
const selected = webService.selectedOptions?.[0]?.textContent || t("webServiceSelect");
|
||||
previewValues.handling.textContent = `${t("locationWebServerAction")}: ${selected}`;
|
||||
previewValues.cache.textContent = t("notEnabled");
|
||||
} else {
|
||||
const selected = backend.selectedOptions?.[0]?.textContent || t("locationPool");
|
||||
previewValues.handling.textContent = `${t("locationProxyAction")}: ${selected}`;
|
||||
previewValues.cache.textContent = t(cache.value === "on" ? "enabled" : cache.value === "off" ? "notEnabled" : "inherit");
|
||||
}
|
||||
const rewriteLabels = { preserve: "locationRewritePreserve", strip_prefix: "locationRewriteStrip", replace_prefix: "locationRewriteReplace", regex: "locationRewriteRegex" };
|
||||
const replacement = read("location-rewrite-value", row).value.trim();
|
||||
previewValues.rewrite.textContent = `${t(rewriteLabels[rewriteMode.value] || "locationRewritePreserve")}${replacement && !["preserve", "strip_prefix"].includes(rewriteMode.value) ? `: ${replacement}` : ""} · ${t("locationPreviewQueryPreserved")}`;
|
||||
}
|
||||
previewValues.access.textContent = yes("acl-use", row) ? t("locationPreviewCustomAcl") : t("locationPreviewVhostAcl");
|
||||
try {
|
||||
const headerValue = editor.read(), requestRules = headerValue.request?.length || 0, responseRules = headerValue.response?.length || 0;
|
||||
previewValues.headers.textContent = `${requestRules} ${t("locationPreviewRequestRules")} / ${responseRules} ${t("locationPreviewResponseRules")}`;
|
||||
} catch { previewValues.headers.textContent = t("headersInvalid"); }
|
||||
};
|
||||
row.addEventListener("input", () => { syncUi(); sync(); }); row.addEventListener("change", () => { syncUi(); sync(); });
|
||||
rows.append(row); syncUi();
|
||||
}
|
||||
// The main vhost backend is an internal fallback, not a Custom location.
|
||||
// An empty location list must stay empty so the editor cannot accidentally
|
||||
// delete or recreate the main route as user configuration.
|
||||
for (const value of values) add(value);
|
||||
const bottom = el("div", undefined, "vh-wide location-toolbar location-toolbar-bottom"); bottom.append(button("locationAdd", () => { add(); sync(); }, "primary")); parent.append(bottom);
|
||||
form.querySelector(".vh-members").addEventListener("input", () => { for (const select of rows.querySelectorAll('[name="location-backend"]')) memberOptions(select); });
|
||||
// Advanced JSON remains an explicit escape hatch; edits rebuild visual rows.
|
||||
read("paths").addEventListener("change", () => { try { const data = JSON.parse(str("paths")); if (!Array.isArray(data) || data.length > 64) return; reloading = true; rows.replaceChildren(); for (const value of data) add(value); } catch { } finally { reloading = false; } });
|
||||
sync(); return readRows;
|
||||
}
|
||||
function basicAuthEditor(parent, settings = {}) {
|
||||
const realm = field(parent, "basic-realm", "basicRealm", settings.realm || "Restricted", "text", { maxLength: 128 });
|
||||
const secure = field(parent, "basic-tls", "basicRequireTls", settings.require_tls !== false, "checkbox"), forward = field(parent, "basic-forward", "basicForward", settings.forward_credentials, "checkbox");
|
||||
const rows = el("div", undefined, "vh-wide basic-users"); parent.append(rows); let sequence = 0;
|
||||
function add(user = {}) {
|
||||
if (rows.children.length >= 64) { toast(t("basic.too_many_users"), true); return; } const row = el("div", undefined, "basic-user");
|
||||
const username = field(row, "basic-username", "username", user.username || "", "text", { maxLength: 128 });
|
||||
const password = field(row, "basic-password", "basicPassword", "", "password", { maxLength: 128 }); password.autocomplete = "new-password"; password.placeholder = user.password_hash ? t("basicKeepPassword") : t("basicPassword");
|
||||
const state = el("span", user.password_hash ? t("basicHashSaved") : t("basicHashRequired"), "fine"); row.append(state, button("remove", () => { row.remove(); changed(); })); row.userHash = user.password_hash || ""; row.sequence = ++sequence; rows.append(row);
|
||||
row.prepare = async () => { if (!password.value) { if (!row.userHash) throw new Error(t("basicHashRequired")); return; } const name = username.value.trim(), secret = password.value, epoch = draftEpoch; const result = await api("/api/basic-auth/hash", { method: "POST", body: JSON.stringify({ username: name, password: secret }) }); if (epoch !== draftEpoch || !row.isConnected || password.value !== secret || username.value.trim() !== name) throw new Error(t("vhUnsaved")); row.userHash = result.password_hash; password.value = ""; password.placeholder = t("basicKeepPassword"); state.textContent = t("basicHashSaved"); };
|
||||
}
|
||||
for (const user of settings.users || []) add(user); parent.append(button("basicAddUser", () => { add(); changed(); })); note(parent, "basicHelp");
|
||||
return { async prepare() { for (const row of rows.children) await row.prepare(); }, read: () => ({ enabled: str("auth-mode") === "basic", realm: realm.value.trim(), require_tls: secure.checked, forward_credentials: forward.checked, users: [...rows.children].map(row => ({ username: str("basic-username", row), password_hash: row.userHash })) }) };
|
||||
}
|
||||
|
||||
function hostTagEditor(parent, hosts = []) {
|
||||
const wrapper = el("div", undefined, "vh-field vh-wide host-chip-field"); wrapper.append(text("span", "vhHosts"));
|
||||
const hidden = document.createElement("textarea"); hidden.name = "hosts"; hidden.hidden = true;
|
||||
const box = el("div", undefined, "host-chip-editor"), input = document.createElement("input"); input.type = "text"; input.maxLength = 253; input.placeholder = "app.example.com / *.example.com"; input.setAttribute("aria-label", t("vhHostAdd"));
|
||||
const values = [];
|
||||
const normalize = raw => {
|
||||
let value = raw.trim().toLowerCase().replace(/\.$/, ""); if (!value) return null; const wildcard = value.startsWith("*."); const base = wildcard ? value.slice(2) : value;
|
||||
if (base.length > 253 || !/^[a-z0-9.-]+$/.test(base) || base.startsWith(".") || base.endsWith(".") || base.split(".").some(part => !part || part.length > 63 || part.startsWith("-") || part.endsWith("-"))) throw new Error(t("vhHostInvalid"));
|
||||
if (wildcard && !base.includes(".")) throw new Error(t("vhHostInvalid")); return wildcard ? `*.${base}` : base;
|
||||
};
|
||||
const sync = () => { hidden.value = values.join("\n"); box.querySelectorAll(".host-chip").forEach(node => node.remove()); for (const value of values) { const chip = el("span", undefined, "host-chip"); chip.append(el("span", value)); const remove = el("button", "×"); remove.type = "button"; remove.setAttribute("aria-label", `${t("remove")} ${value}`); remove.addEventListener("click", () => { values.splice(values.indexOf(value), 1); sync(); changed(); }); chip.append(remove); box.insertBefore(chip, input); } };
|
||||
const add = raw => { const value = normalize(raw); if (!value || values.includes(value)) return; values.push(value); sync(); changed(); };
|
||||
const consume = raw => { for (const part of raw.split(/[\s,]+/).filter(Boolean)) add(part); input.value = ""; };
|
||||
input.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); try { consume(input.value); } catch (error) { toast(error.message, true); } } });
|
||||
input.addEventListener("blur", () => { if (!input.value.trim()) return; try { consume(input.value); } catch (error) { toast(error.message, true); } });
|
||||
input.addEventListener("paste", event => { const textValue = event.clipboardData?.getData("text") || ""; if (!/[\s,]/.test(textValue.trim())) return; event.preventDefault(); try { consume(textValue); } catch (error) { toast(error.message, true); } });
|
||||
box.append(input); wrapper.append(hidden, box, text("p", "vhHostsHelp", "fine")); parent.append(wrapper); for (const host of hosts) { try { add(host); } catch { } } dirty = false;
|
||||
return () => { if (input.value.trim()) consume(input.value); if (!values.length) throw new Error(t("vhHostRequired")); return [...values]; };
|
||||
}
|
||||
|
||||
function load(value = defaults(), migrationIds = [], show = true) {
|
||||
draftEpoch++; stagedCertificates = []; uploading = false;
|
||||
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true;
|
||||
container.replaceChildren(); $("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
|
||||
let g = group("vhEditor"); field(g, "id", "vhId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" }); if (editing) note(g, "vhIdRenameHelp");
|
||||
readHosts = hostTagEditor(g, value.frontend?.hosts || []); field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
|
||||
const mainSource = field(g, "source-type", "sourceType", value.source?.type || "reverse_proxy", "select", { values: [["reverse_proxy", "sourceReverseProxy"], ["web_server", "sourceWebServer"]] });
|
||||
const mainWebService = field(g, "source-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(mainWebService, value.source?.web_service_id || "");
|
||||
const syncMainSource = () => { const web = mainSource.value === "web_server"; mainWebService.disabled = !web; mainWebService.required = web; }; mainSource.addEventListener("change", syncMainSource); syncMainSource();
|
||||
note(g, "sourceHelp");
|
||||
const websocketEnabled = field(g, "websockets", "vhWebsockets", value.websockets !== false, "checkbox");
|
||||
const websocketModeValue = value.websocket_rfc8441 === true && value.websocket_rfc9220 === true ? "h2h3" : value.websocket_rfc8441 === true ? "h2" : value.websocket_rfc9220 === true ? "h3" : "h1";
|
||||
const websocketMode = field(g, "websocket-mode", "vhWebsocketMode", websocketModeValue, "select", { values: [["h1", "vhWebsocketH1"], ["h2", "vhWebsocketH2Fallback"], ["h3", "vhWebsocketH3Fallback"], ["h2h3", "vhWebsocketH2H3Fallback"]] });
|
||||
note(g, "vhWebsocketTransportHelp");
|
||||
const websocketLegacy = field(g, "websocket-legacy-support", "vhWebsocketLegacySupport", value.websocket_legacy_support === true, "checkbox");
|
||||
note(g, "vhWebsocketLegacySupportHelp");
|
||||
g = group("vhFrontends");
|
||||
const bindingControl = window.rustProxyIntegrations.bindings(g, value.frontend?.listener_ids || [], !editing); readBindings = bindingControl.read;
|
||||
note(g, "listenerDefaultPortsHint");
|
||||
field(g, "force-ssl", "vhForceSsl", value.force_ssl === true, "checkbox");
|
||||
note(g, "vhForceSslHelp");
|
||||
field(g, "forward-client-ip", "vhForwardClientIp", value.forward_client_ip !== false, "checkbox");
|
||||
note(g, "vhForwardClientIpHelp");
|
||||
field(g, "cloudflare-client-ip", "vhCloudflareClientIp", value.cloudflare_client_ip === true, "checkbox");
|
||||
const protocolRow = el("div", undefined, "vh-wide protocol-toggle-row"); g.append(protocolRow);
|
||||
const h2Enabled = field(protocolRow, "http2-enabled", "vhHttp2Enabled", value.http2_enabled !== false, "checkbox");
|
||||
const h3Enabled = field(protocolRow, "http3-enabled", "vhHttp3Enabled", value.http3_enabled !== false, "checkbox");
|
||||
note(g, "vhFrontendProtocolsHelp"); note(g, "vhFrontendSelectionOnly");
|
||||
const syncFrontendTransports = () => {
|
||||
const capabilities = bindingControl.capabilities();
|
||||
h2Enabled.disabled = !capabilities.http2; h3Enabled.disabled = !capabilities.http3;
|
||||
if (!capabilities.http2) h2Enabled.checked = false;
|
||||
if (!capabilities.http3) h3Enabled.checked = false;
|
||||
const h2Available = capabilities.http2 && h2Enabled.checked;
|
||||
const h3Available = capabilities.http3 && h3Enabled.checked;
|
||||
websocketMode.disabled = !websocketEnabled.checked;
|
||||
websocketLegacy.disabled = !websocketEnabled.checked;
|
||||
const optionH2 = websocketMode.querySelector('option[value="h2"]');
|
||||
const optionH3 = websocketMode.querySelector('option[value="h3"]');
|
||||
const optionH2H3 = websocketMode.querySelector('option[value="h2h3"]');
|
||||
optionH2.disabled = !h2Available;
|
||||
optionH3.disabled = !h3Available;
|
||||
optionH2H3.disabled = !h2Available || !h3Available;
|
||||
const selected = websocketMode.value;
|
||||
if ((selected === "h2" && !h2Available) || (selected === "h3" && !h3Available) || (selected === "h2h3" && (!h2Available || !h3Available))) {
|
||||
websocketMode.value = selected === "h2h3" && h2Available ? "h2" : selected === "h2h3" && h3Available ? "h3" : "h1";
|
||||
}
|
||||
};
|
||||
websocketEnabled.addEventListener("change", syncFrontendTransports);
|
||||
bindingControl.onChange(syncFrontendTransports);
|
||||
h2Enabled.addEventListener("change", syncFrontendTransports);
|
||||
h3Enabled.addEventListener("change", syncFrontendTransports);
|
||||
syncFrontendTransports();
|
||||
g = group("vhBackendPool"); const members = el("div", undefined, "vh-wide vh-members"); g.append(members);
|
||||
(value.backends || []).forEach(m => memberRow(members, m)); g.append(button("vhAddBackend", () => { memberRow(members); changed(); }));
|
||||
g = group("vhBackendDiagnostics");
|
||||
field(g, "timing-headers", "vhTimingHeaders", value.timing_headers === true, "checkbox");
|
||||
field(g, "backend-header", "vhBackendHeader", value.backend_header === true, "checkbox");
|
||||
field(g, "backend-timing-header", "vhBackendTimingHeader", value.backend_timing_header === true, "checkbox");
|
||||
note(g, "vhBackendDiagnosticsHelp");
|
||||
const proxyTimeouts = { ...defaultProxyTimeouts(), ...(value.proxy_timeouts || {}) };
|
||||
g = group("vhProxyTimeouts");
|
||||
field(g, "proxy-connect-timeout", "vhProxyConnectTimeout", proxyTimeouts.connect_timeout_seconds ?? 10, "number", { required: true, min: 1, max: 300 });
|
||||
field(g, "proxy-header-timeout", "vhProxyHeaderTimeout", proxyTimeouts.response_header_timeout_seconds ?? 120, "number", { required: true, min: 1, max: 86400 });
|
||||
field(g, "proxy-read-timeout", "vhProxyReadTimeout", proxyTimeouts.read_timeout_seconds ?? 60, "number", { required: true, min: 1, max: 86400 });
|
||||
field(g, "proxy-send-timeout", "vhProxySendTimeout", proxyTimeouts.send_timeout_seconds ?? 60, "number", { required: true, min: 1, max: 86400 });
|
||||
note(g, "vhProxyTimeoutsHelp");
|
||||
const settings = value.load_balancing || {}, health = settings.health_check || {}, keepalive = settings.keepalive || {}, retry = settings.retry || {}, sticky = settings.sticky || {};
|
||||
g = group("vhBalancing"); field(g, "algorithm", "vhAlgorithm", settings.algorithm || "least_connections", "select", { values: [["round_robin", "vhRoundRobin"], ["least_connections", "vhLeastConnections"], ["least_traffic", "vhLeastTraffic"], ["adaptive", "vhAdaptive"], ["ip_hash", "vhIpHash"]] });
|
||||
field(g, "slow-start", "vhSlowStart", settings.slow_start_seconds ?? 10, "number", { required: true, min: 0, max: 300 });
|
||||
field(g, "retry-max", "vhRetryMax", retry.max_retries ?? 1, "number", { required: true, min: 0, max: 3 });
|
||||
field(g, "retry-connect", "vhRetryConnect", retry.retry_connect_errors !== false, "checkbox");
|
||||
field(g, "retry-timeouts", "vhRetryTimeouts", retry.retry_timeouts !== false, "checkbox");
|
||||
field(g, "retry-statuses", "vhRetryStatuses", (retry.retry_statuses || [502, 503, 504]).join(", "), "text", { maxLength: 64 });
|
||||
note(g, "vhRetryHelp");
|
||||
field(g, "keepalive", "vhKeepalive", keepalive.enabled !== false, "checkbox");
|
||||
field(g, "idle-timeout", "vhIdleTimeout", keepalive.idle_timeout_seconds ?? 30, "number", { required: true, min: 1, max: 3600 });
|
||||
field(g, "max-idle", "vhMaxIdle", keepalive.max_idle_per_host ?? 4, "number", { required: true, min: 1, max: 64 });
|
||||
field(g, "tcp-keepalive", "vhTcpKeepalive", keepalive.tcp_keepalive_seconds ?? 30, "number", { required: true, min: 1, max: 3600 });
|
||||
g = group("vhHealth"); const healthMode = field(g, "health-mode", "vhHealthMode", health.mode || "tcp", "select", { values: [["off", "vhOff"], ["tcp", "vhTcp"], ["http", "vhHttp"]] });
|
||||
const healthOffWarning = note(g, "vhHealthOffWarning"); const syncHealthWarning = () => { healthOffWarning.hidden = healthMode.value !== "off"; }; healthMode.addEventListener("change", syncHealthWarning); syncHealthWarning();
|
||||
field(g, "health-path", "vhHealthPath", health.path || "/", "text", { required: true, maxLength: 1024 });
|
||||
field(g, "health-host", "vhHealthHost", health.host || "", "text", { maxLength: 260 });
|
||||
for (const [name, key, val, min, max] of [["interval", "vhInterval", health.interval_ms ?? 5000, 250, 300000], ["timeout", "vhTimeout", health.timeout_ms ?? 2000, 50, 60000], ["slow", "vhSlow", health.slow_response_ms ?? 1500, 0, 60000], ["fall", "vhFall", health.fall ?? 3, 1, 20], ["rise", "vhRise", health.rise ?? 2, 1, 20], ["status-min", "vhStatusMin", health.expected_status_min ?? 200, 100, 599], ["status-max", "vhStatusMax", health.expected_status_max ?? 399, 100, 599]]) field(g, name, key, val, "number", { required: true, min, max }); note(g, "vhHealthNote");
|
||||
const passive = settings.passive_health || {};
|
||||
g = group("vhPassiveHealth");
|
||||
const passiveEnabled = field(g, "passive-enabled", "vhPassiveEnabled", passive.enabled === true, "checkbox");
|
||||
note(g, "vhPassiveHelp");
|
||||
const passiveFields = [
|
||||
field(g, "passive-connect", "vhPassiveConnect", passive.count_connect_errors !== false, "checkbox"),
|
||||
field(g, "passive-timeouts", "vhPassiveTimeouts", passive.count_timeouts !== false, "checkbox"),
|
||||
field(g, "passive-io", "vhPassiveIo", passive.count_io_errors !== false, "checkbox"),
|
||||
field(g, "passive-5xx", "vhPassive5xx", passive.count_http_5xx !== false, "checkbox"),
|
||||
field(g, "passive-slow", "vhPassiveSlow", passive.count_slow_responses === true, "checkbox"),
|
||||
field(g, "passive-fall", "vhPassiveFall", passive.fall ?? "", "number", { min: 1, max: 20 }),
|
||||
field(g, "passive-rise", "vhPassiveRise", passive.rise ?? "", "number", { min: 1, max: 20 })
|
||||
];
|
||||
const passive5xx = passiveFields[3], passiveSlow = passiveFields[4];
|
||||
const passive5xxWarning = note(g, "vhPassive5xxWarning");
|
||||
const passiveSlowWarning = note(g, "vhPassiveSlowWarning");
|
||||
const syncPassive = () => {
|
||||
passiveFields.forEach(control => { control.disabled = !passiveEnabled.checked; const label = control.closest("label"); if (label) label.hidden = !passiveEnabled.checked; });
|
||||
passive5xxWarning.hidden = !passiveEnabled.checked || !passive5xx.checked;
|
||||
passiveSlowWarning.hidden = !passiveEnabled.checked || !passiveSlow.checked;
|
||||
};
|
||||
passiveEnabled.addEventListener("change", syncPassive);
|
||||
passive5xx.addEventListener("change", syncPassive);
|
||||
passiveSlow.addEventListener("change", syncPassive);
|
||||
syncPassive();
|
||||
g = group("vhSticky"); field(g, "sticky-mode", "vhStickyMode", sticky.mode || "off", "select", { values: [["off", "vhOff"], ["cookie", "vhCookie"], ["ip_hash", "vhIpHash"]] });
|
||||
field(g, "cookie-name", "vhCookieName", sticky.cookie_name || "", "text", { maxLength: 96, pattern: "[A-Za-z0-9_-]*" });
|
||||
field(g, "cookie-ttl", "vhCookieTtl", sticky.ttl_seconds ?? 3600, "number", { required: true, min: 60, max: 604800 }); note(g, "vhStickyNote");
|
||||
const rate = value.rate_limit || {}, access = rate.country_access || {};
|
||||
g = group("vhTrafficPolicy");
|
||||
const aclProfile = field(g, "acl-profile-id", "vhAclProfile", value.acl_profile_id || "", "select");
|
||||
const noProfile = text("option", "vhAclProfileNone"); noProfile.value = ""; aclProfile.append(noProfile, ...(envelope?.config.acl_profiles || []).map(profile => { const option = el("option", profile.name || profile.id); option.value = profile.id; return option; })); aclProfile.value = value.acl_profile_id || ""; note(g, "vhAclProfileHelp");
|
||||
field(g, "rate-enabled", "vhRateEnabled", rate.enabled === true, "checkbox");
|
||||
field(g, "rate-requests", "vhRateRequests", rate.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
|
||||
field(g, "rate-window", "vhRateWindow", rate.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 });
|
||||
field(g, "rate-burst", "vhRateBurst", rate.burst ?? 30, "number", { required: true, min: 0, max: 1000000 });
|
||||
field(g, "country-mode", "vhCountryMode", access.mode || "off", "select", { values: [["off", "vhOff"], ["block", "vhCountryBlock"], ["allow", "vhCountryAllow"]] });
|
||||
field(g, "country-list", "vhCountryList", (access.countries || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
|
||||
note(g, "vhCountryPolicyHelp"); countryRatesReader = countryRateEditor(g, rate.country_limits || []); note(g, "vhRateHelp");
|
||||
const security = value.security_headers || {}, hsts = security.hsts || {};
|
||||
g = group("vhSecurity"); field(g, "hsts", "vhHsts", hsts.enabled, "checkbox"); field(g, "hsts-age", "vhHstsAge", hsts.max_age_seconds ?? 31536000, "number", { required: true, min: 0, max: 63072000 });
|
||||
field(g, "hsts-sub", "vhHstsSub", hsts.include_subdomains, "checkbox"); field(g, "hsts-preload", "vhHstsPreload", hsts.preload, "checkbox"); field(g, "nosniff", "vhNosniff", security.nosniff, "checkbox");
|
||||
field(g, "frame", "vhFrame", security.frame_options || "preserve", "select", { values: [["preserve", "vhPreserve"], ["deny", "vhDeny"], ["sameorigin", "vhSameorigin"]] });
|
||||
field(g, "referrer", "vhReferrer", security.referrer_policy || "", "text", { maxLength: 8192 });
|
||||
field(g, "csp", "vhCsp", security.content_security_policy || "", "textarea", { rows: 2, maxLength: 8192 });
|
||||
field(g, "permissions", "vhPermissions", security.permissions_policy || "", "text", { maxLength: 8192 });
|
||||
g = group("vhAdvanced", true); field(g, "paths", "vhPaths", JSON.stringify(value.routes || [], null, 2), "textarea", { rows: 5, maxLength: 131072 });
|
||||
field(g, "acl", "vhAcl", JSON.stringify(value.acl ?? null, null, 2), "textarea", { rows: 5, maxLength: 32768 });
|
||||
field(g, "headers", "vhHeaders", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 5, maxLength: 65536 });
|
||||
g = group("vhLocations"); locationsReader = locationsEditor(g, value.routes || []);
|
||||
g = group("vhSsl");
|
||||
const selector = field(g, "certificate-id", "vhCertificate", value.certificate_id || "", "select");
|
||||
const fillCertificates = () => { const selected = selector.value || value.certificate_id || ""; const inherit = text("option", "vhCertificateDefault"); inherit.value = ""; selector.replaceChildren(inherit, ...[...(envelope?.config.tls?.certificates || []), ...stagedCertificates].map(c => { const o = el("option", c.id); o.value = c.id; return o; })); selector.value = selected; }; fillCertificates();
|
||||
g.append(button("certManage", async () => { if (!(await closeEditor())) return; setPanel("tls"); })); note(g, "certSelectHint");
|
||||
const inheritedTlsPolicy = envelope?.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] };
|
||||
const tlsPolicy = value.tls_policy || inheritedTlsPolicy;
|
||||
const customTlsPolicy = field(g, "tls-policy-custom", "vhTlsOverride", value.tls_policy != null, "checkbox");
|
||||
const tls12 = field(g, "tls-policy-12", "tls12", tlsPolicy.tls12 !== false, "checkbox");
|
||||
const tls13 = field(g, "tls-policy-13", "tls13", tlsPolicy.tls13 !== false, "checkbox");
|
||||
const tlsCiphers = field(g, "tls-policy-ciphers", "tlsCiphers", (tlsPolicy.cipher_suites || []).join("\n"), "textarea", { rows: 7, maxLength: 8192 });
|
||||
const syncTlsPolicy = () => { for (const input of [tls12, tls13, tlsCiphers]) input.disabled = !customTlsPolicy.checked; };
|
||||
customTlsPolicy.addEventListener("change", syncTlsPolicy); syncTlsPolicy(); note(g, "vhTlsOverrideHelp");
|
||||
g = group("vhTinyauth"); const auth = value.tinyauth || {}, authRoot = g;
|
||||
const authMode = field(g, "auth-mode", "authMode", value.basic_auth?.enabled ? "basic" : auth.enabled ? "tinyauth" : "none", "select", { values: [["none", "authNone"], ["tinyauth", "authTinyauth"], ["basic", "authBasic"]] });
|
||||
note(g, "authModeHelp");
|
||||
|
||||
const aclBody = el("section", undefined, "vh-wide auth-subcard auth-ip-card"); authRoot.append(aclBody);
|
||||
aclBody.append(text("h3", "vhIpAccess"));
|
||||
aclControl = aclEditor(aclBody, value.acl, () => { if (aclControl) read("acl").value = JSON.stringify(aclControl.read(), null, 2); });
|
||||
|
||||
const taBody = el("section", undefined, "vh-wide auth-subcard"), basicBody = el("section", undefined, "vh-wide auth-subcard"); authRoot.append(taBody, basicBody);
|
||||
taBody.append(text("h3", "authTinyauth"));
|
||||
const taPrimary = el("div", undefined, "vh-grid"); taBody.append(taPrimary);
|
||||
const authEnable = field(taPrimary, "auth-enabled", "taEnable", auth.enabled, "checkbox");
|
||||
const authUrl = field(taPrimary, "auth-url", "taUrl", auth.url || "", "url", { maxLength: 2048 });
|
||||
const loginUrl = field(taPrimary, "auth-login", "taLogin", auth.login_url || "", "url", { maxLength: 2048 });
|
||||
authUrl.placeholder = "http://10.87.7.99:3000"; loginUrl.placeholder = "https://auth.example.com/login";
|
||||
field(taPrimary, "auth-private", "vhPrivate", auth.allow_private, "checkbox");
|
||||
field(taPrimary, "auth-redirect-forbidden", "taRedirectForbidden", auth.redirect_forbidden !== false, "checkbox");
|
||||
authEnable.closest("label").hidden = true;
|
||||
note(taBody, "taHelp");
|
||||
|
||||
const taAdvanced = el("details", undefined, "vh-wide auth-advanced"); taAdvanced.append(text("summary", "taAdvanced"));
|
||||
const taAdvancedGrid = el("div", undefined, "vh-grid"); taAdvanced.append(taAdvancedGrid); taBody.append(taAdvanced);
|
||||
field(taAdvancedGrid, "auth-connect", "taConnect", auth.connect_timeout_seconds ?? 5, "number", { required: true, min: 1, max: 30 });
|
||||
field(taAdvancedGrid, "auth-timeout", "taTimeout", auth.timeout_seconds ?? 10, "number", { required: true, min: 1, max: 60 });
|
||||
field(taAdvancedGrid, "auth-request-headers", "taRequestHeaders", (auth.request_headers || ["cookie", "authorization", "x-api-key", "user-agent", "accept"]).join("\n"), "textarea", { rows: 3, maxLength: 8192 });
|
||||
field(taAdvancedGrid, "auth-response-headers", "taResponseHeaders", (auth.response_headers || ["remote-user", "remote-email"]).join("\n"), "textarea", { rows: 3, maxLength: 8192 });
|
||||
field(taAdvancedGrid, "auth-allowlist", "vhAddressAllowlist", (auth.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
|
||||
field(taAdvancedGrid, "auth-ca", "vhCa", auth.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 });
|
||||
|
||||
basicBody.append(text("h3", "authBasic"));
|
||||
const basicGrid = el("div", undefined, "vh-grid"); basicBody.append(basicGrid);
|
||||
basicEditor = basicAuthEditor(basicGrid, value.basic_auth || {});
|
||||
const syncAuth = () => {
|
||||
authEnable.checked = authMode.value === "tinyauth";
|
||||
taBody.hidden = !authEnable.checked;
|
||||
basicBody.hidden = authMode.value !== "basic";
|
||||
authUrl.required = loginUrl.required = authEnable.checked;
|
||||
};
|
||||
authMode.addEventListener("change", syncAuth); syncAuth();
|
||||
g = group("vhResponsePolicy"); const server = value.server_header, diagnostics = value.diagnostic_headers;
|
||||
const serverMode = field(g, "server-mode", "serverHeader", server?.mode || "inherit", "select", { values: [["inherit", "vhInherit"], ["preserve", "serverPreserve"], ["hide", "serverHide"], ["custom", "serverCustom"]] });
|
||||
const serverValue = field(g, "server-value", "serverValue", server?.value || "RustProxy/v0.25.0", "text", { maxLength: 256 });
|
||||
const syncServer = () => { serverValue.disabled = serverMode.value !== "custom"; serverValue.required = !serverValue.disabled; }; serverMode.addEventListener("change", syncServer); syncServer();
|
||||
const inheritDiagnostics = field(g, "diag-inherit", "diagInherit", !diagnostics, "checkbox"), diagInputs = [];
|
||||
for (const [name, key] of [["request_id", "diagRequestId"]]) diagInputs.push(field(g, `diag-${name}`, key, diagnostics?.[name] ?? envelope?.config.diagnostic_headers?.[name], "checkbox"));
|
||||
const syncDiag = () => { for (const input of diagInputs) input.disabled = inheritDiagnostics.checked; }; inheritDiagnostics.addEventListener("change", syncDiag); syncDiag(); note(g, "diagHelp");
|
||||
g = group("vhCache"); const cache = value.cache || {};
|
||||
const tri = (parent, name, key, current) => field(parent, name, key, current === true ? "on" : current === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
|
||||
const cacheToggle = tri(g, "vh-cache-enabled", "cacheEnabled", cache.enabled);
|
||||
if (envelope?.config.cache?.enabled !== true) note(g, "vhCacheGlobalDisabled");
|
||||
const cacheOptions = el("div", undefined, "vh-wide vh-grid vh-cache-options"); g.append(cacheOptions);
|
||||
field(cacheOptions, "vh-cache-ttl", "vhCacheTtl", cache.max_ttl_seconds ?? "", "number", { min: 1, max: 604800 });
|
||||
field(cacheOptions, "vh-cache-default", "vhCacheDefault", cache.default_ttl_seconds ?? "", "number", { min: 0, max: 604800 });
|
||||
field(cacheOptions, "vh-cache-object", "vhCacheObject", cache.max_object_bytes == null ? "" : cache.max_object_bytes / 1024, "number", { min: 1, step: "any", max: (envelope?.config.cache?.max_object_bytes ?? 1048576) / 1024 });
|
||||
tri(cacheOptions, "vh-cache-query", "cacheBypassQuery", cache.bypass_query);
|
||||
tri(cacheOptions, "vh-cache-static-cookie", "vhCacheStaticCookie", cache.static_ignore_cookies);
|
||||
const customStatic = field(cacheOptions, "vh-cache-static-custom", "vhCacheStaticCustom", cache.static_extensions != null, "checkbox");
|
||||
const staticExtensions = field(cacheOptions, "vh-cache-static-extensions", "vhCacheStaticExtensions", (cache.static_extensions || envelope?.config.cache?.static_extensions || []).join("\n"), "textarea", { rows: 5, maxLength: 2048 });
|
||||
const syncStatic = () => { staticExtensions.disabled = !customStatic.checked; }; customStatic.addEventListener("change", syncStatic); syncStatic(); note(cacheOptions, "vhCacheStaticHelp");
|
||||
tri(cacheOptions, "vh-cache-status", "cacheStatusHeader", cache.expose_status); tri(cacheOptions, "vh-cache-hits", "cacheHitsHeader", cache.expose_hits); tri(cacheOptions, "vh-cache-rfc", "cacheRfcHeader", cache.expose_cache_status); tri(cacheOptions, "vh-cache-resident-age", "cacheResidentAge", cache.expose_resident_age);
|
||||
field(cacheOptions, "vh-cache-prefix-custom", "vhCacheCustomPrefixes", cache.bypass_prefixes != null, "checkbox");
|
||||
field(cacheOptions, "vh-cache-prefixes", "cacheBypassPrefixes", (cache.bypass_prefixes || []).join("\n"), "textarea", { rows: 2, maxLength: 32768 }); note(cacheOptions, "vhCacheHint");
|
||||
if (editing) cacheOptions.append(button("vhPurgeCache", () => purge(editing)));
|
||||
const syncCacheVisibility = () => {
|
||||
const enabled = envelope?.config.cache?.enabled === true && cacheToggle.value !== "off";
|
||||
cacheOptions.hidden = !enabled;
|
||||
};
|
||||
cacheToggle.addEventListener("change", syncCacheVisibility); syncCacheVisibility();
|
||||
g = group("vhTransform"); const transform = value.transform || { enabled: false };
|
||||
const transformEnabled = field(g, "vh-transform-enabled", "vhTransformEnable", transform.enabled === true, "checkbox");
|
||||
const transformOptions = el("div", undefined, "vh-wide vh-grid vh-transform-options"); g.append(transformOptions);
|
||||
for (const [name, key] of [["javascript", "transformJavascript"], ["css", "transformCss"], ["html", "transformHtml"], ["svg", "transformSvg"], ["json", "transformJson"]]) tri(transformOptions, `vh-transform-${name}`, key, transform[name]);
|
||||
const customTransformExtensions = field(transformOptions, "vh-transform-extensions-custom", "vhTransformExtensionsCustom", transform.extensions != null, "checkbox");
|
||||
const transformExtensions = field(transformOptions, "vh-transform-extensions", "transformExtensions", (transform.extensions || envelope?.config.transform?.extensions || []).join("\n"), "textarea", { rows: 5, maxLength: 2048 });
|
||||
const syncTransform = () => {
|
||||
transformOptions.hidden = !transformEnabled.checked;
|
||||
transformExtensions.disabled = !customTransformExtensions.checked;
|
||||
};
|
||||
transformEnabled.addEventListener("change", syncTransform); customTransformExtensions.addEventListener("change", syncTransform); syncTransform();
|
||||
note(transformOptions, "vhTransformHelp");
|
||||
g = group("vhHeaders"); headerEditors(g, value.headers || {});
|
||||
g = group("vhPages"); g.classList.add("vh-pages-grid"); pagesEditor = window.rustProxyPages.vhostEditor(g, value.pages || {});
|
||||
initTabs(); dirty = false; if (latestStatus) renderStatus(latestStatus); if (show && !dialog.open) dialog.showModal();
|
||||
|
||||
}
|
||||
function collect() {
|
||||
locationsReader(); // Validate every visual row; never silently save an earlier JSON value.
|
||||
let paths, acl, headers;
|
||||
try {
|
||||
paths = JSON.parse(str("paths")); acl = JSON.parse(str("acl")); headers = JSON.parse(str("headers"));
|
||||
if (!Array.isArray(paths) || (acl !== null && (typeof acl !== "object" || Array.isArray(acl))) || !headers || typeof headers !== "object" || Array.isArray(headers)) throw Error();
|
||||
} catch { throw new Error(t("vhJsonInvalid")); }
|
||||
const members = [...form.querySelectorAll(".vh-member")].map(row => ({ id: str("member-id", row), url: str("member-url", row), weight: num("member-weight", row), enabled: yes("member-enabled", row), pool_eligible: yes("member-pool", row), backup: yes("member-backup", row), drain: yes("member-drain", row), allow_private: yes("member-private", row), accept_local_listener_risk: yes("member-local-risk", row), preserve_host: yes("member-host", row), protocol: str("member-protocol", row), address_allowlist: lines(str("member-allowlist", row)), tls_server_name: nullable("member-tls-name", row), tls_verify: yes("member-tls-verify", row), tls_ca_pem: nullable("member-ca", row), max_concurrent_requests: num("member-max-concurrent", row), queue_timeout_ms: num("member-queue-timeout", row) })).filter(member => member.url);
|
||||
const bindingSelection = readBindings();
|
||||
const mainSourceType = str("source-type");
|
||||
const needsProxy = mainSourceType === "reverse_proxy" || paths.some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
|
||||
if (!bindingSelection.length) throw new Error(t("vhListenerRequired"));
|
||||
if (needsProxy && !members.length) throw new Error(t("vhBackendRequired"));
|
||||
if (mainSourceType === "web_server" && !str("source-web-service")) throw new Error(t("webServiceRequired"));
|
||||
if (paths.some(route => !route.response && route.source?.type === "web_server" && !route.source.web_service_id)) throw new Error(t("webServiceRequired"));
|
||||
if (yes("force-ssl")) {
|
||||
const tlsPorts = new Set(); let tlsSelected = false, httpSelected = false;
|
||||
for (const id of bindingSelection) {
|
||||
const endpoint = envelope?.config.listeners?.endpoints?.find(item => item.id === id);
|
||||
if (!endpoint || endpoint.enabled === false) continue;
|
||||
if (!endpoint.binding?.tls) { httpSelected = true; continue; }
|
||||
tlsSelected = true;
|
||||
for (const address of [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean)) {
|
||||
const match = String(address).match(/:(\d+)$/); if (match) tlsPorts.add(Number(match[1]));
|
||||
}
|
||||
}
|
||||
if (!httpSelected) throw new Error(t("vhForceSslNeedsHttp"));
|
||||
if (!tlsSelected || !tlsPorts.size) throw new Error(t("vhForceSslNeedsTls"));
|
||||
if (!tlsPorts.has(443) && tlsPorts.size !== 1) throw new Error(t("vhForceSslPortConflict"));
|
||||
}
|
||||
if (yes("tls-policy-custom") && !yes("tls-policy-12") && !yes("tls-policy-13")) throw new Error(t("tlsPolicyVersionRequired"));
|
||||
const selectedUsesHttp3 = bindingSelection.some(id => {
|
||||
const endpoint = envelope?.config.listeners?.endpoints?.find(item => item.id === id);
|
||||
return !!endpoint?.binding?.http3;
|
||||
});
|
||||
if (yes("http3-enabled") && selectedUsesHttp3 && yes("tls-policy-custom")) {
|
||||
const selected = { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")).sort() };
|
||||
const inherited = envelope?.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] };
|
||||
const globalPolicy = { tls12: inherited.tls12 !== false, tls13: inherited.tls13 !== false, cipher_suites: [...(inherited.cipher_suites || [])].sort() };
|
||||
if (JSON.stringify(selected) !== JSON.stringify(globalPolicy)) throw new Error(t("vhTlsHttp3Conflict"));
|
||||
}
|
||||
return {
|
||||
id: str("id"), enabled: yes("enabled"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, force_ssl: yes("force-ssl"), forward_client_ip: yes("forward-client-ip"), websockets: yes("websockets"), websocket_legacy_support: yes("websockets") && yes("websocket-legacy-support"), websocket_rfc8441: yes("websockets") && ["h2", "h2h3"].includes(str("websocket-mode")), websocket_rfc9220: yes("websockets") && ["h3", "h2h3"].includes(str("websocket-mode")), http2_enabled: yes("http2-enabled"), http3_enabled: yes("http3-enabled"), cloudflare_client_ip: yes("cloudflare-client-ip"), timing_headers: yes("timing-headers"), backend_header: yes("backend-header"), backend_timing_header: yes("backend-timing-header"), basic_auth: basicEditor.read(), certificate_id: nullable("certificate-id"), tls_policy: yes("tls-policy-custom") ? { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")) } : null, cache: collectCache(), transform: collectTransform(), pages: pagesEditor.read(), frontend: { hosts: readHosts(), listener_ids: bindingSelection }, acl_profile_id: nullable("acl-profile-id"),
|
||||
proxy_timeouts: { connect_timeout_seconds: num("proxy-connect-timeout"), response_header_timeout_seconds: num("proxy-header-timeout"), read_timeout_seconds: num("proxy-read-timeout"), send_timeout_seconds: num("proxy-send-timeout") },
|
||||
rate_limit: { enabled: yes("rate-enabled"), requests: num("rate-requests"), window_seconds: num("rate-window"), burst: num("rate-burst"), country_access: { mode: str("country-mode"), countries: lines(str("country-list")).map(value => value.toUpperCase()) }, country_limits: countryRatesReader() },
|
||||
server_header: str("server-mode") === "inherit" ? null : { mode: str("server-mode"), value: str("server-value") },
|
||||
diagnostic_headers: yes("diag-inherit") ? null : { request_id: yes("diag-request_id") },
|
||||
tinyauth: { enabled: yes("auth-enabled"), url: str("auth-url"), login_url: str("auth-login"), allow_private: yes("auth-private"), redirect_forbidden: yes("auth-redirect-forbidden"), connect_timeout_seconds: num("auth-connect"), timeout_seconds: num("auth-timeout"), request_headers: lines(str("auth-request-headers")), response_headers: lines(str("auth-response-headers")), address_allowlist: lines(str("auth-allowlist")), tls_ca_pem: nullable("auth-ca") }, backends: members, routes: paths, acl, headers,
|
||||
load_balancing: {
|
||||
algorithm: str("algorithm"), slow_start_seconds: num("slow-start"), keepalive: { enabled: yes("keepalive"), idle_timeout_seconds: num("idle-timeout"), max_idle_per_host: num("max-idle"), tcp_keepalive_seconds: num("tcp-keepalive") },
|
||||
retry: { max_retries: num("retry-max"), retry_connect_errors: yes("retry-connect"), retry_timeouts: yes("retry-timeouts"), retry_statuses: str("retry-statuses").split(/[\s,]+/).filter(Boolean).map(value => Number(value)) },
|
||||
health_check: { mode: str("health-mode"), path: str("health-path"), host: nullable("health-host"), interval_ms: num("interval"), timeout_ms: num("timeout"), slow_response_ms: num("slow"), fall: num("fall"), rise: num("rise"), expected_status_min: num("status-min"), expected_status_max: num("status-max") },
|
||||
passive_health: {
|
||||
enabled: yes("passive-enabled"),
|
||||
count_connect_errors: yes("passive-connect"),
|
||||
count_timeouts: yes("passive-timeouts"),
|
||||
count_io_errors: yes("passive-io"),
|
||||
count_http_5xx: yes("passive-5xx"),
|
||||
count_slow_responses: yes("passive-slow"),
|
||||
fall: str("passive-fall") === "" ? null : num("passive-fall"),
|
||||
rise: str("passive-rise") === "" ? null : num("passive-rise")
|
||||
},
|
||||
sticky: { mode: str("sticky-mode"), cookie_name: nullable("cookie-name"), ttl_seconds: num("cookie-ttl") }
|
||||
},
|
||||
security_headers: { hsts: { enabled: yes("hsts"), max_age_seconds: num("hsts-age"), include_subdomains: yes("hsts-sub"), preload: yes("hsts-preload") }, nosniff: yes("nosniff"), frame_options: str("frame"), referrer_policy: nullable("referrer"), content_security_policy: nullable("csp"), permissions_policy: nullable("permissions") }
|
||||
};
|
||||
}
|
||||
function render() {
|
||||
if (!envelope) return;
|
||||
if (!initialized) load(defaults(), [], false);
|
||||
const config = envelope.config;
|
||||
const configuredHasProxySource = (config.vhosts || []).some(vhost =>
|
||||
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
|
||||
);
|
||||
const diagnosticsCard = $("backend-diagnostics-card");
|
||||
if (diagnosticsCard) diagnosticsCard.hidden = !configuredHasProxySource;
|
||||
const query = ($("vhost-search")?.value || "").trim().toLowerCase();
|
||||
const visibleVhosts = (config.vhosts || []).filter(v => {
|
||||
if (!query) return true;
|
||||
const haystack = [
|
||||
v.id,
|
||||
...(v.frontend?.hosts || []),
|
||||
...(v.backends || []).flatMap(backend => [backend.id, backend.url]),
|
||||
v.source?.web_service_id,
|
||||
...(v.routes || []).map(route => route.source?.web_service_id),
|
||||
].filter(Boolean).join(" ").toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
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");
|
||||
const title = el("div", undefined, "vhost-title-line"); title.append(el("strong", v.id, "entity-title"));
|
||||
const state = el("span", t(v.enabled === false ? "vhDisabled" : "vhEnabled"), `pill ${v.enabled === false ? "" : "live"}`); title.append(state);
|
||||
if (v.source?.type === "web_server") title.append(el("span", `${t("webServer")}: ${v.source.web_service_id}`, "pill"));
|
||||
identity.append(title, el("div", (v.frontend?.hosts || []).join(", ") || "-", "vhost-hosts")); head.append(identity);
|
||||
const actions = el("div", undefined, "entity-actions");
|
||||
const configuredHosts = (v.frontend?.hosts || []).filter(Boolean);
|
||||
const browseHost = configuredHosts.find(host => !host.startsWith("*.")) || configuredHosts.find(host => host.startsWith("*."))?.slice(2) || null;
|
||||
const configuredEndpoints = (v.frontend?.listener_ids || []).map(id => config.listeners?.endpoints?.find(endpoint => endpoint.id === id)).filter(Boolean);
|
||||
const browseTls = configuredEndpoints.some(endpoint => endpoint.binding?.tls === true);
|
||||
const openButton = button("vhOpenBrowser", () => { if (browseHost) window.open(`${browseTls ? "https" : "http"}://${browseHost}/`, "_blank", "noopener,noreferrer"); });
|
||||
openButton.disabled = !browseHost || v.enabled === false;
|
||||
actions.append(openButton, button("edit", async () => { if (await allowDiscard()) load(structuredClone(v)); }), button("remove", async () => {
|
||||
if (busy || !(await appConfirm(t("vhDelete"), { danger: true }))) return;
|
||||
const next = structuredClone(envelope.config); next.vhosts = (next.vhosts || []).filter(item => item.id !== v.id);
|
||||
if (await saveSection("vhosts", next.vhosts || [])) { if (editing === v.id) load(); render(); }
|
||||
}), button("liveLogs", () => window.rustProxyLogs.open(v.id)), button("vhPurgeCache", () => purge(v.id))); head.append(actions); box.append(head);
|
||||
|
||||
const details = el("div", undefined, "vhost-detail-strip");
|
||||
const selectedEndpoints = (v.frontend?.listener_ids || []).map(id =>
|
||||
config.listeners?.endpoints?.find(e => e.id === id) || { id }
|
||||
);
|
||||
const listeners = selectedEndpoints.map(endpoint => {
|
||||
const addresses = [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean).join(", ");
|
||||
return `${endpoint.name || endpoint.id} · ${addresses || t("listenerMissing")}`;
|
||||
});
|
||||
const supportsH2 = selectedEndpoints.some(endpoint => endpoint.binding?.http2);
|
||||
const supportsH3 = selectedEndpoints.some(endpoint => endpoint.binding?.http3);
|
||||
const protocols = [
|
||||
"HTTP/1.1",
|
||||
v.http2_enabled !== false && supportsH2 ? "H2" : null,
|
||||
v.http3_enabled !== false && supportsH3 ? "H3" : null,
|
||||
].filter(Boolean).join(" + ");
|
||||
const tlsPolicy = v.tls_policy ? [v.tls_policy.tls12 ? "TLS 1.2" : null, v.tls_policy.tls13 ? "TLS 1.3" : null].filter(Boolean).join(" + ") : t("inherit");
|
||||
const detailItems = [
|
||||
["vhListeners", listeners.join(" | ") || "-"],
|
||||
["vhProtocols", protocols],
|
||||
["tlsPolicy", tlsPolicy],
|
||||
["vhCertificate", v.certificate_id || t("vhCertificateDefault")],
|
||||
["vhAclProfile", v.acl_profile_id ? ((config.acl_profiles || []).find(profile => profile.id === v.acl_profile_id)?.name || v.acl_profile_id) : t("vhAclProfileNone")],
|
||||
["cache", v.cache?.enabled === true ? t("enabled") : v.cache?.enabled === false ? t("notEnabled") : t("inherit")],
|
||||
["vhBackendPool", v.source?.type === "web_server"
|
||||
? ((v.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy") && (v.backends || []).length
|
||||
? `${t("webServer")} + ${(v.backends || []).length}`
|
||||
: t("webServer"))
|
||||
: `${(v.backends || []).length}`],
|
||||
];
|
||||
for (const [key, value] of detailItems) { const item = el("div", undefined, "vhost-detail"); item.append(text("span", key, "small-label"), el("b", value)); details.append(item); }
|
||||
box.append(details);
|
||||
|
||||
const live = el("div", undefined, "vhost-live-grid");
|
||||
const metric = (key, suffix) => { const item = el("div", undefined, "vhost-live-metric"); item.append(text("span", key), el("strong", "-")); item.querySelector("strong").dataset[suffix] = v.id; live.append(item); };
|
||||
metric("status", "vhostHealth"); metric("vhRequests", "vhostRequests"); metric("vhActive", "vhostActive"); metric("vhErrors", "vhostErrors"); metric("vhLatency", "vhostLatency"); metric("vhTraffic", "vhostTraffic");
|
||||
box.append(live);
|
||||
const chart = el("div", undefined, "vhost-chart"); const chartHead = el("div", undefined, "vhost-chart-head"); chartHead.append(text("span", "vhLifetimeChart", "small-label")); const chartValue = el("span", "-", "fine"); chartValue.dataset.chartValueFor = v.id; chartHead.append(chartValue); chart.append(chartHead);
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 320 54"); svg.setAttribute("preserveAspectRatio", "none"); svg.dataset.chartFor = v.id; svg.setAttribute("aria-label", t("vhLifetimeChart")); const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); polyline.setAttribute("class", "vhost-chart-line"); svg.append(polyline); chart.append(svg); box.append(chart);
|
||||
const hasProxySource = v.source?.type !== "web_server" || (v.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
|
||||
if (hasProxySource) {
|
||||
const health = el("p", t("vhHealthEmpty"), "vh-health-summary"); health.dataset.healthFor = v.id; box.append(health);
|
||||
}
|
||||
const policy = el("div", undefined, "vhost-policy-pills");
|
||||
if (v.basic_auth?.enabled) policy.append(el("small", t("authBasic"), "pill"));
|
||||
if (v.tinyauth?.enabled) policy.append(el("small", "Tinyauth", "pill"));
|
||||
if (v.rate_limit?.enabled) policy.append(el("small", t("vhRateEnabled"), "pill"));
|
||||
if (v.acl_profile_id) policy.append(el("small", `${t("vhAclProfile")}: ${v.acl_profile_id}`, "pill"));
|
||||
if (v.force_ssl) policy.append(el("small", t("vhForceSsl"), "pill"));
|
||||
if (v.forward_client_ip === false) policy.append(el("small", t("vhClientIpHidden"), "pill"));
|
||||
if (v.websockets) {
|
||||
const transports = ["H1"];
|
||||
if (v.websocket_rfc8441) transports.push("H2/RFC8441");
|
||||
if (v.websocket_rfc9220) transports.push("H3/RFC9220");
|
||||
policy.append(el("small", `WebSocket · ${transports.join("+")}`, "pill"));
|
||||
if (v.websocket_legacy_support) policy.append(el("small", t("vhWebsocketLegacyBadge"), "pill"));
|
||||
}
|
||||
if (policy.children.length) box.append(policy);
|
||||
return box;
|
||||
});
|
||||
$("vhost-list").replaceChildren(...(cards.length ? cards : [text("p", query ? "vhSearchEmpty" : "vhEmpty", "fine")]));
|
||||
$("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
|
||||
if (latestStatus) renderStatus(latestStatus);
|
||||
}
|
||||
// SSE snapshots arrive slower than the backend sampler, so a short burst can be zeroed before the UI sees it.
|
||||
// Fall back to the byte-counter delta for this dashboard interval when the reported instantaneous rate is zero.
|
||||
function backendTrafficRate(poolId, member, snapshot, now, seen) {
|
||||
const key = `${poolId}\u0000${member.id}`; seen.add(key);
|
||||
const total = Math.max(0, Number(member.bytes_in) || 0) + Math.max(0, Number(member.bytes_out) || 0);
|
||||
const reported = Math.max(0, Number(member.traffic_bytes_per_second) || 0);
|
||||
const previous = backendTrafficHistory.get(key);
|
||||
if (previous?.snapshot === snapshot) return previous.rate;
|
||||
const elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0;
|
||||
const observed = previous && total >= previous.total ? (total - previous.total) / elapsed : 0;
|
||||
const rate = reported > 0 ? reported : observed;
|
||||
backendTrafficHistory.set(key, { total, at: now, snapshot, rate });
|
||||
return rate;
|
||||
}
|
||||
function vhostTrafficRate(id, metrics, snapshot, now, seen) {
|
||||
seen.add(id);
|
||||
const total = Math.max(0, Number(metrics?.bytes_in) || 0) + Math.max(0, Number(metrics?.bytes_out) || 0);
|
||||
const previous = vhostTrafficHistory.get(id);
|
||||
if (previous?.snapshot === snapshot) return previous.rate;
|
||||
const elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0;
|
||||
const rate = previous && total >= previous.total ? (total - previous.total) / elapsed : 0;
|
||||
vhostTrafficHistory.set(id, { total, at: now, snapshot, rate });
|
||||
return rate;
|
||||
}
|
||||
function renderStatus(status) {
|
||||
form.querySelectorAll(".vh-member").forEach(row => row.syncLocalRisk?.());
|
||||
const rows = [], summaries = new Map(), aggregate = new Map();
|
||||
const trafficSnapshot = status, trafficNow = performance.now(), seenTraffic = new Set(), seenVhostTraffic = new Set();
|
||||
const statusKeys = { up: "vhUp", down: "vhDown", checking: "vhChecking", draining: "vhDraining", disabled: "vhDisabled" };
|
||||
const configuredVhosts = new Map((envelope?.config.vhosts || []).map(vhost => [vhost.id, vhost]));
|
||||
for (const pool of status.backend_pools || []) {
|
||||
const members = pool.members || [], vhostDisabled = configuredVhosts.get(pool.id)?.enabled === false;
|
||||
let up = 0, active = 0, requests = 0, errors = 0, traffic = 0, weightedLatency = 0, latencyWeight = 0;
|
||||
for (const member of members) {
|
||||
const memberTraffic = backendTrafficRate(pool.id, member, trafficSnapshot, trafficNow, seenTraffic);
|
||||
const label = vhostDisabled ? t("vhDisabled") : (pool.health_mode === "off" && member.status === "up" ? t("vhHealthDisabled") : t(statusKeys[member.status] || "vhHealthUnknown"));
|
||||
const activeLimit = Number(member.max_concurrent_requests) > 0 ? `${member.active ?? 0}/${member.max_concurrent_requests}` : `${member.active ?? 0}/∞`;
|
||||
const lastFailure = Number(member.last_failure_at_ms) > 0 ? `${member.last_failure_class || "-"} · ${new Date(member.last_failure_at_ms).toLocaleTimeString(locale())}` : "-";
|
||||
const lastRecovery = Number(member.last_recovered_ms) > 0 ? new Date(member.last_recovered_ms).toLocaleTimeString(locale()) : "-";
|
||||
const healthDetail = `${t("vhActiveHealth")}: ${member.last_result || "-"} · ${t("vhPassiveFailures")}: ${member.passive_failures ?? 0} (${member.last_passive_result || "-"})`;
|
||||
const row = el("tr"); if (vhostDisabled) row.classList.add("pool-vhost-disabled"); const poolDomain = status.backend_pool_domains?.[pool.id]; const poolLabel = poolDomain ? `${pool.id} (${poolDomain})` : pool.id; const cells = [poolLabel, `${member.id}${member.backup ? " / " + t("vhBackup") : ""}`, label, activeLimit, member.requests, member.errors,
|
||||
member.ewma_latency_ms > 0 ? `${member.ewma_latency_ms.toFixed(1)} ms` : "-", `${rateBytes(memberTraffic)}/s`, lastFailure, lastRecovery];
|
||||
cells.forEach((value, index) => { const cell = el("td", String(value ?? "-")); cell.title = index === 2 ? (vhostDisabled ? t("vhDisabled") : `${label} · ${healthDetail}`) : index === 8 ? (member.last_failure_detail || lastFailure) : String(value ?? "-"); if (index === 2 && !vhostDisabled) cell.className = member.status === "down" ? "status-error" : member.status === "up" ? "status-good" : ""; if (index === 8 && member.last_failure_at_ms) cell.classList.add("backend-last-failure"); row.append(cell); }); rows.push(row);
|
||||
if (!vhostDisabled && member.status === "up") up++; active += Number(member.active) || 0; requests += Number(member.requests) || 0; errors += Number(member.errors) || 0; traffic += memberTraffic;
|
||||
const latency = Number(member.ewma_latency_ms || member.latency_ms) || 0; if (latency > 0) { const weight = Math.max(1, Number(member.requests) || 1); weightedLatency += latency * weight; latencyWeight += weight; }
|
||||
}
|
||||
const summary = vhostDisabled ? t("vhDisabled") : members.map(member => `${member.id}: ${pool.health_mode === "off" && member.status === "up" ? t("vhHealthDisabled") : t(statusKeys[member.status] || "vhHealthUnknown")} / ${Number(member.latency_ms ?? member.ewma_latency_ms) > 0 ? `${Number(member.latency_ms ?? member.ewma_latency_ms).toFixed(1)} ms` : "-"} / ${member.active ?? 0} ${t("vhActive")}`).join(" | ");
|
||||
summaries.set(pool.id, summary || t("vhHealthEmpty"));
|
||||
aggregate.set(pool.id, {
|
||||
disabled: vhostDisabled,
|
||||
healthMode: pool.health_mode,
|
||||
up,
|
||||
total: members.length,
|
||||
active,
|
||||
requests,
|
||||
errors,
|
||||
traffic,
|
||||
latency: latencyWeight ? weightedLatency / latencyWeight : 0,
|
||||
});
|
||||
}
|
||||
for (const key of backendTrafficHistory.keys()) if (!seenTraffic.has(key)) backendTrafficHistory.delete(key);
|
||||
for (const [id, vhost] of configuredVhosts) {
|
||||
const existing = aggregate.get(id) || {};
|
||||
const metrics = status.vhost_metrics?.[id];
|
||||
const hasProxySource = vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
|
||||
const disabled = vhost.enabled === false;
|
||||
aggregate.set(id, {
|
||||
...existing,
|
||||
disabled,
|
||||
staticOnly: !hasProxySource,
|
||||
healthMode: existing.healthMode,
|
||||
up: existing.up ?? 0,
|
||||
total: existing.total ?? 0,
|
||||
active: metrics ? Number(metrics.active) || 0 : existing.active ?? 0,
|
||||
requests: metrics ? Number(metrics.requests) || 0 : existing.requests ?? 0,
|
||||
errors: metrics ? Number(metrics.errors) || 0 : existing.errors ?? 0,
|
||||
traffic: metrics ? vhostTrafficRate(id, metrics, trafficSnapshot, trafficNow, seenVhostTraffic) : existing.traffic ?? 0,
|
||||
latency: metrics ? Number(metrics.ewma_latency_ms) || 0 : existing.latency ?? 0,
|
||||
});
|
||||
}
|
||||
for (const key of vhostTrafficHistory.keys()) if (!seenVhostTraffic.has(key)) vhostTrafficHistory.delete(key);
|
||||
const hasConfiguredProxySource = [...configuredVhosts.values()].some(vhost =>
|
||||
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
|
||||
);
|
||||
$("pool-rows").replaceChildren(...rows);
|
||||
$("pool-empty").hidden = rows.length > 0 || !hasConfiguredProxySource;
|
||||
const backendDiagnosticsCard = $("backend-diagnostics-card");
|
||||
if (backendDiagnosticsCard) backendDiagnosticsCard.hidden = !hasConfiguredProxySource;
|
||||
document.querySelectorAll("[data-health-for]").forEach(node => node.textContent = summaries.get(node.dataset.healthFor) || t("vhHealthEmpty"));
|
||||
const fill = (attr, format) => document.querySelectorAll(`[data-${attr.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`)}]`).forEach(node => { const data = aggregate.get(node.dataset[attr]); node.textContent = data ? format(data) : "-"; });
|
||||
fill("vhostHealth", d => d.disabled ? t("vhDisabled") : d.staticOnly ? t("vhUp") : d.healthMode === "off" ? t("vhHealthDisabled") : `${d.up}/${d.total} ${t("vhUp")}`);
|
||||
fill("vhostRequests", d => fmt(d.requests)); fill("vhostActive", d => fmt(d.active)); fill("vhostErrors", d => fmt(d.errors));
|
||||
fill("vhostLatency", d => d.latency > 0 ? `${fmt(d.latency, 1)} ms` : "-"); fill("vhostTraffic", d => `${rateBytes(d.traffic)}/s`);
|
||||
const now = performance.now();
|
||||
for (const [id, data] of aggregate) {
|
||||
const history = chartHistory.get(id) || { lastTime: now, lastRequests: data.requests, points: [], bucketSize: 1, pendingSum: 0, pendingCount: 0 };
|
||||
const elapsed = Math.max(.001, (now - history.lastTime) / 1000); const rate = data.requests >= history.lastRequests ? (data.requests - history.lastRequests) / elapsed : 0; history.lastTime = now; history.lastRequests = data.requests;
|
||||
history.pendingSum += rate; history.pendingCount += 1;
|
||||
if (history.pendingCount >= history.bucketSize) { history.points.push(history.pendingSum / history.pendingCount); history.pendingSum = 0; history.pendingCount = 0; }
|
||||
if (history.points.length >= 120) { const compacted = []; for (let i = 0; i < history.points.length; i += 2) compacted.push((history.points[i] + history.points[i + 1]) / 2); history.points = compacted; history.bucketSize *= 2; }
|
||||
chartHistory.set(id, history);
|
||||
const displayPoints = history.pendingCount ? [...history.points, history.pendingSum / history.pendingCount] : history.points; const actualPeak = Math.max(0, ...displayPoints), scalePeak = Math.max(1, actualPeak); const points = displayPoints.map((value, index) => `${index * 320 / Math.max(1, displayPoints.length - 1)},${50 - value / scalePeak * 44}`).join(" ");
|
||||
const svg = document.querySelector(`[data-chart-for="${CSS.escape(id)}"]`); svg?.querySelector("polyline")?.setAttribute("points", points); const label = document.querySelector(`[data-chart-value-for="${CSS.escape(id)}"]`); if (label) label.textContent = `${rate.toFixed(1)} ${t("vhReqPerSecond")} · ${t("vhPeak")} ${actualPeak.toFixed(1)}`;
|
||||
}
|
||||
const editingVhost = editing ? configuredVhosts.get(editing) : null;
|
||||
const editingHasProxy = editingVhost && (editingVhost.source?.type !== "web_server" || (editingVhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy"));
|
||||
$("vhost-health-live").hidden = !!editingVhost && !editingHasProxy;
|
||||
$("vhost-health-live").textContent = editing ? (editingHasProxy ? (summaries.get(editing) || t("vhHealthEmpty")) : "") : t("vhNewHealthHint");
|
||||
}
|
||||
form.addEventListener("input", changed); form.addEventListener("change", changed);
|
||||
form.addEventListener("submit", async event => {
|
||||
event.preventDefault(); if (!envelope || busy || uploading) return;
|
||||
const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) { activateTab(invalid.closest(".vh-group")?.dataset.tab || "details"); const details = invalid.closest("details"); if (details) details.open = true; invalid.reportValidity(); return; }
|
||||
if (revision !== envelope.revision) { toast(t("stale"), true); return; }
|
||||
try {
|
||||
const epoch = draftEpoch; uploading = true;
|
||||
try { await basicEditor.prepare(); } finally { uploading = false; }
|
||||
if (epoch !== draftEpoch || revision !== envelope.revision || !dialog.open) throw new Error(t("stale"));
|
||||
const value = collect(), next = structuredClone(envelope.config); next.vhosts ||= [];
|
||||
const debugIssues = debugOriginIssues(value.backends); if (debugIssues.length && !(await appConfirm(`${t("vhDebugOriginMismatch")}\n\n${debugIssues.map(member => member.id).join(", ")}`, { danger: true }))) return;
|
||||
if (next.vhosts.some(v => v.id === value.id && v.id !== editing)) throw new Error(t("vhConflict"));
|
||||
if (editing && value.id !== editing && !(await appConfirm(t("vhIdRenameConfirm").replace("{old}", editing).replace("{new}", value.id), { danger: true }))) return;
|
||||
const addsCertificate = stagedCertificates.some(c => c.id === value.certificate_id);
|
||||
if (addsCertificate) { next.tls ||= {}; next.tls.certificates ||= []; for (const cert of stagedCertificates.filter(c => c.id === value.certificate_id)) if (!next.tls.certificates.some(c => c.id === cert.id)) next.tls.certificates.push(cert); }
|
||||
const index = next.vhosts.findIndex(v => v.id === editing); if (index < 0) next.vhosts.push(value); else next.vhosts[index] = value;
|
||||
const saved = addsCertificate ? await save(next) : await saveSection("vhosts", next.vhosts || []);
|
||||
if (saved) { load(value, [], false); dialog.close(); render(); }
|
||||
} catch (error) { toast(error.message, true); }
|
||||
});
|
||||
$("vhost-new").addEventListener("click", async () => { if (await allowDiscard()) load(); });
|
||||
$("vhost-search").addEventListener("input", render);
|
||||
$("vhost-reset").addEventListener("click", async () => {
|
||||
if (!(await allowDiscard())) return;
|
||||
const saved = editing ? (envelope?.config.vhosts || []).find(item => item.id === editing) : null;
|
||||
load(saved ? structuredClone(saved) : defaults());
|
||||
});
|
||||
$("pool-open").addEventListener("click", () => setPanel("vhosts"));
|
||||
$("location-help-close")?.addEventListener("click", () => $("location-help-dialog")?.close());
|
||||
window.rustProxyVhosts = { render, renderStatus, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); } };
|
||||
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);
|
||||
})();
|
||||
@@ -0,0 +1,402 @@
|
||||
"use strict";
|
||||
// Vhost UI: language content comes from langs/*.json, never a locale switch.
|
||||
// Drafts are isolated from SSE/config rendering; writes share revision+CSRF CAS.
|
||||
let basicEditor = null, locationsReader = () => [], aclControl = null, editing = null, revision = null, dirty = false, initialized = false, readBindings = () => [], readHosts = () => [], countryRatesReader = () => [], pagesEditor = null, stagedCertificates = [], uploading = false, draftEpoch = 0;
|
||||
const chartHistory = new Map();
|
||||
const backendTrafficHistory = new Map();
|
||||
const vhostTrafficHistory = new Map();
|
||||
const dialog = $("vhost-dialog");
|
||||
const form = $("vhost-form"), container = $("vhost-fields");
|
||||
const text = (tag, key, cls) => { const node = el(tag, t(key), cls); node.dataset.i18n = key; return node; };
|
||||
const field = (parent, name, key, value, type = "text", options = {}) => {
|
||||
const label = el("label", undefined, type === "checkbox" ? "check vh-check" : "vh-field");
|
||||
const input = document.createElement(type === "textarea" ? "textarea" : type === "select" ? "select" : "input");
|
||||
input.name = name;
|
||||
if (type === "select") for (const [val, key] of options.values || []) { const option = text("option", key); option.value = val; input.append(option); }
|
||||
else if (type !== "textarea") input.type = type;
|
||||
if (type === "checkbox") input.checked = Boolean(value); else input.value = value ?? "";
|
||||
for (const attr of ["min", "max", "step", "maxLength", "rows", "required", "pattern", "readOnly"]) if (Object.hasOwn(options, attr)) input[attr] = options[attr];
|
||||
if (type === "textarea") input.spellcheck = false;
|
||||
if (type === "checkbox") label.append(input, text("span", key)); else label.append(text("span", key), input);
|
||||
parent.append(label); return input;
|
||||
};
|
||||
const group = (key, advanced = false) => {
|
||||
const box = el(advanced ? "details" : "fieldset", undefined, "vh-group");
|
||||
box.dataset.tab = ({ vhEditor: "details", vhFrontends: "details", vhBackendPool: "backends", vhBackendDiagnostics: "backends", vhProxyTimeouts: "backends", vhBalancing: "backends", vhSticky: "backends", vhHealth: "health", vhPassiveHealth: "health", vhSecurity: "security", vhTrafficPolicy: "security", vhTinyauth: "auth", vhResponsePolicy: "security", vhHeaders: "security", vhAdvanced: "advanced", vhLocations: "locations", vhSsl: "ssl", vhCache: "cache", vhTransform: "transform", vhPages: "pages" })[key] || "advanced"; box.append(text(advanced ? "summary" : "legend", key));
|
||||
const body = el("div", undefined, "vh-grid"); box.append(body); container.append(box); return body;
|
||||
};
|
||||
const note = (parent, key) => { const node = text("p", key, "fine vh-wide"); parent.append(node); return node; };
|
||||
const button = (key, callback, cls = "quiet") => { const b = text("button", key, cls); b.type = "button"; b.addEventListener("click", callback); return b; };
|
||||
const read = (name, scope = form) => scope.querySelector(`[name="${name}"]`);
|
||||
const str = (name, scope) => read(name, scope).value.trim();
|
||||
const num = (name, scope) => { const value = Number(str(name, scope)); if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; };
|
||||
const yes = (name, scope) => read(name, scope).checked;
|
||||
const lines = value => value.split(/[\n,]+/).map(s => s.trim()).filter(Boolean);
|
||||
const nullable = (name, scope) => str(name, scope) || null;
|
||||
const defaultProxyTimeouts = () => ({ connect_timeout_seconds: 10, response_header_timeout_seconds: 120, read_timeout_seconds: 60, send_timeout_seconds: 60 });
|
||||
const changed = () => { dirty = true; };
|
||||
const fillWebServices = (select, chosen = "") => {
|
||||
const first = el("option", t("webServiceSelect")); first.value = "";
|
||||
select.replaceChildren(first, ...(envelope?.config.web_services || []).map(service => { const option = el("option", service.name || service.id); option.value = service.id; return option; }));
|
||||
if (chosen && !Array.from(select.options).some(option => option.value === chosen)) { const missing = el("option", `${chosen} / ${t("locationMissing")}`); missing.value = chosen; select.append(missing); }
|
||||
select.value = chosen;
|
||||
};
|
||||
const allowDiscard = async () => { if (!dirty) return true; if (!(await appConfirm(t("vhUnsaved"), { danger: true, confirmLabel: t("discardAction") }))) return false; dirty = false; return true; };
|
||||
const defaults = () => ({
|
||||
id: "", enabled: true, force_ssl: false, forward_client_ip: true, websockets: false, websocket_legacy_support: false, websocket_rfc8441: false, websocket_rfc9220: false, http2_enabled: true, http3_enabled: true, cloudflare_client_ip: false, timing_headers: false, backend_header: false, backend_timing_header: false,
|
||||
frontend: { hosts: [], listener_ids: [] },
|
||||
source: { type: "reverse_proxy" },
|
||||
backends: [{ id: "origin-1", url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }],
|
||||
cache: { enabled: false }, transform: { enabled: false }, rate_limit: {}, routes: [], headers: {}, acl_profile_id: null, acl: null, tls_policy: null, proxy_timeouts: defaultProxyTimeouts()
|
||||
});
|
||||
|
||||
function activateTab(name) { document.querySelectorAll("#vhost-tabs button").forEach(b => { const active = b.dataset.tab === name; b.classList.toggle("active", active); b.setAttribute("aria-pressed", String(active)); b.setAttribute("aria-selected", String(active)); b.tabIndex = active ? 0 : -1; }); container.querySelectorAll(".vh-group").forEach(g => g.hidden = g.dataset.tab !== name); }
|
||||
function initTabs() { const tabs = $("vhost-tabs"); tabs.replaceChildren(); tabs.setAttribute("role", "tablist"); for (const [name, key] of [["details", "vhDetailsTab"], ["backends", "vhBackendPool"], ["health", "vhHealth"], ["locations", "vhLocations"], ["auth", "vhTabAuth"], ["ssl", "vhSsl"], ["cache", "vhCache"], ["transform", "vhTransform"], ["security", "vhSecurity"], ["pages", "vhPages"], ["advanced", "advanced"]]) { const b = button(key, () => activateTab(name)); b.dataset.tab = name; b.setAttribute("role", "tab"); tabs.append(b); } activateTab("details"); }
|
||||
function collectCache() { const tri = name => str(name) === "" ? null : str(name) === "on", number = (name, mul = 1) => { if (str(name) === "") return null; const value = Number(str(name)) * mul; if (!Number.isSafeInteger(value)) throw new Error(t("vhJsonInvalid")); return value; }; return { enabled: tri("vh-cache-enabled"), max_ttl_seconds: number("vh-cache-ttl"), default_ttl_seconds: number("vh-cache-default"), max_object_bytes: number("vh-cache-object", 1024), bypass_query: tri("vh-cache-query"), static_ignore_cookies: tri("vh-cache-static-cookie"), static_extensions: yes("vh-cache-static-custom") ? lines(str("vh-cache-static-extensions")).map(v => v.replace(/^\./, "").toLowerCase()) : null, expose_status: tri("vh-cache-status"), expose_hits: tri("vh-cache-hits"), expose_cache_status: tri("vh-cache-rfc"), expose_resident_age: tri("vh-cache-resident-age"), bypass_prefixes: yes("vh-cache-prefix-custom") ? lines(str("vh-cache-prefixes")) : null }; }
|
||||
function collectTransform() {
|
||||
const tri = name => str(name) === "" ? null : str(name) === "on";
|
||||
return {
|
||||
enabled: yes("vh-transform-enabled"),
|
||||
javascript: tri("vh-transform-javascript"),
|
||||
css: tri("vh-transform-css"),
|
||||
html: tri("vh-transform-html"),
|
||||
svg: tri("vh-transform-svg"),
|
||||
json: tri("vh-transform-json"),
|
||||
extensions: yes("vh-transform-extensions-custom") ? lines(str("vh-transform-extensions")).map(v => v.replace(/^\./, "").toLowerCase()) : null
|
||||
};
|
||||
}
|
||||
async function purge(id) { if (!(await appConfirm(t("vhPurgeConfirm"), { danger: true }))) return; try { const result = await api(`/api/cache/purge?vhost=${encodeURIComponent(id)}`, { method: "POST", body: "{}" }); toast(`${t("cachePurged")}: ${result.purged_entries}`); await refresh(true); } catch (error) { toast(error.message, true); } }
|
||||
function validateHeaders(value) {
|
||||
const fail = () => { throw new Error(t("headersInvalid")); };
|
||||
if (!value || Array.isArray(value) || typeof value !== "object" || Object.keys(value).some(k => !["request", "response"].includes(k))) fail();
|
||||
for (const direction of ["request", "response"]) {
|
||||
const rules = value[direction] || []; if (!Array.isArray(rules) || rules.length > 32) fail(); for (const rule of rules) {
|
||||
if (!rule || typeof rule !== "object" || Object.keys(rule).some(k => !["op", "name", "value"].includes(k)) || !["set", "append", "remove"].includes(rule.op) || typeof rule.name !== "string" || rule.name.length > 128 || !/^[-!#$%&'*+.^_`|~0-9A-Za-z]+$/.test(rule.name)) fail();
|
||||
const name = rule.name.toLowerCase(); if (["host", "connection", "content-length", "transfer-encoding", "te", "trailer", "content-encoding", "content-range", "upgrade", "keep-alive", "proxy-connection", "proxy-authorization", "proxy-authenticate", "forwarded", "x-real-ip", "x-request-id", "x-rust-proxy-backend", "x-rust-proxy-backend-time", "x-rustproxy-backend-time"].includes(name) || name.startsWith("x-forwarded-") || name.startsWith("sec-websocket-")) throw new Error(t("headersProtected"));
|
||||
if (rule.op === "remove") { if (Object.hasOwn(rule, "value") && rule.value !== null) fail(); } else if (typeof rule.value !== "string" || rule.value.length > 8192 || /[\r\n\0]/.test(rule.value)) fail();
|
||||
}
|
||||
} return value;
|
||||
}
|
||||
function headerEditors(parent, initial, target = read("headers"), onChange = changed) {
|
||||
const holders = {}, adders = {}, emptyStates = {};
|
||||
const renumber = direction => {
|
||||
const rows = holders[direction];
|
||||
[...rows.children].forEach((row, index) => {
|
||||
const title = row.querySelector(".vh-header-rule-title");
|
||||
if (title) title.textContent = `${t("headerRule")} ${index + 1}`;
|
||||
});
|
||||
if (emptyStates[direction]) emptyStates[direction].hidden = rows.children.length > 0;
|
||||
};
|
||||
const sync = () => {
|
||||
const result = {};
|
||||
for (const direction of ["request", "response"]) result[direction] = [...holders[direction].children].map(row => {
|
||||
const op = str("rule-op", row), name = str("rule-name", row), value = read("rule-value", row).value;
|
||||
return op === "remove" ? { op, name } : { op, name, value };
|
||||
});
|
||||
target.value = JSON.stringify(result, null, 2); target.setCustomValidity(""); onChange();
|
||||
};
|
||||
for (const direction of ["request", "response"]) {
|
||||
const block = el("section", undefined, "vh-wide location-header-direction"); block.dataset.headerDirection = direction;
|
||||
const heading = el("div", undefined, "location-header-direction-head");
|
||||
heading.append(text("h3", direction === "response" ? "locationResponseHeaders" : "locationProxyHeaders"), text("p", "headerRuleHelp", "fine"));
|
||||
block.append(heading);
|
||||
const empty = text("p", "headerRulesEmpty", "fine vh-header-empty"); emptyStates[direction] = empty; block.append(empty);
|
||||
const rows = el("div", undefined, "vh-header-rows"); holders[direction] = rows; block.append(rows); parent.append(block);
|
||||
const add = (value = { op: "set", name: "", value: "" }) => {
|
||||
if (rows.children.length >= 32) { toast(t("headerLimit"), true); return; }
|
||||
const row = el("article", undefined, "vh-header-row");
|
||||
const rowHead = el("div", undefined, "vh-header-rule-head"), rowTitle = el("strong", "", "vh-header-rule-title");
|
||||
rowHead.append(rowTitle, button("remove", () => { row.remove(); renumber(direction); sync(); })); row.append(rowHead);
|
||||
const fields = el("div", undefined, "vh-header-rule-fields");
|
||||
field(fields, "rule-op", "headerOperation", value.op, "select", { values: [["set", "header_set"], ["append", "header_append"], ["remove", "header_remove"]] });
|
||||
const name = field(fields, "rule-name", "headerName", value.name, "text", { maxLength: 128 }); name.placeholder = "X-App-Header";
|
||||
const ruleValue = field(fields, "rule-value", "headerValue", value.value ?? "", "text", { maxLength: 8192 }); ruleValue.placeholder = "value";
|
||||
row.append(fields);
|
||||
const enabled = () => { ruleValue.disabled = str("rule-op", row) === "remove"; row.classList.toggle("is-remove", ruleValue.disabled); };
|
||||
row.addEventListener("change", () => { enabled(); sync(); }); row.addEventListener("input", sync); rows.append(row); enabled(); renumber(direction);
|
||||
};
|
||||
adders[direction] = add; for (const rule of initial[direction] || []) add(rule); renumber(direction);
|
||||
block.append(button("addHeader", () => { add(); sync(); }, "quiet location-add-header"));
|
||||
}
|
||||
target.addEventListener("input", () => {
|
||||
try {
|
||||
const value = validateHeaders(JSON.parse(target.value));
|
||||
for (const direction of ["request", "response"]) {
|
||||
holders[direction].replaceChildren();
|
||||
for (const rule of value[direction] || []) adders[direction](rule);
|
||||
renumber(direction);
|
||||
}
|
||||
target.setCustomValidity(""); onChange();
|
||||
} catch (error) { target.setCustomValidity(error.message || t("headersInvalid")); }
|
||||
});
|
||||
note(parent, "locationHeadersHelp");
|
||||
return { read: () => validateHeaders(JSON.parse(target.value)) };
|
||||
}
|
||||
async function closeEditor() { if (!(await allowDiscard())) return false; draftEpoch++; form.querySelectorAll('[type="password"]').forEach(input => input.value = ""); dialog.close(); return true; }
|
||||
$("vhost-close").addEventListener("click", () => void closeEditor()); dialog.addEventListener("cancel", event => { event.preventDefault(); void closeEditor(); });
|
||||
function administrationListenCandidates() {
|
||||
return [
|
||||
envelope?.config.administration?.listen,
|
||||
latestStatus?.administration?.locked_listen,
|
||||
latestStatus?.administration?.desired?.listen,
|
||||
latestStatus?.administration?.active?.listen,
|
||||
latestStatus?.admin_listen,
|
||||
].map(value => String(value || "").trim()).filter((value, index, values) => value && values.indexOf(value) === index);
|
||||
}
|
||||
function adminListenerBackendCandidate(raw) {
|
||||
const splitSocket = value => {
|
||||
const textValue = String(value || "").trim();
|
||||
if (!textValue) return null;
|
||||
if (textValue.startsWith("[")) {
|
||||
const match = textValue.match(/^\[([^\]]+)\]:(\d+)$/); return match ? { host: match[1].toLowerCase(), port: Number(match[2]) } : null;
|
||||
}
|
||||
const at = textValue.lastIndexOf(":"); if (at <= 0) return null;
|
||||
const port = Number(textValue.slice(at + 1)); return Number.isInteger(port) ? { host: textValue.slice(0, at).toLowerCase(), port } : null;
|
||||
};
|
||||
const adminListeners = administrationListenCandidates().map(splitSocket).filter(Boolean);
|
||||
if (!adminListeners.length) return null;
|
||||
let parsed; try { parsed = new URL(String(raw || "").trim()); } catch { return false; }
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) return false;
|
||||
const port = parsed.port ? Number(parsed.port) : parsed.protocol === 'https:' ? 443 : 80;
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, '').toLowerCase();
|
||||
const loopback = value => value === 'localhost' || value === '::1' || /^127(?:\.\d{1,3}){3}$/.test(value);
|
||||
return adminListeners.some(admin => {
|
||||
if (port !== admin.port) return false;
|
||||
const wildcard = admin.host === '0.0.0.0' || admin.host === '::' || admin.host === '[::]';
|
||||
return host === admin.host || (wildcard && loopback(host)) || (loopback(admin.host) && loopback(host));
|
||||
});
|
||||
}
|
||||
|
||||
function memberRow(parent, value = { id: `origin-${parent.children.length + 1}`, url: "", weight: 1, enabled: true, pool_eligible: true, allow_private: true, accept_local_listener_risk: false, preserve_host: true, protocol: "http1", tls_verify: false }) {
|
||||
if (parent.children.length >= 32) { toast(t("vhRowLimit"), true); return; }
|
||||
const row = el("article", undefined, "vh-member vh-row vh-backend-card");
|
||||
const head = el("div", undefined, "vh-backend-head"), identity = el("div", undefined, "vh-backend-identity");
|
||||
const title = el("strong", value.id || t("backend"), "vh-backend-title"), endpoint = el("span", value.url || "-", "fine vh-backend-endpoint");
|
||||
identity.append(title, endpoint); head.append(identity); const remove = button("remove", () => { row.remove(); changed(); }); head.append(remove); row.append(head);
|
||||
|
||||
const main = el("div", undefined, "vh-grid vh-backend-main"); row.append(main);
|
||||
const id = field(main, "member-id", "vhMemberId", value.id, "text", { pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
|
||||
const url = field(main, "member-url", "vhUrl", value.url, "url", { maxLength: 512 });
|
||||
field(main, "member-weight", "vhWeight", value.weight ?? 1, "number", { required: true, min: 1, max: 256 });
|
||||
const protocol = field(main, "member-protocol", "vhProtocol", value.protocol || "http1", "select", { values: [["http1", "vhProtocolHttp1"], ["auto", "vhProtocolAuto"], ["http2", "vhProtocolHttp2"]] });
|
||||
|
||||
const routing = el("div", undefined, "vh-wide vh-backend-switches"); main.append(routing);
|
||||
const controls = {};
|
||||
for (const [name, key, checked] of [["member-enabled", "vhEnabled", value.enabled !== false], ["member-pool", "locationPoolEligible", value.pool_eligible !== false], ["member-backup", "vhBackup", value.backup], ["member-drain", "vhDrain", value.drain]]) controls[name] = field(routing, name, key, checked, "checkbox");
|
||||
const connection = el("div", undefined, "vh-wide vh-backend-switches vh-backend-connection"); main.append(connection);
|
||||
controls["member-private"] = field(connection, "member-private", "vhPrivate", value.allow_private, "checkbox");
|
||||
const preserve = field(connection, "member-host", "vhPreserveHost", value.preserve_host === true, "checkbox");
|
||||
const syncProtocol = () => { preserve.disabled = protocol.value !== "http1"; if (preserve.disabled) preserve.checked = false; }; protocol.addEventListener("change", syncProtocol); syncProtocol();
|
||||
|
||||
const advanced = el("details", undefined, "vh-wide vh-backend-advanced"), extra = el("div", undefined, "vh-grid"); advanced.append(text("summary", "advanced"), extra);
|
||||
field(extra, "member-allowlist", "vhAddressAllowlist", (value.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
|
||||
const riskGate = el("div", undefined, "vh-wide vh-local-risk-gate"); extra.append(riskGate);
|
||||
const localRisk = field(riskGate, "member-local-risk", "vhLocalListenerRisk", value.accept_local_listener_risk === true, "checkbox");
|
||||
localRisk.addEventListener("change", async () => { if (localRisk.checked && !(await appConfirm(t("vhLocalListenerRiskConfirm"), { danger: true }))) localRisk.checked = false; });
|
||||
note(riskGate, "vhLocalListenerRiskHelp");
|
||||
field(extra, "member-tls-name", "vhTlsServerName", value.tls_server_name || "", "text", { maxLength: 253, placeholder: "backend.example.internal" });
|
||||
const tlsVerify = field(extra, "member-tls-verify", "vhTlsVerify", value.tls_verify === true, "checkbox"); note(extra, "vhTlsVerifyHelp");
|
||||
field(extra, "member-ca", "vhCa", value.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 });
|
||||
field(extra, "member-max-concurrent", "vhMaxConcurrentRequests", value.max_concurrent_requests ?? 0, "number", { required: true, min: 0, max: 1000000 });
|
||||
field(extra, "member-queue-timeout", "vhBackendQueueTimeout", value.queue_timeout_ms ?? 0, "number", { required: true, min: 0, max: 60000 });
|
||||
note(extra, "vhBackendCapacityHelp");
|
||||
row.append(advanced);
|
||||
|
||||
const actions = el("div", undefined, "vh-backend-actions");
|
||||
actions.append(button("vhDebugOriginPreset", async () => {
|
||||
if (!(await appConfirm(t("vhDebugOriginTlsWarning"), { danger: true }))) return;
|
||||
if (!url.value.trim()) url.value = "https://127.0.0.1:9000";
|
||||
controls["member-pool"].checked = true; controls["member-private"].checked = true;
|
||||
protocol.value = "http1"; preserve.checked = true; tlsVerify.checked = false; syncProtocol(); syncRisk(); changed();
|
||||
}));
|
||||
actions.append(text("span", "vhOriginPresetHelp", "fine")); row.append(actions);
|
||||
|
||||
const syncIdentity = () => { title.textContent = id.value.trim() || t("backend"); endpoint.textContent = url.value.trim() || "-"; };
|
||||
const syncRisk = (clearWhenHidden = false) => {
|
||||
const show = adminListenerBackendCandidate(url.value);
|
||||
riskGate.hidden = show !== true;
|
||||
if (clearWhenHidden && show === false) localRisk.checked = false;
|
||||
};
|
||||
id.addEventListener("input", syncIdentity); url.addEventListener("input", () => { syncIdentity(); syncRisk(true); }); url.addEventListener("change", () => syncRisk(true));
|
||||
row.syncLocalRisk = () => syncRisk(false);
|
||||
syncIdentity(); syncRisk(false); parent.append(row);
|
||||
}
|
||||
function countryRateEditor(parent, values = []) {
|
||||
const rows = el("div", undefined, "vh-wide vh-country-rates"); parent.append(rows);
|
||||
const add = (value = {}) => {
|
||||
if (rows.children.length >= 64) { toast(t("vhCountryRateLimit"), true); return; }
|
||||
const row = el("div", undefined, "vh-row vh-grid");
|
||||
field(row, "country-rate-country", "vhCountry", value.country || "", "text", { required: true, maxLength: 2, pattern: "[A-Za-z]{2}" });
|
||||
field(row, "country-rate-requests", "vhRateRequests", value.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
|
||||
field(row, "country-rate-window", "vhRateWindow", value.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 });
|
||||
field(row, "country-rate-burst", "vhRateBurst", value.burst ?? 30, "number", { required: true, min: 0, max: 1000000 });
|
||||
row.append(button("remove", () => { row.remove(); changed(); })); rows.append(row);
|
||||
};
|
||||
for (const value of values) add(value);
|
||||
parent.append(button("vhAddCountryRate", () => { add(); changed(); }));
|
||||
return () => [...rows.children].map(row => ({
|
||||
country: str("country-rate-country", row).toUpperCase(), requests: num("country-rate-requests", row),
|
||||
window_seconds: num("country-rate-window", row), burst: num("country-rate-burst", row)
|
||||
}));
|
||||
}
|
||||
function debugOriginIssues(members) {
|
||||
return members.filter(member => {
|
||||
let parsed; try { parsed = new URL(member.url); } catch { return false; }
|
||||
const host = parsed.hostname.replace(/^\[|\]$/g, ""), ipHost = /^\d{1,3}(?:\.\d{1,3}){3}$/.test(host) || host.includes(":");
|
||||
if (parsed.protocol !== "https:" || !ipHost) return false;
|
||||
return !member.pool_eligible || !member.allow_private || member.protocol !== "http1" || !member.preserve_host || member.tls_verify;
|
||||
});
|
||||
}
|
||||
function aclEditor(parent, initial, onChange = () => { }) {
|
||||
const enabled = field(parent, "acl-use", "aclUse", !!initial, "checkbox"), body = el("div", undefined, "vh-wide vh-grid"); parent.append(body);
|
||||
const policy = field(body, "acl-default", "aclDefault", initial?.default || "deny", "select", { values: [["deny", "vhDeny"], ["allow", "aclAllow"]] }), rows = el("div", undefined, "vh-wide"); body.append(rows);
|
||||
function add(value = { network: "", action: "allow" }) { const row = el("div", undefined, "acl-rule"); field(row, "network", "aclNetwork", value.network, "text", { maxLength: 64 }); field(row, "action", "aclAction", value.action, "select", { values: [["allow", "aclAllow"], ["deny", "vhDeny"]] }); row.append(button("remove", () => { row.remove(); onChange(); })); rows.append(row); }
|
||||
body.append(button("aclAdd", () => { add(); onChange(); })); for (const rule of initial?.rules || []) add(rule);
|
||||
const sync = () => { body.hidden = !enabled.checked; onChange(); }; enabled.addEventListener("change", sync); body.addEventListener("input", onChange); body.addEventListener("change", onChange); body.hidden = !enabled.checked;
|
||||
note(parent, "aclHelp"); return { read: () => enabled.checked ? { default: policy.value, rules: [...rows.children].map(r => ({ network: str("network", r), action: str("action", r) })) } : null };
|
||||
}
|
||||
function openLocationHelp(topic = "locations") {
|
||||
const dialog = $("location-help-dialog"), title = $("location-help-title"), body = $("location-help-body");
|
||||
if (!dialog || !title || !body) return;
|
||||
title.textContent = t(topic === "headers" ? "headersDocsTitle" : "locationDocsTitle");
|
||||
const section = (headingKey, keys, cls = "") => {
|
||||
const box = el("section", undefined, `location-doc-section ${cls}`.trim());
|
||||
if (headingKey) box.append(text("h3", headingKey));
|
||||
for (const key of keys) box.append(el("p", t(key)));
|
||||
return box;
|
||||
};
|
||||
if (topic === "headers") {
|
||||
body.replaceChildren(
|
||||
el("p", t("headersDocsIntro"), "location-doc-intro"),
|
||||
section("locationHeaders", ["headersDocsSet", "headersDocsAppend", "headersDocsRemove"]),
|
||||
section("advanced", ["headersDocsProtected", "headersDocsOrder"])
|
||||
);
|
||||
} else {
|
||||
body.replaceChildren(
|
||||
el("p", t("locationDocsIntro"), "location-doc-intro"),
|
||||
section("locationDocsMatchHeading", ["locationDocsPrefix", "locationDocsExact", "locationDocsRegex"]),
|
||||
section("locationDocsActionHeading", ["locationDocsReturn", "locationDocsRewrite"]),
|
||||
section("locationDocsPolicyHeading", ["locationDocsAccess", "locationDocsLimits"]),
|
||||
section("locationDocsExamplesHeading", ["locationDocsExampleExact", "locationDocsExampleStatic", "locationDocsExampleRegex"], "location-doc-examples")
|
||||
);
|
||||
}
|
||||
dialog.showModal();
|
||||
}
|
||||
function locationsEditor(parent, values) {
|
||||
const toolbar = el("div", undefined, "vh-wide location-toolbar"), rows = el("div", undefined, "vh-wide vh-locations");
|
||||
const addTop = button("locationAdd", () => { add(); sync(); }, "primary");
|
||||
toolbar.append(addTop, button("documentation", () => openLocationHelp("locations")));
|
||||
parent.append(toolbar); note(parent, "locationHelp"); parent.append(rows); let reloading = false;
|
||||
function readRows() {
|
||||
return [...rows.children].map(r => {
|
||||
const action = str("location-action", r), rewriteMode = str("location-rewrite-mode", r);
|
||||
const handled = action !== "return";
|
||||
return {
|
||||
id: str("location-id", r), path_prefix: str("location-path", r), match_type: str("location-match", r),
|
||||
source: action === "web_server" ? { type: "web_server", web_service_id: str("location-web-service", r) } : { type: "reverse_proxy" },
|
||||
backend_id: action === "proxy" ? (str("location-backend", r) || null) : null,
|
||||
enabled: yes("location-enabled", r), cache: action === "proxy" ? (str("location-cache", r) === "" ? null : str("location-cache", r) === "on") : false,
|
||||
acl: r.readAcl(), headers: r.readHeaders(),
|
||||
rewrite: { mode: handled ? rewriteMode : "preserve", value: handled && rewriteMode !== "preserve" ? read("location-rewrite-value", r).value.trim() : "" },
|
||||
response: action === "return" ? { status: Number(str("location-response-status", r)), content_type: str("location-response-type", r), body: read("location-response-body", r).value } : null
|
||||
};
|
||||
});
|
||||
}
|
||||
function sync() { if (reloading) return; try { read("paths").value = JSON.stringify(readRows(), null, 2); changed(); } catch { } }
|
||||
function memberOptions(select, chosen = select.value) { const base = text("option", "locationPool"); base.value = ""; select.replaceChildren(base, ...[...form.querySelectorAll(".vh-member")].map(m => { const id = str("member-id", m), o = el("option", `${id} / ${str("member-url", m)}`); o.value = id; return o; })); if (chosen && !Array.from(select.options).some(o => o.value === chosen)) { const o = el("option", `${chosen} / ${t("locationMissing")}`); o.value = chosen; select.append(o); } select.value = chosen; }
|
||||
function add(value = {}) {
|
||||
if (rows.children.length >= 64) { toast(t("locationLimit"), true); return; }
|
||||
const row = el("details", undefined, "vh-location"); row.open = true;
|
||||
const summary = el("summary", undefined, "location-summary"), body = el("div", undefined, "vh-grid location-body"); row.append(summary, body);
|
||||
const used = new Set([...rows.children].map(r => str("location-id", r))); let suffix = 1; while (used.has(`location-${suffix}`)) suffix++;
|
||||
field(body, "location-id", "locationId", value.id || `location-${suffix}`, "text", { required: true, pattern: "[A-Za-z0-9_-]{1,64}", maxLength: 64 });
|
||||
const match = field(body, "location-match", "locationMatchType", value.match_type || "prefix", "select", { values: [["prefix", "locationMatchPrefix"], ["prefix_priority", "locationMatchPriority"], ["exact", "locationMatchExact"], ["regex", "locationMatchRegex"], ["regex_insensitive", "locationMatchRegexI"]] });
|
||||
const path = field(body, "location-path", "locationPathPattern", value.path_prefix || "/", "text", { required: true, maxLength: 1024 }); path.placeholder = "/api or ^/api/v[0-9]+/";
|
||||
const initialAction = value.response ? "return" : value.source?.type === "web_server" ? "web_server" : "proxy";
|
||||
const action = field(body, "location-action", "locationAction", initialAction, "select", { values: [["proxy", "locationProxyAction"], ["web_server", "locationWebServerAction"], ["return", "locationReturnAction"]] });
|
||||
const backend = field(body, "location-backend", "backend", value.backend_id || "", "select"); memberOptions(backend, value.backend_id || "");
|
||||
const webService = field(body, "location-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(webService, value.source?.web_service_id || "");
|
||||
field(body, "location-enabled", "enabled", value.enabled !== false, "checkbox");
|
||||
const cache = field(body, "location-cache", "cacheEnabled", value.cache === true ? "on" : value.cache === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
|
||||
|
||||
const handling = el("details", undefined, "vh-wide location-section"), handlingBody = el("div", undefined, "vh-grid"); handling.append(text("summary", "locationHandling"), handlingBody); body.append(handling);
|
||||
const rewriteMode = field(handlingBody, "location-rewrite-mode", "locationRewriteMode", value.rewrite?.mode || "preserve", "select", { values: [["preserve", "locationRewritePreserve"], ["strip_prefix", "locationRewriteStrip"], ["replace_prefix", "locationRewriteReplace"], ["regex", "locationRewriteRegex"]] });
|
||||
const rewriteValue = field(handlingBody, "location-rewrite-value", "locationRewriteValue", value.rewrite?.value || "", "text", { maxLength: 2048 });
|
||||
const returnBox = el("div", undefined, "vh-wide vh-grid location-return-box"); handlingBody.append(returnBox);
|
||||
field(returnBox, "location-response-status", "locationResponseStatus", value.response?.status ?? 200, "number", { min: 200, max: 599, required: true });
|
||||
field(returnBox, "location-response-type", "locationResponseType", value.response?.content_type || "text/plain; charset=utf-8", "text", { maxLength: 256, required: true });
|
||||
field(returnBox, "location-response-body", "locationResponseBody", value.response?.body || "", "textarea", { rows: 5, maxLength: 65536 });
|
||||
note(handlingBody, "locationRewriteHelp");
|
||||
|
||||
const extra = el("details", undefined, "vh-wide location-section"), accessBody = el("div", undefined, "vh-grid"); extra.append(text("summary", "locationAccess"), accessBody); body.append(extra); const acl = aclEditor(accessBody, value.acl, sync); row.readAcl = () => acl.read();
|
||||
const headerBox = el("details", undefined, "vh-wide location-section location-header-editor"), headerBody = el("div", undefined, "vh-grid"); headerBox.append(text("summary", "locationHeaders"), headerBody); body.append(headerBox);
|
||||
const headerToolbar = el("div", undefined, "vh-wide location-header-toolbar"), visualPane = el("div", undefined, "vh-wide vh-grid"), jsonPane = el("div", undefined, "vh-wide vh-grid"); jsonPane.hidden = true;
|
||||
const visualButton = button("headersVisualBuilder", () => { visualPane.hidden = false; jsonPane.hidden = true; }), jsonButton = button("headersAdvancedJson", () => { visualPane.hidden = true; jsonPane.hidden = false; }), docsButton = button("documentation", () => openLocationHelp("headers"));
|
||||
headerToolbar.append(visualButton, jsonButton, docsButton); headerBody.append(headerToolbar, visualPane, jsonPane);
|
||||
const raw = field(jsonPane, "location-headers", "headersAdvancedJson", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 8, maxLength: 65536 });
|
||||
const editor = headerEditors(visualPane, value.headers || {}, raw, sync); row.readHeaders = () => editor.read();
|
||||
|
||||
const preview = el("section", undefined, "vh-wide location-preview");
|
||||
preview.append(text("h4", "locationPreviewTitle"));
|
||||
const previewStatus = text("p", "locationPreviewDisabled", "fine location-preview-disabled"); preview.append(previewStatus);
|
||||
const previewGrid = el("dl", undefined, "location-preview-grid"), previewValues = {};
|
||||
for (const [name, key] of [["match", "locationPreviewMatch"], ["handling", "locationPreviewHandling"], ["rewrite", "locationPreviewRewrite"], ["cache", "locationPreviewCache"], ["access", "locationPreviewAccess"], ["headers", "locationPreviewHeaders"]]) {
|
||||
const item = el("div"); item.append(text("dt", key)); const output = el("dd", "-"); previewValues[name] = output; item.append(output); previewGrid.append(item);
|
||||
}
|
||||
preview.append(previewGrid); body.append(preview);
|
||||
|
||||
const actions = el("div", undefined, "vh-wide location-actions");
|
||||
const dedicated = button("locationAddBackend", () => { const members = form.querySelector(".vh-members"); let i = 1; const ids = new Set([...members.children].map(r => str("member-id", r))); while (ids.has(`location-origin-${i}`)) i++; const id = `location-origin-${i}`; memberRow(members, { id, url: "http://127.0.0.1:9001", weight: 1, enabled: true, pool_eligible: false, allow_private: false }); memberOptions(backend, id); sync(); activateTab("backends"); });
|
||||
actions.append(dedicated, button("remove", () => { row.remove(); sync(); })); body.append(actions);
|
||||
|
||||
const syncUi = () => {
|
||||
const isReturn = action.value === "return", isWeb = action.value === "web_server", isProxy = action.value === "proxy", isRegex = match.value === "regex" || match.value === "regex_insensitive";
|
||||
backend.disabled = !isProxy; cache.disabled = !isProxy; dedicated.disabled = !isProxy;
|
||||
webService.disabled = !isWeb; webService.required = isWeb;
|
||||
returnBox.hidden = !isReturn; rewriteMode.disabled = isReturn; rewriteValue.disabled = isReturn || rewriteMode.value === "preserve" || rewriteMode.value === "strip_prefix";
|
||||
for (const option of rewriteMode.options) if (option.value === "regex") option.disabled = !isRegex;
|
||||
if (!isRegex && rewriteMode.value === "regex") rewriteMode.value = "preserve";
|
||||
path.placeholder = isRegex ? "^/api/v[0-9]+/" : "/api/";
|
||||
const id = str("location-id", row) || t("locationId"), pattern = str("location-path", row) || "/";
|
||||
summary.textContent = `${id} · ${t(`locationMatch_${match.value}`)} · ${pattern} · ${t(isReturn ? "locationReturnShort" : isWeb ? "locationWebServerShort" : "locationProxyShort")}`;
|
||||
|
||||
previewStatus.hidden = yes("location-enabled", row);
|
||||
previewValues.match.textContent = `${t(`locationMatch_${match.value}`)} · ${pattern}`;
|
||||
if (isReturn) {
|
||||
const status = str("location-response-status", row) || "200", type = str("location-response-type", row) || "text/plain";
|
||||
previewValues.handling.textContent = `${t("locationReturnAction")}: HTTP ${status} · ${type} · ${t("locationPreviewBackendSkipped")}`;
|
||||
previewValues.rewrite.textContent = "-";
|
||||
previewValues.cache.textContent = t("notEnabled");
|
||||
} else {
|
||||
if (isWeb) {
|
||||
const selected = webService.selectedOptions?.[0]?.textContent || t("webServiceSelect");
|
||||
previewValues.handling.textContent = `${t("locationWebServerAction")}: ${selected}`;
|
||||
previewValues.cache.textContent = t("notEnabled");
|
||||
} else {
|
||||
const selected = backend.selectedOptions?.[0]?.textContent || t("locationPool");
|
||||
previewValues.handling.textContent = `${t("locationProxyAction")}: ${selected}`;
|
||||
previewValues.cache.textContent = t(cache.value === "on" ? "enabled" : cache.value === "off" ? "notEnabled" : "inherit");
|
||||
}
|
||||
const rewriteLabels = { preserve: "locationRewritePreserve", strip_prefix: "locationRewriteStrip", replace_prefix: "locationRewriteReplace", regex: "locationRewriteRegex" };
|
||||
const replacement = read("location-rewrite-value", row).value.trim();
|
||||
previewValues.rewrite.textContent = `${t(rewriteLabels[rewriteMode.value] || "locationRewritePreserve")}${replacement && !["preserve", "strip_prefix"].includes(rewriteMode.value) ? `: ${replacement}` : ""} · ${t("locationPreviewQueryPreserved")}`;
|
||||
}
|
||||
previewValues.access.textContent = yes("acl-use", row) ? t("locationPreviewCustomAcl") : t("locationPreviewVhostAcl");
|
||||
try {
|
||||
const headerValue = editor.read(), requestRules = headerValue.request?.length || 0, responseRules = headerValue.response?.length || 0;
|
||||
previewValues.headers.textContent = `${requestRules} ${t("locationPreviewRequestRules")} / ${responseRules} ${t("locationPreviewResponseRules")}`;
|
||||
} catch { previewValues.headers.textContent = t("headersInvalid"); }
|
||||
};
|
||||
row.addEventListener("input", () => { syncUi(); sync(); }); row.addEventListener("change", () => { syncUi(); sync(); });
|
||||
rows.append(row); syncUi();
|
||||
}
|
||||
// The main vhost backend is an internal fallback, not a Custom location.
|
||||
// An empty location list must stay empty so the editor cannot accidentally
|
||||
// delete or recreate the main route as user configuration.
|
||||
for (const value of values) add(value);
|
||||
const bottom = el("div", undefined, "vh-wide location-toolbar location-toolbar-bottom"); bottom.append(button("locationAdd", () => { add(); sync(); }, "primary")); parent.append(bottom);
|
||||
form.querySelector(".vh-members").addEventListener("input", () => { for (const select of rows.querySelectorAll('[name="location-backend"]')) memberOptions(select); });
|
||||
// Advanced JSON remains an explicit escape hatch; edits rebuild visual rows.
|
||||
read("paths").addEventListener("change", () => { try { const data = JSON.parse(str("paths")); if (!Array.isArray(data) || data.length > 64) return; reloading = true; rows.replaceChildren(); for (const value of data) add(value); } catch { } finally { reloading = false; } });
|
||||
sync(); return readRows;
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
function basicAuthEditor(parent, settings = {}) {
|
||||
const realm = field(parent, "basic-realm", "basicRealm", settings.realm || "Restricted", "text", { maxLength: 128 });
|
||||
const secure = field(parent, "basic-tls", "basicRequireTls", settings.require_tls !== false, "checkbox"), forward = field(parent, "basic-forward", "basicForward", settings.forward_credentials, "checkbox");
|
||||
const rows = el("div", undefined, "vh-wide basic-users"); parent.append(rows); let sequence = 0;
|
||||
function add(user = {}) {
|
||||
if (rows.children.length >= 64) { toast(t("basic.too_many_users"), true); return; } const row = el("div", undefined, "basic-user");
|
||||
const username = field(row, "basic-username", "username", user.username || "", "text", { maxLength: 128 });
|
||||
const password = field(row, "basic-password", "basicPassword", "", "password", { maxLength: 128 }); password.autocomplete = "new-password"; password.placeholder = user.password_hash ? t("basicKeepPassword") : t("basicPassword");
|
||||
const state = el("span", user.password_hash ? t("basicHashSaved") : t("basicHashRequired"), "fine"); row.append(state, button("remove", () => { row.remove(); changed(); })); row.userHash = user.password_hash || ""; row.sequence = ++sequence; rows.append(row);
|
||||
row.prepare = async () => { if (!password.value) { if (!row.userHash) throw new Error(t("basicHashRequired")); return; } const name = username.value.trim(), secret = password.value, epoch = draftEpoch; const result = await api("/api/basic-auth/hash", { method: "POST", body: JSON.stringify({ username: name, password: secret }) }); if (epoch !== draftEpoch || !row.isConnected || password.value !== secret || username.value.trim() !== name) throw new Error(t("vhUnsaved")); row.userHash = result.password_hash; password.value = ""; password.placeholder = t("basicKeepPassword"); state.textContent = t("basicHashSaved"); };
|
||||
}
|
||||
for (const user of settings.users || []) add(user); parent.append(button("basicAddUser", () => { add(); changed(); })); note(parent, "basicHelp");
|
||||
return { async prepare() { for (const row of rows.children) await row.prepare(); }, read: () => ({ enabled: str("auth-mode") === "basic", realm: realm.value.trim(), require_tls: secure.checked, forward_credentials: forward.checked, users: [...rows.children].map(row => ({ username: str("basic-username", row), password_hash: row.userHash })) }) };
|
||||
}
|
||||
|
||||
function hostTagEditor(parent, hosts = []) {
|
||||
const wrapper = el("div", undefined, "vh-field vh-wide host-chip-field"); wrapper.append(text("span", "vhHosts"));
|
||||
const hidden = document.createElement("textarea"); hidden.name = "hosts"; hidden.hidden = true;
|
||||
const box = el("div", undefined, "host-chip-editor"), input = document.createElement("input"); input.type = "text"; input.maxLength = 253; input.placeholder = "app.example.com / *.example.com"; input.setAttribute("aria-label", t("vhHostAdd"));
|
||||
const values = [];
|
||||
const normalize = raw => {
|
||||
let value = raw.trim().toLowerCase().replace(/\.$/, ""); if (!value) return null; const wildcard = value.startsWith("*."); const base = wildcard ? value.slice(2) : value;
|
||||
if (base.length > 253 || !/^[a-z0-9.-]+$/.test(base) || base.startsWith(".") || base.endsWith(".") || base.split(".").some(part => !part || part.length > 63 || part.startsWith("-") || part.endsWith("-"))) throw new Error(t("vhHostInvalid"));
|
||||
if (wildcard && !base.includes(".")) throw new Error(t("vhHostInvalid")); return wildcard ? `*.${base}` : base;
|
||||
};
|
||||
const sync = () => { hidden.value = values.join("\n"); box.querySelectorAll(".host-chip").forEach(node => node.remove()); for (const value of values) { const chip = el("span", undefined, "host-chip"); chip.append(el("span", value)); const remove = el("button", "×"); remove.type = "button"; remove.setAttribute("aria-label", `${t("remove")} ${value}`); remove.addEventListener("click", () => { values.splice(values.indexOf(value), 1); sync(); changed(); }); chip.append(remove); box.insertBefore(chip, input); } };
|
||||
const add = raw => { const value = normalize(raw); if (!value || values.includes(value)) return; values.push(value); sync(); changed(); };
|
||||
const consume = raw => { for (const part of raw.split(/[\s,]+/).filter(Boolean)) add(part); input.value = ""; };
|
||||
input.addEventListener("keydown", event => { if (event.key === "Enter" || event.key === ",") { event.preventDefault(); try { consume(input.value); } catch (error) { toast(error.message, true); } } });
|
||||
input.addEventListener("blur", () => { if (!input.value.trim()) return; try { consume(input.value); } catch (error) { toast(error.message, true); } });
|
||||
input.addEventListener("paste", event => { const textValue = event.clipboardData?.getData("text") || ""; if (!/[\s,]/.test(textValue.trim())) return; event.preventDefault(); try { consume(textValue); } catch (error) { toast(error.message, true); } });
|
||||
box.append(input); wrapper.append(hidden, box, text("p", "vhHostsHelp", "fine")); parent.append(wrapper); for (const host of hosts) { try { add(host); } catch { } } dirty = false;
|
||||
return () => { if (input.value.trim()) consume(input.value); if (!values.length) throw new Error(t("vhHostRequired")); return [...values]; };
|
||||
}
|
||||
|
||||
function load(value = defaults(), migrationIds = [], show = true) {
|
||||
draftEpoch++; stagedCertificates = []; uploading = false;
|
||||
editing = value.id || null; revision = envelope?.revision ?? null; dirty = false; initialized = true;
|
||||
container.replaceChildren(); $("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
|
||||
let g = group("vhEditor"); field(g, "id", "vhId", value.id, "text", { required: true, maxLength: 64, pattern: "[A-Za-z0-9_-]{1,64}" }); if (editing) note(g, "vhIdRenameHelp");
|
||||
readHosts = hostTagEditor(g, value.frontend?.hosts || []); field(g, "enabled", "vhEnabled", value.enabled !== false, "checkbox");
|
||||
const mainSource = field(g, "source-type", "sourceType", value.source?.type || "reverse_proxy", "select", { values: [["reverse_proxy", "sourceReverseProxy"], ["web_server", "sourceWebServer"]] });
|
||||
const mainWebService = field(g, "source-web-service", "webService", value.source?.web_service_id || "", "select"); fillWebServices(mainWebService, value.source?.web_service_id || "");
|
||||
const syncMainSource = () => { const web = mainSource.value === "web_server"; mainWebService.disabled = !web; mainWebService.required = web; }; mainSource.addEventListener("change", syncMainSource); syncMainSource();
|
||||
note(g, "sourceHelp");
|
||||
const websocketEnabled = field(g, "websockets", "vhWebsockets", value.websockets !== false, "checkbox");
|
||||
const websocketModeValue = value.websocket_rfc8441 === true && value.websocket_rfc9220 === true ? "h2h3" : value.websocket_rfc8441 === true ? "h2" : value.websocket_rfc9220 === true ? "h3" : "h1";
|
||||
const websocketMode = field(g, "websocket-mode", "vhWebsocketMode", websocketModeValue, "select", { values: [["h1", "vhWebsocketH1"], ["h2", "vhWebsocketH2Fallback"], ["h3", "vhWebsocketH3Fallback"], ["h2h3", "vhWebsocketH2H3Fallback"]] });
|
||||
note(g, "vhWebsocketTransportHelp");
|
||||
const websocketLegacy = field(g, "websocket-legacy-support", "vhWebsocketLegacySupport", value.websocket_legacy_support === true, "checkbox");
|
||||
note(g, "vhWebsocketLegacySupportHelp");
|
||||
g = group("vhFrontends");
|
||||
const bindingControl = window.rustProxyIntegrations.bindings(g, value.frontend?.listener_ids || [], !editing); readBindings = bindingControl.read;
|
||||
note(g, "listenerDefaultPortsHint");
|
||||
field(g, "force-ssl", "vhForceSsl", value.force_ssl === true, "checkbox");
|
||||
note(g, "vhForceSslHelp");
|
||||
field(g, "forward-client-ip", "vhForwardClientIp", value.forward_client_ip !== false, "checkbox");
|
||||
note(g, "vhForwardClientIpHelp");
|
||||
field(g, "cloudflare-client-ip", "vhCloudflareClientIp", value.cloudflare_client_ip === true, "checkbox");
|
||||
const protocolRow = el("div", undefined, "vh-wide protocol-toggle-row"); g.append(protocolRow);
|
||||
const h2Enabled = field(protocolRow, "http2-enabled", "vhHttp2Enabled", value.http2_enabled !== false, "checkbox");
|
||||
const h3Enabled = field(protocolRow, "http3-enabled", "vhHttp3Enabled", value.http3_enabled !== false, "checkbox");
|
||||
note(g, "vhFrontendProtocolsHelp"); note(g, "vhFrontendSelectionOnly");
|
||||
const syncFrontendTransports = () => {
|
||||
const capabilities = bindingControl.capabilities();
|
||||
h2Enabled.disabled = !capabilities.http2; h3Enabled.disabled = !capabilities.http3;
|
||||
if (!capabilities.http2) h2Enabled.checked = false;
|
||||
if (!capabilities.http3) h3Enabled.checked = false;
|
||||
const h2Available = capabilities.http2 && h2Enabled.checked;
|
||||
const h3Available = capabilities.http3 && h3Enabled.checked;
|
||||
websocketMode.disabled = !websocketEnabled.checked;
|
||||
websocketLegacy.disabled = !websocketEnabled.checked;
|
||||
const optionH2 = websocketMode.querySelector('option[value="h2"]');
|
||||
const optionH3 = websocketMode.querySelector('option[value="h3"]');
|
||||
const optionH2H3 = websocketMode.querySelector('option[value="h2h3"]');
|
||||
optionH2.disabled = !h2Available;
|
||||
optionH3.disabled = !h3Available;
|
||||
optionH2H3.disabled = !h2Available || !h3Available;
|
||||
const selected = websocketMode.value;
|
||||
if ((selected === "h2" && !h2Available) || (selected === "h3" && !h3Available) || (selected === "h2h3" && (!h2Available || !h3Available))) {
|
||||
websocketMode.value = selected === "h2h3" && h2Available ? "h2" : selected === "h2h3" && h3Available ? "h3" : "h1";
|
||||
}
|
||||
};
|
||||
websocketEnabled.addEventListener("change", syncFrontendTransports);
|
||||
bindingControl.onChange(syncFrontendTransports);
|
||||
h2Enabled.addEventListener("change", syncFrontendTransports);
|
||||
h3Enabled.addEventListener("change", syncFrontendTransports);
|
||||
syncFrontendTransports();
|
||||
g = group("vhBackendPool"); const members = el("div", undefined, "vh-wide vh-members"); g.append(members);
|
||||
(value.backends || []).forEach(m => memberRow(members, m)); g.append(button("vhAddBackend", () => { memberRow(members); changed(); }));
|
||||
g = group("vhBackendDiagnostics");
|
||||
field(g, "timing-headers", "vhTimingHeaders", value.timing_headers === true, "checkbox");
|
||||
field(g, "backend-header", "vhBackendHeader", value.backend_header === true, "checkbox");
|
||||
field(g, "backend-timing-header", "vhBackendTimingHeader", value.backend_timing_header === true, "checkbox");
|
||||
note(g, "vhBackendDiagnosticsHelp");
|
||||
const proxyTimeouts = { ...defaultProxyTimeouts(), ...(value.proxy_timeouts || {}) };
|
||||
g = group("vhProxyTimeouts");
|
||||
field(g, "proxy-connect-timeout", "vhProxyConnectTimeout", proxyTimeouts.connect_timeout_seconds ?? 10, "number", { required: true, min: 1, max: 300 });
|
||||
field(g, "proxy-header-timeout", "vhProxyHeaderTimeout", proxyTimeouts.response_header_timeout_seconds ?? 120, "number", { required: true, min: 1, max: 86400 });
|
||||
field(g, "proxy-read-timeout", "vhProxyReadTimeout", proxyTimeouts.read_timeout_seconds ?? 60, "number", { required: true, min: 1, max: 86400 });
|
||||
field(g, "proxy-send-timeout", "vhProxySendTimeout", proxyTimeouts.send_timeout_seconds ?? 60, "number", { required: true, min: 1, max: 86400 });
|
||||
note(g, "vhProxyTimeoutsHelp");
|
||||
const settings = value.load_balancing || {}, health = settings.health_check || {}, keepalive = settings.keepalive || {}, retry = settings.retry || {}, sticky = settings.sticky || {};
|
||||
g = group("vhBalancing"); field(g, "algorithm", "vhAlgorithm", settings.algorithm || "least_connections", "select", { values: [["round_robin", "vhRoundRobin"], ["least_connections", "vhLeastConnections"], ["least_traffic", "vhLeastTraffic"], ["adaptive", "vhAdaptive"], ["ip_hash", "vhIpHash"]] });
|
||||
field(g, "slow-start", "vhSlowStart", settings.slow_start_seconds ?? 10, "number", { required: true, min: 0, max: 300 });
|
||||
field(g, "retry-max", "vhRetryMax", retry.max_retries ?? 1, "number", { required: true, min: 0, max: 3 });
|
||||
field(g, "retry-connect", "vhRetryConnect", retry.retry_connect_errors !== false, "checkbox");
|
||||
field(g, "retry-timeouts", "vhRetryTimeouts", retry.retry_timeouts !== false, "checkbox");
|
||||
field(g, "retry-statuses", "vhRetryStatuses", (retry.retry_statuses || [502, 503, 504]).join(", "), "text", { maxLength: 64 });
|
||||
note(g, "vhRetryHelp");
|
||||
field(g, "keepalive", "vhKeepalive", keepalive.enabled !== false, "checkbox");
|
||||
field(g, "idle-timeout", "vhIdleTimeout", keepalive.idle_timeout_seconds ?? 30, "number", { required: true, min: 1, max: 3600 });
|
||||
field(g, "max-idle", "vhMaxIdle", keepalive.max_idle_per_host ?? 4, "number", { required: true, min: 1, max: 64 });
|
||||
field(g, "tcp-keepalive", "vhTcpKeepalive", keepalive.tcp_keepalive_seconds ?? 30, "number", { required: true, min: 1, max: 3600 });
|
||||
g = group("vhHealth"); const healthMode = field(g, "health-mode", "vhHealthMode", health.mode || "tcp", "select", { values: [["off", "vhOff"], ["tcp", "vhTcp"], ["http", "vhHttp"]] });
|
||||
const healthOffWarning = note(g, "vhHealthOffWarning"); const syncHealthWarning = () => { healthOffWarning.hidden = healthMode.value !== "off"; }; healthMode.addEventListener("change", syncHealthWarning); syncHealthWarning();
|
||||
field(g, "health-path", "vhHealthPath", health.path || "/", "text", { required: true, maxLength: 1024 });
|
||||
field(g, "health-host", "vhHealthHost", health.host || "", "text", { maxLength: 260 });
|
||||
for (const [name, key, val, min, max] of [["interval", "vhInterval", health.interval_ms ?? 5000, 250, 300000], ["timeout", "vhTimeout", health.timeout_ms ?? 2000, 50, 60000], ["slow", "vhSlow", health.slow_response_ms ?? 1500, 0, 60000], ["fall", "vhFall", health.fall ?? 3, 1, 20], ["rise", "vhRise", health.rise ?? 2, 1, 20], ["status-min", "vhStatusMin", health.expected_status_min ?? 200, 100, 599], ["status-max", "vhStatusMax", health.expected_status_max ?? 399, 100, 599]]) field(g, name, key, val, "number", { required: true, min, max }); note(g, "vhHealthNote");
|
||||
const passive = settings.passive_health || {};
|
||||
g = group("vhPassiveHealth");
|
||||
const passiveEnabled = field(g, "passive-enabled", "vhPassiveEnabled", passive.enabled === true, "checkbox");
|
||||
note(g, "vhPassiveHelp");
|
||||
const passiveFields = [
|
||||
field(g, "passive-connect", "vhPassiveConnect", passive.count_connect_errors !== false, "checkbox"),
|
||||
field(g, "passive-timeouts", "vhPassiveTimeouts", passive.count_timeouts !== false, "checkbox"),
|
||||
field(g, "passive-io", "vhPassiveIo", passive.count_io_errors !== false, "checkbox"),
|
||||
field(g, "passive-5xx", "vhPassive5xx", passive.count_http_5xx !== false, "checkbox"),
|
||||
field(g, "passive-slow", "vhPassiveSlow", passive.count_slow_responses === true, "checkbox"),
|
||||
field(g, "passive-fall", "vhPassiveFall", passive.fall ?? "", "number", { min: 1, max: 20 }),
|
||||
field(g, "passive-rise", "vhPassiveRise", passive.rise ?? "", "number", { min: 1, max: 20 })
|
||||
];
|
||||
const passive5xx = passiveFields[3], passiveSlow = passiveFields[4];
|
||||
const passive5xxWarning = note(g, "vhPassive5xxWarning");
|
||||
const passiveSlowWarning = note(g, "vhPassiveSlowWarning");
|
||||
const syncPassive = () => {
|
||||
passiveFields.forEach(control => { control.disabled = !passiveEnabled.checked; const label = control.closest("label"); if (label) label.hidden = !passiveEnabled.checked; });
|
||||
passive5xxWarning.hidden = !passiveEnabled.checked || !passive5xx.checked;
|
||||
passiveSlowWarning.hidden = !passiveEnabled.checked || !passiveSlow.checked;
|
||||
};
|
||||
passiveEnabled.addEventListener("change", syncPassive);
|
||||
passive5xx.addEventListener("change", syncPassive);
|
||||
passiveSlow.addEventListener("change", syncPassive);
|
||||
syncPassive();
|
||||
g = group("vhSticky"); field(g, "sticky-mode", "vhStickyMode", sticky.mode || "off", "select", { values: [["off", "vhOff"], ["cookie", "vhCookie"], ["ip_hash", "vhIpHash"]] });
|
||||
field(g, "cookie-name", "vhCookieName", sticky.cookie_name || "", "text", { maxLength: 96, pattern: "[A-Za-z0-9_-]*" });
|
||||
field(g, "cookie-ttl", "vhCookieTtl", sticky.ttl_seconds ?? 3600, "number", { required: true, min: 60, max: 604800 }); note(g, "vhStickyNote");
|
||||
const rate = value.rate_limit || {}, access = rate.country_access || {};
|
||||
g = group("vhTrafficPolicy");
|
||||
const aclProfile = field(g, "acl-profile-id", "vhAclProfile", value.acl_profile_id || "", "select");
|
||||
const noProfile = text("option", "vhAclProfileNone"); noProfile.value = ""; aclProfile.append(noProfile, ...(envelope?.config.acl_profiles || []).map(profile => { const option = el("option", profile.name || profile.id); option.value = profile.id; return option; })); aclProfile.value = value.acl_profile_id || ""; note(g, "vhAclProfileHelp");
|
||||
field(g, "rate-enabled", "vhRateEnabled", rate.enabled === true, "checkbox");
|
||||
field(g, "rate-requests", "vhRateRequests", rate.requests ?? 120, "number", { required: true, min: 1, max: 1000000 });
|
||||
field(g, "rate-window", "vhRateWindow", rate.window_seconds ?? 60, "number", { required: true, min: 1, max: 3600 });
|
||||
field(g, "rate-burst", "vhRateBurst", rate.burst ?? 30, "number", { required: true, min: 0, max: 1000000 });
|
||||
field(g, "country-mode", "vhCountryMode", access.mode || "off", "select", { values: [["off", "vhOff"], ["block", "vhCountryBlock"], ["allow", "vhCountryAllow"]] });
|
||||
field(g, "country-list", "vhCountryList", (access.countries || []).join("\n"), "textarea", { rows: 3, maxLength: 1024 });
|
||||
note(g, "vhCountryPolicyHelp"); countryRatesReader = countryRateEditor(g, rate.country_limits || []); note(g, "vhRateHelp");
|
||||
const security = value.security_headers || {}, hsts = security.hsts || {};
|
||||
g = group("vhSecurity"); field(g, "hsts", "vhHsts", hsts.enabled, "checkbox"); field(g, "hsts-age", "vhHstsAge", hsts.max_age_seconds ?? 31536000, "number", { required: true, min: 0, max: 63072000 });
|
||||
field(g, "hsts-sub", "vhHstsSub", hsts.include_subdomains, "checkbox"); field(g, "hsts-preload", "vhHstsPreload", hsts.preload, "checkbox"); field(g, "nosniff", "vhNosniff", security.nosniff, "checkbox");
|
||||
field(g, "frame", "vhFrame", security.frame_options || "preserve", "select", { values: [["preserve", "vhPreserve"], ["deny", "vhDeny"], ["sameorigin", "vhSameorigin"]] });
|
||||
field(g, "referrer", "vhReferrer", security.referrer_policy || "", "text", { maxLength: 8192 });
|
||||
field(g, "csp", "vhCsp", security.content_security_policy || "", "textarea", { rows: 2, maxLength: 8192 });
|
||||
field(g, "permissions", "vhPermissions", security.permissions_policy || "", "text", { maxLength: 8192 });
|
||||
g = group("vhAdvanced", true); field(g, "paths", "vhPaths", JSON.stringify(value.routes || [], null, 2), "textarea", { rows: 5, maxLength: 131072 });
|
||||
field(g, "acl", "vhAcl", JSON.stringify(value.acl ?? null, null, 2), "textarea", { rows: 5, maxLength: 32768 });
|
||||
field(g, "headers", "vhHeaders", JSON.stringify(value.headers || {}, null, 2), "textarea", { rows: 5, maxLength: 65536 });
|
||||
g = group("vhLocations"); locationsReader = locationsEditor(g, value.routes || []);
|
||||
g = group("vhSsl");
|
||||
const selector = field(g, "certificate-id", "vhCertificate", value.certificate_id || "", "select");
|
||||
const fillCertificates = () => { const selected = selector.value || value.certificate_id || ""; const inherit = text("option", "vhCertificateDefault"); inherit.value = ""; selector.replaceChildren(inherit, ...[...(envelope?.config.tls?.certificates || []), ...stagedCertificates].map(c => { const o = el("option", c.id); o.value = c.id; return o; })); selector.value = selected; }; fillCertificates();
|
||||
g.append(button("certManage", async () => { if (!(await closeEditor())) return; setPanel("tls"); })); note(g, "certSelectHint");
|
||||
const inheritedTlsPolicy = envelope?.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] };
|
||||
const tlsPolicy = value.tls_policy || inheritedTlsPolicy;
|
||||
const customTlsPolicy = field(g, "tls-policy-custom", "vhTlsOverride", value.tls_policy != null, "checkbox");
|
||||
const tls12 = field(g, "tls-policy-12", "tls12", tlsPolicy.tls12 !== false, "checkbox");
|
||||
const tls13 = field(g, "tls-policy-13", "tls13", tlsPolicy.tls13 !== false, "checkbox");
|
||||
const tlsCiphers = field(g, "tls-policy-ciphers", "tlsCiphers", (tlsPolicy.cipher_suites || []).join("\n"), "textarea", { rows: 7, maxLength: 8192 });
|
||||
const syncTlsPolicy = () => { for (const input of [tls12, tls13, tlsCiphers]) input.disabled = !customTlsPolicy.checked; };
|
||||
customTlsPolicy.addEventListener("change", syncTlsPolicy); syncTlsPolicy(); note(g, "vhTlsOverrideHelp");
|
||||
g = group("vhTinyauth"); const auth = value.tinyauth || {}, authRoot = g;
|
||||
const authMode = field(g, "auth-mode", "authMode", value.basic_auth?.enabled ? "basic" : auth.enabled ? "tinyauth" : "none", "select", { values: [["none", "authNone"], ["tinyauth", "authTinyauth"], ["basic", "authBasic"]] });
|
||||
note(g, "authModeHelp");
|
||||
|
||||
const aclBody = el("section", undefined, "vh-wide auth-subcard auth-ip-card"); authRoot.append(aclBody);
|
||||
aclBody.append(text("h3", "vhIpAccess"));
|
||||
aclControl = aclEditor(aclBody, value.acl, () => { if (aclControl) read("acl").value = JSON.stringify(aclControl.read(), null, 2); });
|
||||
|
||||
const taBody = el("section", undefined, "vh-wide auth-subcard"), basicBody = el("section", undefined, "vh-wide auth-subcard"); authRoot.append(taBody, basicBody);
|
||||
taBody.append(text("h3", "authTinyauth"));
|
||||
const taPrimary = el("div", undefined, "vh-grid"); taBody.append(taPrimary);
|
||||
const authEnable = field(taPrimary, "auth-enabled", "taEnable", auth.enabled, "checkbox");
|
||||
const authUrl = field(taPrimary, "auth-url", "taUrl", auth.url || "", "url", { maxLength: 2048 });
|
||||
const loginUrl = field(taPrimary, "auth-login", "taLogin", auth.login_url || "", "url", { maxLength: 2048 });
|
||||
authUrl.placeholder = "http://10.87.7.99:3000"; loginUrl.placeholder = "https://auth.example.com/login";
|
||||
field(taPrimary, "auth-private", "vhPrivate", auth.allow_private, "checkbox");
|
||||
field(taPrimary, "auth-redirect-forbidden", "taRedirectForbidden", auth.redirect_forbidden !== false, "checkbox");
|
||||
authEnable.closest("label").hidden = true;
|
||||
note(taBody, "taHelp");
|
||||
|
||||
const taAdvanced = el("details", undefined, "vh-wide auth-advanced"); taAdvanced.append(text("summary", "taAdvanced"));
|
||||
const taAdvancedGrid = el("div", undefined, "vh-grid"); taAdvanced.append(taAdvancedGrid); taBody.append(taAdvanced);
|
||||
field(taAdvancedGrid, "auth-connect", "taConnect", auth.connect_timeout_seconds ?? 5, "number", { required: true, min: 1, max: 30 });
|
||||
field(taAdvancedGrid, "auth-timeout", "taTimeout", auth.timeout_seconds ?? 10, "number", { required: true, min: 1, max: 60 });
|
||||
field(taAdvancedGrid, "auth-request-headers", "taRequestHeaders", (auth.request_headers || ["cookie", "authorization", "x-api-key", "user-agent", "accept"]).join("\n"), "textarea", { rows: 3, maxLength: 8192 });
|
||||
field(taAdvancedGrid, "auth-response-headers", "taResponseHeaders", (auth.response_headers || ["remote-user", "remote-email"]).join("\n"), "textarea", { rows: 3, maxLength: 8192 });
|
||||
field(taAdvancedGrid, "auth-allowlist", "vhAddressAllowlist", (auth.address_allowlist || []).join("\n"), "textarea", { rows: 2, maxLength: 8192 });
|
||||
field(taAdvancedGrid, "auth-ca", "vhCa", auth.tls_ca_pem || "", "textarea", { rows: 2, maxLength: 65536 });
|
||||
|
||||
basicBody.append(text("h3", "authBasic"));
|
||||
const basicGrid = el("div", undefined, "vh-grid"); basicBody.append(basicGrid);
|
||||
basicEditor = basicAuthEditor(basicGrid, value.basic_auth || {});
|
||||
const syncAuth = () => {
|
||||
authEnable.checked = authMode.value === "tinyauth";
|
||||
taBody.hidden = !authEnable.checked;
|
||||
basicBody.hidden = authMode.value !== "basic";
|
||||
authUrl.required = loginUrl.required = authEnable.checked;
|
||||
};
|
||||
authMode.addEventListener("change", syncAuth); syncAuth();
|
||||
g = group("vhResponsePolicy"); const server = value.server_header, diagnostics = value.diagnostic_headers;
|
||||
const serverMode = field(g, "server-mode", "serverHeader", server?.mode || "inherit", "select", { values: [["inherit", "vhInherit"], ["preserve", "serverPreserve"], ["hide", "serverHide"], ["custom", "serverCustom"]] });
|
||||
const serverValue = field(g, "server-value", "serverValue", server?.value || "RustProxy/v0.25.0", "text", { maxLength: 256 });
|
||||
const syncServer = () => { serverValue.disabled = serverMode.value !== "custom"; serverValue.required = !serverValue.disabled; }; serverMode.addEventListener("change", syncServer); syncServer();
|
||||
const inheritDiagnostics = field(g, "diag-inherit", "diagInherit", !diagnostics, "checkbox"), diagInputs = [];
|
||||
for (const [name, key] of [["request_id", "diagRequestId"]]) diagInputs.push(field(g, `diag-${name}`, key, diagnostics?.[name] ?? envelope?.config.diagnostic_headers?.[name], "checkbox"));
|
||||
const syncDiag = () => { for (const input of diagInputs) input.disabled = inheritDiagnostics.checked; }; inheritDiagnostics.addEventListener("change", syncDiag); syncDiag(); note(g, "diagHelp");
|
||||
g = group("vhCache"); const cache = value.cache || {};
|
||||
const tri = (parent, name, key, current) => field(parent, name, key, current === true ? "on" : current === false ? "off" : "", "select", { values: [["", "inherit"], ["on", "enabled"], ["off", "notEnabled"]] });
|
||||
const cacheToggle = tri(g, "vh-cache-enabled", "cacheEnabled", cache.enabled);
|
||||
if (envelope?.config.cache?.enabled !== true) note(g, "vhCacheGlobalDisabled");
|
||||
const cacheOptions = el("div", undefined, "vh-wide vh-grid vh-cache-options"); g.append(cacheOptions);
|
||||
field(cacheOptions, "vh-cache-ttl", "vhCacheTtl", cache.max_ttl_seconds ?? "", "number", { min: 1, max: 604800 });
|
||||
field(cacheOptions, "vh-cache-default", "vhCacheDefault", cache.default_ttl_seconds ?? "", "number", { min: 0, max: 604800 });
|
||||
field(cacheOptions, "vh-cache-object", "vhCacheObject", cache.max_object_bytes == null ? "" : cache.max_object_bytes / 1024, "number", { min: 1, step: "any", max: (envelope?.config.cache?.max_object_bytes ?? 1048576) / 1024 });
|
||||
tri(cacheOptions, "vh-cache-query", "cacheBypassQuery", cache.bypass_query);
|
||||
tri(cacheOptions, "vh-cache-static-cookie", "vhCacheStaticCookie", cache.static_ignore_cookies);
|
||||
const customStatic = field(cacheOptions, "vh-cache-static-custom", "vhCacheStaticCustom", cache.static_extensions != null, "checkbox");
|
||||
const staticExtensions = field(cacheOptions, "vh-cache-static-extensions", "vhCacheStaticExtensions", (cache.static_extensions || envelope?.config.cache?.static_extensions || []).join("\n"), "textarea", { rows: 5, maxLength: 2048 });
|
||||
const syncStatic = () => { staticExtensions.disabled = !customStatic.checked; }; customStatic.addEventListener("change", syncStatic); syncStatic(); note(cacheOptions, "vhCacheStaticHelp");
|
||||
tri(cacheOptions, "vh-cache-status", "cacheStatusHeader", cache.expose_status); tri(cacheOptions, "vh-cache-hits", "cacheHitsHeader", cache.expose_hits); tri(cacheOptions, "vh-cache-rfc", "cacheRfcHeader", cache.expose_cache_status); tri(cacheOptions, "vh-cache-resident-age", "cacheResidentAge", cache.expose_resident_age);
|
||||
field(cacheOptions, "vh-cache-prefix-custom", "vhCacheCustomPrefixes", cache.bypass_prefixes != null, "checkbox");
|
||||
field(cacheOptions, "vh-cache-prefixes", "cacheBypassPrefixes", (cache.bypass_prefixes || []).join("\n"), "textarea", { rows: 2, maxLength: 32768 }); note(cacheOptions, "vhCacheHint");
|
||||
if (editing) cacheOptions.append(button("vhPurgeCache", () => purge(editing)));
|
||||
const syncCacheVisibility = () => {
|
||||
const enabled = envelope?.config.cache?.enabled === true && cacheToggle.value !== "off";
|
||||
cacheOptions.hidden = !enabled;
|
||||
};
|
||||
cacheToggle.addEventListener("change", syncCacheVisibility); syncCacheVisibility();
|
||||
g = group("vhTransform"); const transform = value.transform || { enabled: false };
|
||||
const transformEnabled = field(g, "vh-transform-enabled", "vhTransformEnable", transform.enabled === true, "checkbox");
|
||||
const transformOptions = el("div", undefined, "vh-wide vh-grid vh-transform-options"); g.append(transformOptions);
|
||||
for (const [name, key] of [["javascript", "transformJavascript"], ["css", "transformCss"], ["html", "transformHtml"], ["svg", "transformSvg"], ["json", "transformJson"]]) tri(transformOptions, `vh-transform-${name}`, key, transform[name]);
|
||||
const customTransformExtensions = field(transformOptions, "vh-transform-extensions-custom", "vhTransformExtensionsCustom", transform.extensions != null, "checkbox");
|
||||
const transformExtensions = field(transformOptions, "vh-transform-extensions", "transformExtensions", (transform.extensions || envelope?.config.transform?.extensions || []).join("\n"), "textarea", { rows: 5, maxLength: 2048 });
|
||||
const syncTransform = () => {
|
||||
transformOptions.hidden = !transformEnabled.checked;
|
||||
transformExtensions.disabled = !customTransformExtensions.checked;
|
||||
};
|
||||
transformEnabled.addEventListener("change", syncTransform); customTransformExtensions.addEventListener("change", syncTransform); syncTransform();
|
||||
note(transformOptions, "vhTransformHelp");
|
||||
g = group("vhHeaders"); headerEditors(g, value.headers || {});
|
||||
g = group("vhPages"); g.classList.add("vh-pages-grid"); pagesEditor = window.rustProxyPages.vhostEditor(g, value.pages || {});
|
||||
initTabs(); dirty = false; if (latestStatus) renderStatus(latestStatus); if (show && !dialog.open) dialog.showModal();
|
||||
|
||||
}
|
||||
function collect() {
|
||||
locationsReader(); // Validate every visual row; never silently save an earlier JSON value.
|
||||
let paths, acl, headers;
|
||||
try {
|
||||
paths = JSON.parse(str("paths")); acl = JSON.parse(str("acl")); headers = JSON.parse(str("headers"));
|
||||
if (!Array.isArray(paths) || (acl !== null && (typeof acl !== "object" || Array.isArray(acl))) || !headers || typeof headers !== "object" || Array.isArray(headers)) throw Error();
|
||||
} catch { throw new Error(t("vhJsonInvalid")); }
|
||||
const members = [...form.querySelectorAll(".vh-member")].map(row => ({ id: str("member-id", row), url: str("member-url", row), weight: num("member-weight", row), enabled: yes("member-enabled", row), pool_eligible: yes("member-pool", row), backup: yes("member-backup", row), drain: yes("member-drain", row), allow_private: yes("member-private", row), accept_local_listener_risk: yes("member-local-risk", row), preserve_host: yes("member-host", row), protocol: str("member-protocol", row), address_allowlist: lines(str("member-allowlist", row)), tls_server_name: nullable("member-tls-name", row), tls_verify: yes("member-tls-verify", row), tls_ca_pem: nullable("member-ca", row), max_concurrent_requests: num("member-max-concurrent", row), queue_timeout_ms: num("member-queue-timeout", row) })).filter(member => member.url);
|
||||
const bindingSelection = readBindings();
|
||||
const mainSourceType = str("source-type");
|
||||
const needsProxy = mainSourceType === "reverse_proxy" || paths.some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
|
||||
if (!bindingSelection.length) throw new Error(t("vhListenerRequired"));
|
||||
if (needsProxy && !members.length) throw new Error(t("vhBackendRequired"));
|
||||
if (mainSourceType === "web_server" && !str("source-web-service")) throw new Error(t("webServiceRequired"));
|
||||
if (paths.some(route => !route.response && route.source?.type === "web_server" && !route.source.web_service_id)) throw new Error(t("webServiceRequired"));
|
||||
if (yes("force-ssl")) {
|
||||
const tlsPorts = new Set(); let tlsSelected = false, httpSelected = false;
|
||||
for (const id of bindingSelection) {
|
||||
const endpoint = envelope?.config.listeners?.endpoints?.find(item => item.id === id);
|
||||
if (!endpoint || endpoint.enabled === false) continue;
|
||||
if (!endpoint.binding?.tls) { httpSelected = true; continue; }
|
||||
tlsSelected = true;
|
||||
for (const address of [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean)) {
|
||||
const match = String(address).match(/:(\d+)$/); if (match) tlsPorts.add(Number(match[1]));
|
||||
}
|
||||
}
|
||||
if (!httpSelected) throw new Error(t("vhForceSslNeedsHttp"));
|
||||
if (!tlsSelected || !tlsPorts.size) throw new Error(t("vhForceSslNeedsTls"));
|
||||
if (!tlsPorts.has(443) && tlsPorts.size !== 1) throw new Error(t("vhForceSslPortConflict"));
|
||||
}
|
||||
if (yes("tls-policy-custom") && !yes("tls-policy-12") && !yes("tls-policy-13")) throw new Error(t("tlsPolicyVersionRequired"));
|
||||
const selectedUsesHttp3 = bindingSelection.some(id => {
|
||||
const endpoint = envelope?.config.listeners?.endpoints?.find(item => item.id === id);
|
||||
return !!endpoint?.binding?.http3;
|
||||
});
|
||||
if (yes("http3-enabled") && selectedUsesHttp3 && yes("tls-policy-custom")) {
|
||||
const selected = { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")).sort() };
|
||||
const inherited = envelope?.config.tls?.policy || { tls12: true, tls13: true, cipher_suites: [] };
|
||||
const globalPolicy = { tls12: inherited.tls12 !== false, tls13: inherited.tls13 !== false, cipher_suites: [...(inherited.cipher_suites || [])].sort() };
|
||||
if (JSON.stringify(selected) !== JSON.stringify(globalPolicy)) throw new Error(t("vhTlsHttp3Conflict"));
|
||||
}
|
||||
return {
|
||||
id: str("id"), enabled: yes("enabled"), source: mainSourceType === "web_server" ? { type: "web_server", web_service_id: str("source-web-service") } : { type: "reverse_proxy" }, force_ssl: yes("force-ssl"), forward_client_ip: yes("forward-client-ip"), websockets: yes("websockets"), websocket_legacy_support: yes("websockets") && yes("websocket-legacy-support"), websocket_rfc8441: yes("websockets") && ["h2", "h2h3"].includes(str("websocket-mode")), websocket_rfc9220: yes("websockets") && ["h3", "h2h3"].includes(str("websocket-mode")), http2_enabled: yes("http2-enabled"), http3_enabled: yes("http3-enabled"), cloudflare_client_ip: yes("cloudflare-client-ip"), timing_headers: yes("timing-headers"), backend_header: yes("backend-header"), backend_timing_header: yes("backend-timing-header"), basic_auth: basicEditor.read(), certificate_id: nullable("certificate-id"), tls_policy: yes("tls-policy-custom") ? { tls12: yes("tls-policy-12"), tls13: yes("tls-policy-13"), cipher_suites: lines(str("tls-policy-ciphers")) } : null, cache: collectCache(), transform: collectTransform(), pages: pagesEditor.read(), frontend: { hosts: readHosts(), listener_ids: bindingSelection }, acl_profile_id: nullable("acl-profile-id"),
|
||||
proxy_timeouts: { connect_timeout_seconds: num("proxy-connect-timeout"), response_header_timeout_seconds: num("proxy-header-timeout"), read_timeout_seconds: num("proxy-read-timeout"), send_timeout_seconds: num("proxy-send-timeout") },
|
||||
rate_limit: { enabled: yes("rate-enabled"), requests: num("rate-requests"), window_seconds: num("rate-window"), burst: num("rate-burst"), country_access: { mode: str("country-mode"), countries: lines(str("country-list")).map(value => value.toUpperCase()) }, country_limits: countryRatesReader() },
|
||||
server_header: str("server-mode") === "inherit" ? null : { mode: str("server-mode"), value: str("server-value") },
|
||||
diagnostic_headers: yes("diag-inherit") ? null : { request_id: yes("diag-request_id") },
|
||||
tinyauth: { enabled: yes("auth-enabled"), url: str("auth-url"), login_url: str("auth-login"), allow_private: yes("auth-private"), redirect_forbidden: yes("auth-redirect-forbidden"), connect_timeout_seconds: num("auth-connect"), timeout_seconds: num("auth-timeout"), request_headers: lines(str("auth-request-headers")), response_headers: lines(str("auth-response-headers")), address_allowlist: lines(str("auth-allowlist")), tls_ca_pem: nullable("auth-ca") }, backends: members, routes: paths, acl, headers,
|
||||
load_balancing: {
|
||||
algorithm: str("algorithm"), slow_start_seconds: num("slow-start"), keepalive: { enabled: yes("keepalive"), idle_timeout_seconds: num("idle-timeout"), max_idle_per_host: num("max-idle"), tcp_keepalive_seconds: num("tcp-keepalive") },
|
||||
retry: { max_retries: num("retry-max"), retry_connect_errors: yes("retry-connect"), retry_timeouts: yes("retry-timeouts"), retry_statuses: str("retry-statuses").split(/[\s,]+/).filter(Boolean).map(value => Number(value)) },
|
||||
health_check: { mode: str("health-mode"), path: str("health-path"), host: nullable("health-host"), interval_ms: num("interval"), timeout_ms: num("timeout"), slow_response_ms: num("slow"), fall: num("fall"), rise: num("rise"), expected_status_min: num("status-min"), expected_status_max: num("status-max") },
|
||||
passive_health: {
|
||||
enabled: yes("passive-enabled"),
|
||||
count_connect_errors: yes("passive-connect"),
|
||||
count_timeouts: yes("passive-timeouts"),
|
||||
count_io_errors: yes("passive-io"),
|
||||
count_http_5xx: yes("passive-5xx"),
|
||||
count_slow_responses: yes("passive-slow"),
|
||||
fall: str("passive-fall") === "" ? null : num("passive-fall"),
|
||||
rise: str("passive-rise") === "" ? null : num("passive-rise")
|
||||
},
|
||||
sticky: { mode: str("sticky-mode"), cookie_name: nullable("cookie-name"), ttl_seconds: num("cookie-ttl") }
|
||||
},
|
||||
security_headers: { hsts: { enabled: yes("hsts"), max_age_seconds: num("hsts-age"), include_subdomains: yes("hsts-sub"), preload: yes("hsts-preload") }, nosniff: yes("nosniff"), frame_options: str("frame"), referrer_policy: nullable("referrer"), content_security_policy: nullable("csp"), permissions_policy: nullable("permissions") }
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,244 @@
|
||||
function render() {
|
||||
if (!envelope) return;
|
||||
if (!initialized) load(defaults(), [], false);
|
||||
const config = envelope.config;
|
||||
const configuredHasProxySource = (config.vhosts || []).some(vhost =>
|
||||
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
|
||||
);
|
||||
const diagnosticsCard = $("backend-diagnostics-card");
|
||||
if (diagnosticsCard) diagnosticsCard.hidden = !configuredHasProxySource;
|
||||
const query = ($("vhost-search")?.value || "").trim().toLowerCase();
|
||||
const visibleVhosts = (config.vhosts || []).filter(v => {
|
||||
if (!query) return true;
|
||||
const haystack = [
|
||||
v.id,
|
||||
...(v.frontend?.hosts || []),
|
||||
...(v.backends || []).flatMap(backend => [backend.id, backend.url]),
|
||||
v.source?.web_service_id,
|
||||
...(v.routes || []).map(route => route.source?.web_service_id),
|
||||
].filter(Boolean).join(" ").toLowerCase();
|
||||
return haystack.includes(query);
|
||||
});
|
||||
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");
|
||||
const title = el("div", undefined, "vhost-title-line"); title.append(el("strong", v.id, "entity-title"));
|
||||
const state = el("span", t(v.enabled === false ? "vhDisabled" : "vhEnabled"), `pill ${v.enabled === false ? "" : "live"}`); title.append(state);
|
||||
if (v.source?.type === "web_server") title.append(el("span", `${t("webServer")}: ${v.source.web_service_id}`, "pill"));
|
||||
identity.append(title, el("div", (v.frontend?.hosts || []).join(", ") || "-", "vhost-hosts")); head.append(identity);
|
||||
const actions = el("div", undefined, "entity-actions");
|
||||
const configuredHosts = (v.frontend?.hosts || []).filter(Boolean);
|
||||
const browseHost = configuredHosts.find(host => !host.startsWith("*.")) || configuredHosts.find(host => host.startsWith("*."))?.slice(2) || null;
|
||||
const configuredEndpoints = (v.frontend?.listener_ids || []).map(id => config.listeners?.endpoints?.find(endpoint => endpoint.id === id)).filter(Boolean);
|
||||
const browseTls = configuredEndpoints.some(endpoint => endpoint.binding?.tls === true);
|
||||
const openButton = button("vhOpenBrowser", () => { if (browseHost) window.open(`${browseTls ? "https" : "http"}://${browseHost}/`, "_blank", "noopener,noreferrer"); });
|
||||
openButton.disabled = !browseHost || v.enabled === false;
|
||||
actions.append(openButton, button("edit", async () => { if (await allowDiscard()) load(structuredClone(v)); }), button("remove", async () => {
|
||||
if (busy || !(await appConfirm(t("vhDelete"), { danger: true }))) return;
|
||||
const next = structuredClone(envelope.config); next.vhosts = (next.vhosts || []).filter(item => item.id !== v.id);
|
||||
if (await saveSection("vhosts", next.vhosts || [])) { if (editing === v.id) load(); render(); }
|
||||
}), button("liveLogs", () => window.rustProxyLogs.open(v.id)), button("vhPurgeCache", () => purge(v.id))); head.append(actions); box.append(head);
|
||||
|
||||
const details = el("div", undefined, "vhost-detail-strip");
|
||||
const selectedEndpoints = (v.frontend?.listener_ids || []).map(id =>
|
||||
config.listeners?.endpoints?.find(e => e.id === id) || { id }
|
||||
);
|
||||
const listeners = selectedEndpoints.map(endpoint => {
|
||||
const addresses = [endpoint.binding?.address, ...(endpoint.additional_addresses || [])].filter(Boolean).join(", ");
|
||||
return `${endpoint.name || endpoint.id} · ${addresses || t("listenerMissing")}`;
|
||||
});
|
||||
const supportsH2 = selectedEndpoints.some(endpoint => endpoint.binding?.http2);
|
||||
const supportsH3 = selectedEndpoints.some(endpoint => endpoint.binding?.http3);
|
||||
const protocols = [
|
||||
"HTTP/1.1",
|
||||
v.http2_enabled !== false && supportsH2 ? "H2" : null,
|
||||
v.http3_enabled !== false && supportsH3 ? "H3" : null,
|
||||
].filter(Boolean).join(" + ");
|
||||
const tlsPolicy = v.tls_policy ? [v.tls_policy.tls12 ? "TLS 1.2" : null, v.tls_policy.tls13 ? "TLS 1.3" : null].filter(Boolean).join(" + ") : t("inherit");
|
||||
const detailItems = [
|
||||
["vhListeners", listeners.join(" | ") || "-"],
|
||||
["vhProtocols", protocols],
|
||||
["tlsPolicy", tlsPolicy],
|
||||
["vhCertificate", v.certificate_id || t("vhCertificateDefault")],
|
||||
["vhAclProfile", v.acl_profile_id ? ((config.acl_profiles || []).find(profile => profile.id === v.acl_profile_id)?.name || v.acl_profile_id) : t("vhAclProfileNone")],
|
||||
["cache", v.cache?.enabled === true ? t("enabled") : v.cache?.enabled === false ? t("notEnabled") : t("inherit")],
|
||||
["vhBackendPool", v.source?.type === "web_server"
|
||||
? ((v.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy") && (v.backends || []).length
|
||||
? `${t("webServer")} + ${(v.backends || []).length}`
|
||||
: t("webServer"))
|
||||
: `${(v.backends || []).length}`],
|
||||
];
|
||||
for (const [key, value] of detailItems) { const item = el("div", undefined, "vhost-detail"); item.append(text("span", key, "small-label"), el("b", value)); details.append(item); }
|
||||
box.append(details);
|
||||
|
||||
const live = el("div", undefined, "vhost-live-grid");
|
||||
const metric = (key, suffix) => { const item = el("div", undefined, "vhost-live-metric"); item.append(text("span", key), el("strong", "-")); item.querySelector("strong").dataset[suffix] = v.id; live.append(item); };
|
||||
metric("status", "vhostHealth"); metric("vhRequests", "vhostRequests"); metric("vhActive", "vhostActive"); metric("vhErrors", "vhostErrors"); metric("vhLatency", "vhostLatency"); metric("vhTraffic", "vhostTraffic");
|
||||
box.append(live);
|
||||
const chart = el("div", undefined, "vhost-chart"); const chartHead = el("div", undefined, "vhost-chart-head"); chartHead.append(text("span", "vhLifetimeChart", "small-label")); const chartValue = el("span", "-", "fine"); chartValue.dataset.chartValueFor = v.id; chartHead.append(chartValue); chart.append(chartHead);
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); svg.setAttribute("viewBox", "0 0 320 54"); svg.setAttribute("preserveAspectRatio", "none"); svg.dataset.chartFor = v.id; svg.setAttribute("aria-label", t("vhLifetimeChart")); const polyline = document.createElementNS("http://www.w3.org/2000/svg", "polyline"); polyline.setAttribute("class", "vhost-chart-line"); svg.append(polyline); chart.append(svg); box.append(chart);
|
||||
const hasProxySource = v.source?.type !== "web_server" || (v.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
|
||||
if (hasProxySource) {
|
||||
const health = el("p", t("vhHealthEmpty"), "vh-health-summary"); health.dataset.healthFor = v.id; box.append(health);
|
||||
}
|
||||
const policy = el("div", undefined, "vhost-policy-pills");
|
||||
if (v.basic_auth?.enabled) policy.append(el("small", t("authBasic"), "pill"));
|
||||
if (v.tinyauth?.enabled) policy.append(el("small", "Tinyauth", "pill"));
|
||||
if (v.rate_limit?.enabled) policy.append(el("small", t("vhRateEnabled"), "pill"));
|
||||
if (v.acl_profile_id) policy.append(el("small", `${t("vhAclProfile")}: ${v.acl_profile_id}`, "pill"));
|
||||
if (v.force_ssl) policy.append(el("small", t("vhForceSsl"), "pill"));
|
||||
if (v.forward_client_ip === false) policy.append(el("small", t("vhClientIpHidden"), "pill"));
|
||||
if (v.websockets) {
|
||||
const transports = ["H1"];
|
||||
if (v.websocket_rfc8441) transports.push("H2/RFC8441");
|
||||
if (v.websocket_rfc9220) transports.push("H3/RFC9220");
|
||||
policy.append(el("small", `WebSocket · ${transports.join("+")}`, "pill"));
|
||||
if (v.websocket_legacy_support) policy.append(el("small", t("vhWebsocketLegacyBadge"), "pill"));
|
||||
}
|
||||
if (policy.children.length) box.append(policy);
|
||||
return box;
|
||||
});
|
||||
$("vhost-list").replaceChildren(...(cards.length ? cards : [text("p", query ? "vhSearchEmpty" : "vhEmpty", "fine")]));
|
||||
$("vhost-editing").textContent = t(editing ? "vhEditLabel" : "vhNewLabel");
|
||||
if (latestStatus) renderStatus(latestStatus);
|
||||
}
|
||||
// SSE snapshots arrive slower than the backend sampler, so a short burst can be zeroed before the UI sees it.
|
||||
// Fall back to the byte-counter delta for this dashboard interval when the reported instantaneous rate is zero.
|
||||
function backendTrafficRate(poolId, member, snapshot, now, seen) {
|
||||
const key = `${poolId}\u0000${member.id}`; seen.add(key);
|
||||
const total = Math.max(0, Number(member.bytes_in) || 0) + Math.max(0, Number(member.bytes_out) || 0);
|
||||
const reported = Math.max(0, Number(member.traffic_bytes_per_second) || 0);
|
||||
const previous = backendTrafficHistory.get(key);
|
||||
if (previous?.snapshot === snapshot) return previous.rate;
|
||||
const elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0;
|
||||
const observed = previous && total >= previous.total ? (total - previous.total) / elapsed : 0;
|
||||
const rate = reported > 0 ? reported : observed;
|
||||
backendTrafficHistory.set(key, { total, at: now, snapshot, rate });
|
||||
return rate;
|
||||
}
|
||||
function vhostTrafficRate(id, metrics, snapshot, now, seen) {
|
||||
seen.add(id);
|
||||
const total = Math.max(0, Number(metrics?.bytes_in) || 0) + Math.max(0, Number(metrics?.bytes_out) || 0);
|
||||
const previous = vhostTrafficHistory.get(id);
|
||||
if (previous?.snapshot === snapshot) return previous.rate;
|
||||
const elapsed = previous ? Math.max(.001, (now - previous.at) / 1000) : 0;
|
||||
const rate = previous && total >= previous.total ? (total - previous.total) / elapsed : 0;
|
||||
vhostTrafficHistory.set(id, { total, at: now, snapshot, rate });
|
||||
return rate;
|
||||
}
|
||||
function renderStatus(status) {
|
||||
form.querySelectorAll(".vh-member").forEach(row => row.syncLocalRisk?.());
|
||||
const rows = [], summaries = new Map(), aggregate = new Map();
|
||||
const trafficSnapshot = status, trafficNow = performance.now(), seenTraffic = new Set(), seenVhostTraffic = new Set();
|
||||
const statusKeys = { up: "vhUp", down: "vhDown", checking: "vhChecking", draining: "vhDraining", disabled: "vhDisabled" };
|
||||
const configuredVhosts = new Map((envelope?.config.vhosts || []).map(vhost => [vhost.id, vhost]));
|
||||
for (const pool of status.backend_pools || []) {
|
||||
const members = pool.members || [], vhostDisabled = configuredVhosts.get(pool.id)?.enabled === false;
|
||||
let up = 0, active = 0, requests = 0, errors = 0, traffic = 0, weightedLatency = 0, latencyWeight = 0;
|
||||
for (const member of members) {
|
||||
const memberTraffic = backendTrafficRate(pool.id, member, trafficSnapshot, trafficNow, seenTraffic);
|
||||
const label = vhostDisabled ? t("vhDisabled") : (pool.health_mode === "off" && member.status === "up" ? t("vhHealthDisabled") : t(statusKeys[member.status] || "vhHealthUnknown"));
|
||||
const activeLimit = Number(member.max_concurrent_requests) > 0 ? `${member.active ?? 0}/${member.max_concurrent_requests}` : `${member.active ?? 0}/∞`;
|
||||
const lastFailure = Number(member.last_failure_at_ms) > 0 ? `${member.last_failure_class || "-"} · ${new Date(member.last_failure_at_ms).toLocaleTimeString(locale())}` : "-";
|
||||
const lastRecovery = Number(member.last_recovered_ms) > 0 ? new Date(member.last_recovered_ms).toLocaleTimeString(locale()) : "-";
|
||||
const healthDetail = `${t("vhActiveHealth")}: ${member.last_result || "-"} · ${t("vhPassiveFailures")}: ${member.passive_failures ?? 0} (${member.last_passive_result || "-"})`;
|
||||
const row = el("tr"); if (vhostDisabled) row.classList.add("pool-vhost-disabled"); const poolDomain = status.backend_pool_domains?.[pool.id]; const poolLabel = poolDomain ? `${pool.id} (${poolDomain})` : pool.id; const cells = [poolLabel, `${member.id}${member.backup ? " / " + t("vhBackup") : ""}`, label, activeLimit, member.requests, member.errors,
|
||||
member.ewma_latency_ms > 0 ? `${member.ewma_latency_ms.toFixed(1)} ms` : "-", `${rateBytes(memberTraffic)}/s`, lastFailure, lastRecovery];
|
||||
cells.forEach((value, index) => { const cell = el("td", String(value ?? "-")); cell.title = index === 2 ? (vhostDisabled ? t("vhDisabled") : `${label} · ${healthDetail}`) : index === 8 ? (member.last_failure_detail || lastFailure) : String(value ?? "-"); if (index === 2 && !vhostDisabled) cell.className = member.status === "down" ? "status-error" : member.status === "up" ? "status-good" : ""; if (index === 8 && member.last_failure_at_ms) cell.classList.add("backend-last-failure"); row.append(cell); }); rows.push(row);
|
||||
if (!vhostDisabled && member.status === "up") up++; active += Number(member.active) || 0; requests += Number(member.requests) || 0; errors += Number(member.errors) || 0; traffic += memberTraffic;
|
||||
const latency = Number(member.ewma_latency_ms || member.latency_ms) || 0; if (latency > 0) { const weight = Math.max(1, Number(member.requests) || 1); weightedLatency += latency * weight; latencyWeight += weight; }
|
||||
}
|
||||
const summary = vhostDisabled ? t("vhDisabled") : members.map(member => `${member.id}: ${pool.health_mode === "off" && member.status === "up" ? t("vhHealthDisabled") : t(statusKeys[member.status] || "vhHealthUnknown")} / ${Number(member.latency_ms ?? member.ewma_latency_ms) > 0 ? `${Number(member.latency_ms ?? member.ewma_latency_ms).toFixed(1)} ms` : "-"} / ${member.active ?? 0} ${t("vhActive")}`).join(" | ");
|
||||
summaries.set(pool.id, summary || t("vhHealthEmpty"));
|
||||
aggregate.set(pool.id, {
|
||||
disabled: vhostDisabled,
|
||||
healthMode: pool.health_mode,
|
||||
up,
|
||||
total: members.length,
|
||||
active,
|
||||
requests,
|
||||
errors,
|
||||
traffic,
|
||||
latency: latencyWeight ? weightedLatency / latencyWeight : 0,
|
||||
});
|
||||
}
|
||||
for (const key of backendTrafficHistory.keys()) if (!seenTraffic.has(key)) backendTrafficHistory.delete(key);
|
||||
for (const [id, vhost] of configuredVhosts) {
|
||||
const existing = aggregate.get(id) || {};
|
||||
const metrics = status.vhost_metrics?.[id];
|
||||
const hasProxySource = vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy");
|
||||
const disabled = vhost.enabled === false;
|
||||
aggregate.set(id, {
|
||||
...existing,
|
||||
disabled,
|
||||
staticOnly: !hasProxySource,
|
||||
healthMode: existing.healthMode,
|
||||
up: existing.up ?? 0,
|
||||
total: existing.total ?? 0,
|
||||
active: metrics ? Number(metrics.active) || 0 : existing.active ?? 0,
|
||||
requests: metrics ? Number(metrics.requests) || 0 : existing.requests ?? 0,
|
||||
errors: metrics ? Number(metrics.errors) || 0 : existing.errors ?? 0,
|
||||
traffic: metrics ? vhostTrafficRate(id, metrics, trafficSnapshot, trafficNow, seenVhostTraffic) : existing.traffic ?? 0,
|
||||
latency: metrics ? Number(metrics.ewma_latency_ms) || 0 : existing.latency ?? 0,
|
||||
});
|
||||
}
|
||||
for (const key of vhostTrafficHistory.keys()) if (!seenVhostTraffic.has(key)) vhostTrafficHistory.delete(key);
|
||||
const hasConfiguredProxySource = [...configuredVhosts.values()].some(vhost =>
|
||||
vhost.source?.type !== "web_server" || (vhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy")
|
||||
);
|
||||
$("pool-rows").replaceChildren(...rows);
|
||||
$("pool-empty").hidden = rows.length > 0 || !hasConfiguredProxySource;
|
||||
const backendDiagnosticsCard = $("backend-diagnostics-card");
|
||||
if (backendDiagnosticsCard) backendDiagnosticsCard.hidden = !hasConfiguredProxySource;
|
||||
document.querySelectorAll("[data-health-for]").forEach(node => node.textContent = summaries.get(node.dataset.healthFor) || t("vhHealthEmpty"));
|
||||
const fill = (attr, format) => document.querySelectorAll(`[data-${attr.replace(/[A-Z]/g, m => `-${m.toLowerCase()}`)}]`).forEach(node => { const data = aggregate.get(node.dataset[attr]); node.textContent = data ? format(data) : "-"; });
|
||||
fill("vhostHealth", d => d.disabled ? t("vhDisabled") : d.staticOnly ? t("vhUp") : d.healthMode === "off" ? t("vhHealthDisabled") : `${d.up}/${d.total} ${t("vhUp")}`);
|
||||
fill("vhostRequests", d => fmt(d.requests)); fill("vhostActive", d => fmt(d.active)); fill("vhostErrors", d => fmt(d.errors));
|
||||
fill("vhostLatency", d => d.latency > 0 ? `${fmt(d.latency, 1)} ms` : "-"); fill("vhostTraffic", d => `${rateBytes(d.traffic)}/s`);
|
||||
const now = performance.now();
|
||||
for (const [id, data] of aggregate) {
|
||||
const history = chartHistory.get(id) || { lastTime: now, lastRequests: data.requests, points: [], bucketSize: 1, pendingSum: 0, pendingCount: 0 };
|
||||
const elapsed = Math.max(.001, (now - history.lastTime) / 1000); const rate = data.requests >= history.lastRequests ? (data.requests - history.lastRequests) / elapsed : 0; history.lastTime = now; history.lastRequests = data.requests;
|
||||
history.pendingSum += rate; history.pendingCount += 1;
|
||||
if (history.pendingCount >= history.bucketSize) { history.points.push(history.pendingSum / history.pendingCount); history.pendingSum = 0; history.pendingCount = 0; }
|
||||
if (history.points.length >= 120) { const compacted = []; for (let i = 0; i < history.points.length; i += 2) compacted.push((history.points[i] + history.points[i + 1]) / 2); history.points = compacted; history.bucketSize *= 2; }
|
||||
chartHistory.set(id, history);
|
||||
const displayPoints = history.pendingCount ? [...history.points, history.pendingSum / history.pendingCount] : history.points; const actualPeak = Math.max(0, ...displayPoints), scalePeak = Math.max(1, actualPeak); const points = displayPoints.map((value, index) => `${index * 320 / Math.max(1, displayPoints.length - 1)},${50 - value / scalePeak * 44}`).join(" ");
|
||||
const svg = document.querySelector(`[data-chart-for="${CSS.escape(id)}"]`); svg?.querySelector("polyline")?.setAttribute("points", points); const label = document.querySelector(`[data-chart-value-for="${CSS.escape(id)}"]`); if (label) label.textContent = `${rate.toFixed(1)} ${t("vhReqPerSecond")} · ${t("vhPeak")} ${actualPeak.toFixed(1)}`;
|
||||
}
|
||||
const editingVhost = editing ? configuredVhosts.get(editing) : null;
|
||||
const editingHasProxy = editingVhost && (editingVhost.source?.type !== "web_server" || (editingVhost.routes || []).some(route => !route.response && (route.source?.type || "reverse_proxy") === "reverse_proxy"));
|
||||
$("vhost-health-live").hidden = !!editingVhost && !editingHasProxy;
|
||||
$("vhost-health-live").textContent = editing ? (editingHasProxy ? (summaries.get(editing) || t("vhHealthEmpty")) : "") : t("vhNewHealthHint");
|
||||
}
|
||||
form.addEventListener("input", changed); form.addEventListener("change", changed);
|
||||
form.addEventListener("submit", async event => {
|
||||
event.preventDefault(); if (!envelope || busy || uploading) return;
|
||||
const invalid = [...form.elements].find(input => input.willValidate && !input.checkValidity()); if (invalid) { activateTab(invalid.closest(".vh-group")?.dataset.tab || "details"); const details = invalid.closest("details"); if (details) details.open = true; invalid.reportValidity(); return; }
|
||||
if (revision !== envelope.revision) { toast(t("stale"), true); return; }
|
||||
try {
|
||||
const epoch = draftEpoch; uploading = true;
|
||||
try { await basicEditor.prepare(); } finally { uploading = false; }
|
||||
if (epoch !== draftEpoch || revision !== envelope.revision || !dialog.open) throw new Error(t("stale"));
|
||||
const value = collect(), next = structuredClone(envelope.config); next.vhosts ||= [];
|
||||
const debugIssues = debugOriginIssues(value.backends); if (debugIssues.length && !(await appConfirm(`${t("vhDebugOriginMismatch")}\n\n${debugIssues.map(member => member.id).join(", ")}`, { danger: true }))) return;
|
||||
if (next.vhosts.some(v => v.id === value.id && v.id !== editing)) throw new Error(t("vhConflict"));
|
||||
if (editing && value.id !== editing && !(await appConfirm(t("vhIdRenameConfirm").replace("{old}", editing).replace("{new}", value.id), { danger: true }))) return;
|
||||
const addsCertificate = stagedCertificates.some(c => c.id === value.certificate_id);
|
||||
if (addsCertificate) { next.tls ||= {}; next.tls.certificates ||= []; for (const cert of stagedCertificates.filter(c => c.id === value.certificate_id)) if (!next.tls.certificates.some(c => c.id === cert.id)) next.tls.certificates.push(cert); }
|
||||
const index = next.vhosts.findIndex(v => v.id === editing); if (index < 0) next.vhosts.push(value); else next.vhosts[index] = value;
|
||||
const saved = addsCertificate ? await save(next) : await saveSection("vhosts", next.vhosts || []);
|
||||
if (saved) { load(value, [], false); dialog.close(); render(); }
|
||||
} catch (error) { toast(error.message, true); }
|
||||
});
|
||||
$("vhost-new").addEventListener("click", async () => { if (await allowDiscard()) load(); });
|
||||
$("vhost-search").addEventListener("input", render);
|
||||
$("vhost-reset").addEventListener("click", async () => {
|
||||
if (!(await allowDiscard())) return;
|
||||
const saved = editing ? (envelope?.config.vhosts || []).find(item => item.id === editing) : null;
|
||||
load(saved ? structuredClone(saved) : defaults());
|
||||
});
|
||||
$("pool-open").addEventListener("click", () => setPanel("vhosts"));
|
||||
$("location-help-close")?.addEventListener("click", () => $("location-help-dialog")?.close());
|
||||
window.rustProxyVhosts = { render, renderStatus, reset() { dialog.close(); draftEpoch++; stagedCertificates = []; uploading = false; editing = null; revision = null; dirty = false; initialized = false; chartHistory.clear(); backendTrafficHistory.clear(); vhostTrafficHistory.clear(); container.replaceChildren(); $("vhost-search").value = ""; $("vhost-list").replaceChildren(); $("pool-rows").replaceChildren(); } };
|
||||
if (envelope) render(); if (latestStatus) renderStatus(latestStatus);
|
||||
Reference in new issue
Block a user