Archival commit, newer version exists, will push
This commit is contained in:
commit
b6094834bb
22 files changed
+7369
No files matched your search
@@ -0,0 +1,2 @@
|
||||
/target
|
||||
/data
|
||||
Generated
+3518
File diff suppressed because it is too large.
Load diff
+26
@@ -0,0 +1,26 @@
|
||||
[package]
|
||||
name = "labdeck"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
|
||||
[dependencies]
|
||||
argon2 = "0.6.0"
|
||||
axum = { version = "0.8.9", features = ["ws"] }
|
||||
base64 = "0.23.1"
|
||||
chacha20poly1305 = "0.11.0"
|
||||
futures-util = "0.3.34"
|
||||
rand = "0.10.3"
|
||||
reqwest = { version = "0.13.5", default-features = false, features = ["json", "rustls"] }
|
||||
rusqlite = { version = "0.41.0", features = ["bundled"] }
|
||||
russh = "0.65.0"
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.154"
|
||||
sha2 = "0.11.1"
|
||||
subtle = "2.6.1"
|
||||
tokio = { version = "1.54.1", features = ["full"] }
|
||||
x509-parser = "0.18.1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = "thin"
|
||||
strip = true
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
FROM rust:1-bookworm AS build
|
||||
WORKDIR /src
|
||||
COPY Cargo.toml Cargo.lock ./
|
||||
COPY src ./src
|
||||
COPY web ./web
|
||||
RUN cargo build --release
|
||||
|
||||
FROM debian:bookworm-slim
|
||||
RUN apt-get update \
|
||||
&& apt-get install -y --no-install-recommends iputils-ping ca-certificates \
|
||||
&& rm -rf /var/lib/apt/lists/* \
|
||||
&& useradd --system --uid 10001 --home /data labdeck \
|
||||
&& mkdir /data && chown labdeck /data
|
||||
COPY --from=build /src/target/release/labdeck /usr/local/bin/labdeck
|
||||
USER labdeck
|
||||
ENV LABDECK_ADDR=0.0.0.0:8787 LABDECK_DATA=/data
|
||||
VOLUME /data
|
||||
EXPOSE 8787
|
||||
ENTRYPOINT ["labdeck"]
|
||||
@@ -0,0 +1,11 @@
|
||||
services:
|
||||
labdeck:
|
||||
build: .
|
||||
container_name: labdeck
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "8412:8787" # put a TLS reverse proxy in front (Caddy, Traefik, Pangolin ...), change port to your liking
|
||||
volumes:
|
||||
- ./data:/data
|
||||
# Health checks of LAN hosts work with the default bridge network.
|
||||
# If you need to reach services bound to the Docker host itself, use network_mode: host instead.
|
||||
@@ -0,0 +1,68 @@
|
||||
use crate::{App, db};
|
||||
use serde_json::json;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
/// Fire-and-forget notification to the configured webhook (if any).
|
||||
pub fn notify(app: Arc<App>, title: String, body: String, good: bool) {
|
||||
tokio::spawn(async move {
|
||||
let configured = {
|
||||
let c = app.db.lock().unwrap();
|
||||
!db::setting_get(&c, "webhook_url").is_empty()
|
||||
};
|
||||
if configured {
|
||||
if let Err(e) = send(&app, &title, &body, good).await {
|
||||
eprintln!("webhook failed: {e}");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
pub async fn send(app: &App, title: &str, body: &str, good: bool) -> Result<(), String> {
|
||||
let (url, kind) = {
|
||||
let c = app.db.lock().unwrap();
|
||||
(db::setting_get(&c, "webhook_url"), db::setting_get(&c, "webhook_kind"))
|
||||
};
|
||||
if url.is_empty() {
|
||||
return Err("no webhook URL configured".into());
|
||||
}
|
||||
let client = &app.http_strict;
|
||||
let req = match kind.as_str() {
|
||||
"discord" => client
|
||||
.post(&url)
|
||||
.json(&json!({ "content": format!("{} **{}**\n{}", if good { "🟢" } else { "🔴" }, title, body) })),
|
||||
"slack" => client
|
||||
.post(&url)
|
||||
.json(&json!({ "text": format!("{} *{}*\n{}", if good { ":large_green_circle:" } else { ":red_circle:" }, title, body) })),
|
||||
"ntfy" => {
|
||||
let mut r = client
|
||||
.post(&url)
|
||||
.header("Tags", if good { "white_check_mark" } else { "rotating_light" })
|
||||
.header("Priority", if good { "default" } else { "high" })
|
||||
.body(body.to_string());
|
||||
if title.is_ascii() && !title.chars().any(|c| c.is_control()) {
|
||||
r = r.header("Title", title);
|
||||
} else {
|
||||
r = r.body(format!("{title}\n{body}"));
|
||||
}
|
||||
r
|
||||
}
|
||||
_ => client.post(&url).json(&json!({
|
||||
"title": title,
|
||||
"message": body,
|
||||
"status": if good { "up" } else { "down" },
|
||||
"time": db::now(),
|
||||
"source": "labdeck",
|
||||
})),
|
||||
};
|
||||
let resp = req
|
||||
.timeout(Duration::from_secs(10))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
if resp.status().is_success() {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("webhook answered HTTP {}", resp.status().as_u16()))
|
||||
}
|
||||
}
|
||||
+607
@@ -0,0 +1,607 @@
|
||||
use crate::db::{self, HostIn, Service};
|
||||
use crate::health::{self, Live};
|
||||
use crate::{App, alerts, auth, term, vault, web};
|
||||
use axum::{
|
||||
Json, Router,
|
||||
extract::{Path, Query, Request, State},
|
||||
http::{HeaderMap, Method, StatusCode, header},
|
||||
middleware::{self, Next},
|
||||
response::{IntoResponse, Response},
|
||||
routing::{get, post, put},
|
||||
};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
type Ctx = State<Arc<App>>;
|
||||
type R<T = Value> = Result<Json<T>, ApiErr>;
|
||||
|
||||
pub struct ApiErr(StatusCode, String);
|
||||
|
||||
impl IntoResponse for ApiErr {
|
||||
fn into_response(self) -> Response {
|
||||
(self.0, Json(json!({ "error": self.1 }))).into_response()
|
||||
}
|
||||
}
|
||||
impl From<rusqlite::Error> for ApiErr {
|
||||
fn from(e: rusqlite::Error) -> Self {
|
||||
eprintln!("db error: {e}");
|
||||
ApiErr(StatusCode::INTERNAL_SERVER_ERROR, "database error".into())
|
||||
}
|
||||
}
|
||||
fn bad(msg: impl Into<String>) -> ApiErr {
|
||||
ApiErr(StatusCode::BAD_REQUEST, msg.into())
|
||||
}
|
||||
fn not_found() -> ApiErr {
|
||||
ApiErr(StatusCode::NOT_FOUND, "not found".into())
|
||||
}
|
||||
fn ok() -> R {
|
||||
Ok(Json(json!({ "ok": true })))
|
||||
}
|
||||
|
||||
pub fn router(app: Arc<App>) -> Router {
|
||||
let protected = Router::new()
|
||||
.route("/services", get(services_list).post(service_create))
|
||||
.route("/services/{id}", put(service_update).delete(service_delete))
|
||||
.route("/services/{id}/check", post(service_check))
|
||||
.route("/services/{id}/history", get(service_history))
|
||||
.route("/check-all", post(check_all))
|
||||
.route("/hosts", get(hosts_list).post(host_create))
|
||||
.route("/hosts/{id}", put(host_update).delete(host_delete))
|
||||
.route("/hosts/{id}/forget-key", post(host_forget_key))
|
||||
.route("/snippets", get(snippets_list).post(snippet_create))
|
||||
.route("/snippets/{id}", put(snippet_update).delete(snippet_delete))
|
||||
.route("/events", get(events_list))
|
||||
.route("/settings", get(settings_get).put(settings_put))
|
||||
.route("/settings/test-webhook", post(test_webhook))
|
||||
.route("/export", get(export))
|
||||
.route("/import", post(import))
|
||||
.route("/ws-ticket", post(ws_ticket))
|
||||
.route("/account/password", post(change_password))
|
||||
.layer(middleware::from_fn_with_state(app.clone(), require_auth));
|
||||
|
||||
let open = Router::new()
|
||||
.route("/state", get(auth_state))
|
||||
.route("/setup", post(auth_setup))
|
||||
.route("/login", post(auth_login))
|
||||
.route("/logout", post(auth_logout));
|
||||
|
||||
Router::new()
|
||||
.nest("/api", protected.nest("/auth", open))
|
||||
.route("/ws/term/{id}", get(term::ws_term))
|
||||
.fallback(web::serve)
|
||||
.layer(middleware::from_fn(web::security_headers))
|
||||
.with_state(app)
|
||||
}
|
||||
|
||||
async fn require_auth(State(app): Ctx, req: Request, next: Next) -> Response {
|
||||
// /api/auth/* is reachable without a session (state, setup, login, logout)
|
||||
let open = req.uri().path().starts_with("/api/auth/");
|
||||
if !open {
|
||||
if !auth::session_valid(&app, req.headers()) {
|
||||
return ApiErr(StatusCode::UNAUTHORIZED, "not logged in".into()).into_response();
|
||||
}
|
||||
// CSRF: a custom header cannot be set by cross-site forms; cookies are SameSite=Strict as well.
|
||||
let m = req.method();
|
||||
if m != Method::GET && m != Method::HEAD {
|
||||
let hv = req.headers().get("x-requested-with").and_then(|v| v.to_str().ok());
|
||||
if hv != Some("labdeck") {
|
||||
return ApiErr(StatusCode::FORBIDDEN, "missing X-Requested-With header".into()).into_response();
|
||||
}
|
||||
}
|
||||
}
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
fn is_https(headers: &HeaderMap) -> bool {
|
||||
headers
|
||||
.get("x-forwarded-proto")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|v| v.eq_ignore_ascii_case("https"))
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- auth
|
||||
|
||||
async fn auth_state(State(app): Ctx, headers: HeaderMap) -> R {
|
||||
Ok(Json(json!({
|
||||
"initialized": auth::is_initialized(&app),
|
||||
"authenticated": auth::session_valid(&app, &headers),
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
})))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct PwBody {
|
||||
password: String,
|
||||
}
|
||||
|
||||
fn with_cookie(headers: &HeaderMap, token: &str, body: Value) -> Response {
|
||||
(
|
||||
[(
|
||||
header::SET_COOKIE,
|
||||
auth::cookie_header(token, is_https(headers), auth::session_ttl()),
|
||||
)],
|
||||
Json(body),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn auth_setup(State(app): Ctx, headers: HeaderMap, Json(b): Json<PwBody>) -> Result<Response, ApiErr> {
|
||||
let app2 = app.clone();
|
||||
tokio::task::spawn_blocking(move || auth::setup(&app2, &b.password))
|
||||
.await
|
||||
.map_err(|e| bad(e.to_string()))?
|
||||
.map_err(bad)?;
|
||||
let tok = auth::session_create(&app);
|
||||
Ok(with_cookie(&headers, &tok, json!({ "ok": true })))
|
||||
}
|
||||
|
||||
async fn auth_login(State(app): Ctx, headers: HeaderMap, Json(b): Json<PwBody>) -> Result<Response, ApiErr> {
|
||||
let app2 = app.clone();
|
||||
let res = tokio::task::spawn_blocking(move || auth::login(&app2, &b.password))
|
||||
.await
|
||||
.map_err(|e| bad(e.to_string()))?;
|
||||
match res {
|
||||
Ok(()) => {
|
||||
let tok = auth::session_create(&app);
|
||||
Ok(with_cookie(&headers, &tok, json!({ "ok": true })))
|
||||
}
|
||||
Err(auth::LoginErr::Bad) => Err(ApiErr(StatusCode::UNAUTHORIZED, "wrong password".into())),
|
||||
Err(auth::LoginErr::NotInit) => Err(bad("not initialized yet")),
|
||||
Err(auth::LoginErr::Locked(s)) => Err(ApiErr(
|
||||
StatusCode::TOO_MANY_REQUESTS,
|
||||
format!("too many failed attempts - try again in {s}s"),
|
||||
)),
|
||||
Err(auth::LoginErr::Internal(e)) => Err(ApiErr(StatusCode::INTERNAL_SERVER_ERROR, e)),
|
||||
}
|
||||
}
|
||||
|
||||
async fn auth_logout(State(app): Ctx, headers: HeaderMap) -> Response {
|
||||
if let Some(tok) = auth::session_from_headers(&headers) {
|
||||
auth::session_drop(&app, &tok);
|
||||
}
|
||||
(
|
||||
[(header::SET_COOKIE, auth::cookie_header("", is_https(&headers), 0))],
|
||||
Json(json!({ "ok": true })),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ChangePw {
|
||||
old: String,
|
||||
new: String,
|
||||
}
|
||||
|
||||
async fn change_password(State(app): Ctx, Json(b): Json<ChangePw>) -> R {
|
||||
let app2 = app.clone();
|
||||
tokio::task::spawn_blocking(move || auth::change_password(&app2, &b.old, &b.new))
|
||||
.await
|
||||
.map_err(|e| bad(e.to_string()))?
|
||||
.map_err(bad)?;
|
||||
ok()
|
||||
}
|
||||
|
||||
async fn ws_ticket(State(app): Ctx) -> R {
|
||||
Ok(Json(json!({ "ticket": auth::ticket_create(&app) })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- services
|
||||
|
||||
#[derive(Serialize)]
|
||||
struct ServiceOut {
|
||||
#[serde(flatten)]
|
||||
svc: Service,
|
||||
live: Live,
|
||||
uptime_24h: Option<f64>,
|
||||
spark: Vec<Option<i64>>,
|
||||
}
|
||||
|
||||
fn live_of(app: &App, id: i64) -> Live {
|
||||
app.live.read().unwrap().get(&id).cloned().unwrap_or_else(|| Live {
|
||||
status: "unknown".into(),
|
||||
since: db::now(),
|
||||
..Default::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn service_out(app: &App, c: &rusqlite::Connection, svc: Service) -> ServiceOut {
|
||||
let since = db::now() - 86_400;
|
||||
let uptime_24h = c
|
||||
.query_row(
|
||||
"SELECT AVG(ok), COUNT(*) FROM checks WHERE service_id=?1 AND ts>?2",
|
||||
rusqlite::params![svc.id, since],
|
||||
|r| Ok((r.get::<_, Option<f64>>(0)?, r.get::<_, i64>(1)?)),
|
||||
)
|
||||
.ok()
|
||||
.and_then(|(avg, n)| if n > 0 { avg.map(|a| a * 100.0) } else { None });
|
||||
let mut spark: Vec<Option<i64>> = Vec::new();
|
||||
if let Ok(mut st) = c.prepare("SELECT ok, latency_ms FROM checks WHERE service_id=?1 ORDER BY ts DESC LIMIT 30") {
|
||||
if let Ok(rows) = st.query_map([svc.id], |r| {
|
||||
let okv: i64 = r.get(0)?;
|
||||
let lat: Option<i64> = r.get(1)?;
|
||||
Ok(if okv != 0 { Some(lat.unwrap_or(0)) } else { None })
|
||||
}) {
|
||||
spark = rows.filter_map(|x| x.ok()).collect();
|
||||
spark.reverse();
|
||||
}
|
||||
}
|
||||
let live = live_of(app, svc.id);
|
||||
ServiceOut { svc, live, uptime_24h, spark }
|
||||
}
|
||||
|
||||
async fn services_list(State(app): Ctx) -> R<Vec<ServiceOut>> {
|
||||
let c = app.db.lock().unwrap();
|
||||
let list = db::services_list(&c)?;
|
||||
Ok(Json(list.into_iter().map(|s| service_out(&app, &c, s)).collect()))
|
||||
}
|
||||
|
||||
fn check_host_ref(c: &rusqlite::Connection, s: &Service) -> Result<(), ApiErr> {
|
||||
if let Some(h) = s.host_id {
|
||||
if db::host_get(c, h).is_none() {
|
||||
return Err(bad("linked SSH host does not exist"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn service_create(State(app): Ctx, Json(mut s): Json<Service>) -> R<ServiceOut> {
|
||||
s.validate().map_err(bad)?;
|
||||
let out = {
|
||||
let c = app.db.lock().unwrap();
|
||||
check_host_ref(&c, &s)?;
|
||||
s.id = db::service_insert(&c, &s)?;
|
||||
service_out(&app, &c, s)
|
||||
};
|
||||
app.wake.notify_one();
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
async fn service_update(State(app): Ctx, Path(id): Path<i64>, Json(mut s): Json<Service>) -> R<ServiceOut> {
|
||||
s.id = id;
|
||||
s.validate().map_err(bad)?;
|
||||
let out = {
|
||||
let c = app.db.lock().unwrap();
|
||||
check_host_ref(&c, &s)?;
|
||||
if db::service_update(&c, &s)? == 0 {
|
||||
return Err(not_found());
|
||||
}
|
||||
service_out(&app, &c, s)
|
||||
};
|
||||
app.wake.notify_one();
|
||||
Ok(Json(out))
|
||||
}
|
||||
|
||||
async fn service_delete(State(app): Ctx, Path(id): Path<i64>) -> R {
|
||||
let n = {
|
||||
let c = app.db.lock().unwrap();
|
||||
c.execute("DELETE FROM services WHERE id=?1", [id])?
|
||||
};
|
||||
if n == 0 {
|
||||
return Err(not_found());
|
||||
}
|
||||
app.live.write().unwrap().remove(&id);
|
||||
ok()
|
||||
}
|
||||
|
||||
async fn service_check(State(app): Ctx, Path(id): Path<i64>) -> R<ServiceOut> {
|
||||
let svc = {
|
||||
let c = app.db.lock().unwrap();
|
||||
db::service_get(&c, id)
|
||||
}
|
||||
.ok_or_else(not_found)?;
|
||||
if svc.enabled {
|
||||
health::check_and_record(&app, &svc).await;
|
||||
}
|
||||
let c = app.db.lock().unwrap();
|
||||
Ok(Json(service_out(&app, &c, svc)))
|
||||
}
|
||||
|
||||
async fn check_all(State(app): Ctx) -> R {
|
||||
let list = {
|
||||
let c = app.db.lock().unwrap();
|
||||
db::services_list(&c)?
|
||||
};
|
||||
let mut n = 0;
|
||||
for s in list.into_iter().filter(|s| s.enabled) {
|
||||
n += 1;
|
||||
let app2 = app.clone();
|
||||
tokio::spawn(async move { health::check_and_record(&app2, &s).await });
|
||||
}
|
||||
Ok(Json(json!({ "started": n })))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct HistQ {
|
||||
hours: Option<i64>,
|
||||
}
|
||||
|
||||
async fn service_history(State(app): Ctx, Path(id): Path<i64>, Query(q): Query<HistQ>) -> R {
|
||||
let hours = q.hours.unwrap_or(24).clamp(1, 336);
|
||||
let c = app.db.lock().unwrap();
|
||||
let mut st = c.prepare("SELECT ts, ok, latency_ms FROM checks WHERE service_id=?1 AND ts>?2 ORDER BY ts")?;
|
||||
let rows: Vec<(i64, bool, Option<i64>)> = st
|
||||
.query_map(rusqlite::params![id, db::now() - hours * 3600], |r| {
|
||||
Ok((r.get(0)?, r.get::<_, i64>(1)? != 0, r.get(2)?))
|
||||
})?
|
||||
.filter_map(|x| x.ok())
|
||||
.collect();
|
||||
let step = (rows.len() / 1200).max(1);
|
||||
let pts: Vec<Value> = rows
|
||||
.iter()
|
||||
.step_by(step)
|
||||
.map(|(ts, okv, lat)| json!([ts, okv, lat]))
|
||||
.collect();
|
||||
Ok(Json(json!({ "hours": hours, "points": pts })))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- hosts
|
||||
|
||||
fn secret_blob(app: &App, h: &HostIn) -> Result<Option<Vec<u8>>, ApiErr> {
|
||||
let secret = h.secret.as_deref().unwrap_or("");
|
||||
if secret.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
let pass = h.passphrase.clone().unwrap_or_default();
|
||||
let payload = if h.auth == "key" {
|
||||
let p = if pass.is_empty() { None } else { Some(pass.as_str()) };
|
||||
russh::keys::decode_secret_key(secret, p)
|
||||
.map_err(|e| bad(format!("invalid private key (or wrong passphrase): {e}")))?;
|
||||
json!({ "key": secret, "passphrase": pass })
|
||||
} else {
|
||||
json!({ "password": secret })
|
||||
};
|
||||
let key = (*app.vault_key.read().unwrap())
|
||||
.ok_or_else(|| ApiErr(StatusCode::LOCKED, "vault is locked - log in again".into()))?;
|
||||
Ok(Some(vault::encrypt(&key, payload.to_string().as_bytes())))
|
||||
}
|
||||
|
||||
async fn hosts_list(State(app): Ctx) -> R<Vec<db::HostOut>> {
|
||||
let c = app.db.lock().unwrap();
|
||||
Ok(Json(db::hosts_list(&c)?))
|
||||
}
|
||||
|
||||
async fn host_create(State(app): Ctx, Json(mut h): Json<HostIn>) -> R<db::HostOut> {
|
||||
h.validate().map_err(bad)?;
|
||||
let blob = secret_blob(&app, &h)?.ok_or_else(|| bad("a password or private key is required"))?;
|
||||
let c = app.db.lock().unwrap();
|
||||
let id = db::host_insert(&c, &h, Some(&blob))?;
|
||||
Ok(Json(db::host_get(&c, id).ok_or_else(not_found)?))
|
||||
}
|
||||
|
||||
async fn host_update(State(app): Ctx, Path(id): Path<i64>, Json(mut h): Json<HostIn>) -> R<db::HostOut> {
|
||||
h.id = id;
|
||||
h.validate().map_err(bad)?;
|
||||
let existing = {
|
||||
let c = app.db.lock().unwrap();
|
||||
db::host_get(&c, id)
|
||||
}
|
||||
.ok_or_else(not_found)?;
|
||||
let blob = secret_blob(&app, &h)?;
|
||||
if blob.is_none() && existing.auth != h.auth {
|
||||
return Err(bad("auth type changed - enter the new password / private key"));
|
||||
}
|
||||
let c = app.db.lock().unwrap();
|
||||
db::host_update(&c, &h, blob.as_deref())?;
|
||||
if existing.address != h.address || existing.port != h.port {
|
||||
// a different endpoint has a different host key
|
||||
c.execute("UPDATE hosts SET host_key='' WHERE id=?1", [id])?;
|
||||
}
|
||||
Ok(Json(db::host_get(&c, id).ok_or_else(not_found)?))
|
||||
}
|
||||
|
||||
async fn host_delete(State(app): Ctx, Path(id): Path<i64>) -> R {
|
||||
let c = app.db.lock().unwrap();
|
||||
if c.execute("DELETE FROM hosts WHERE id=?1", [id])? == 0 {
|
||||
return Err(not_found());
|
||||
}
|
||||
ok()
|
||||
}
|
||||
|
||||
async fn host_forget_key(State(app): Ctx, Path(id): Path<i64>) -> R {
|
||||
let c = app.db.lock().unwrap();
|
||||
if c.execute("UPDATE hosts SET host_key='' WHERE id=?1", [id])? == 0 {
|
||||
return Err(not_found());
|
||||
}
|
||||
db::event_add(&c, "system", "host key forgotten", &format!("host #{id}"));
|
||||
ok()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- snippets
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct Snippet {
|
||||
id: i64,
|
||||
name: String,
|
||||
command: String,
|
||||
}
|
||||
|
||||
impl Snippet {
|
||||
fn validate(&mut self) -> Result<(), ApiErr> {
|
||||
self.name = self.name.trim().to_string();
|
||||
if self.name.is_empty() || self.command.trim().is_empty() {
|
||||
return Err(bad("name and command are required"));
|
||||
}
|
||||
if self.name.len() > 100 || self.command.len() > 4000 {
|
||||
return Err(bad("field too long"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn snippets_list(State(app): Ctx) -> R<Vec<Snippet>> {
|
||||
let c = app.db.lock().unwrap();
|
||||
let mut st = c.prepare("SELECT id,name,command FROM snippets ORDER BY name COLLATE NOCASE")?;
|
||||
let rows = st.query_map([], |r| {
|
||||
Ok(Snippet { id: r.get(0)?, name: r.get(1)?, command: r.get(2)? })
|
||||
})?;
|
||||
Ok(Json(rows.filter_map(|x| x.ok()).collect()))
|
||||
}
|
||||
|
||||
async fn snippet_create(State(app): Ctx, Json(mut s): Json<Snippet>) -> R<Snippet> {
|
||||
s.validate()?;
|
||||
let c = app.db.lock().unwrap();
|
||||
c.execute("INSERT INTO snippets(name,command) VALUES(?1,?2)", rusqlite::params![s.name, s.command])?;
|
||||
s.id = c.last_insert_rowid();
|
||||
Ok(Json(s))
|
||||
}
|
||||
|
||||
async fn snippet_update(State(app): Ctx, Path(id): Path<i64>, Json(mut s): Json<Snippet>) -> R<Snippet> {
|
||||
s.validate()?;
|
||||
s.id = id;
|
||||
let c = app.db.lock().unwrap();
|
||||
if c.execute(
|
||||
"UPDATE snippets SET name=?1, command=?2 WHERE id=?3",
|
||||
rusqlite::params![s.name, s.command, id],
|
||||
)? == 0
|
||||
{
|
||||
return Err(not_found());
|
||||
}
|
||||
Ok(Json(s))
|
||||
}
|
||||
|
||||
async fn snippet_delete(State(app): Ctx, Path(id): Path<i64>) -> R {
|
||||
let c = app.db.lock().unwrap();
|
||||
c.execute("DELETE FROM snippets WHERE id=?1", [id])?;
|
||||
ok()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- events / settings
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct EvQ {
|
||||
limit: Option<i64>,
|
||||
}
|
||||
|
||||
async fn events_list(State(app): Ctx, Query(q): Query<EvQ>) -> R<Vec<db::Event>> {
|
||||
let c = app.db.lock().unwrap();
|
||||
Ok(Json(db::events_list(&c, q.limit.unwrap_or(200).clamp(1, 1000))?))
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Default)]
|
||||
#[serde(default)]
|
||||
struct Settings {
|
||||
webhook_url: String,
|
||||
webhook_kind: String,
|
||||
}
|
||||
|
||||
async fn settings_get(State(app): Ctx) -> R<Settings> {
|
||||
let c = app.db.lock().unwrap();
|
||||
Ok(Json(Settings {
|
||||
webhook_url: db::setting_get(&c, "webhook_url"),
|
||||
webhook_kind: {
|
||||
let k = db::setting_get(&c, "webhook_kind");
|
||||
if k.is_empty() { "json".into() } else { k }
|
||||
},
|
||||
}))
|
||||
}
|
||||
|
||||
async fn settings_put(State(app): Ctx, Json(mut s): Json<Settings>) -> R {
|
||||
s.webhook_url = s.webhook_url.trim().to_string();
|
||||
if !s.webhook_url.is_empty() && !(s.webhook_url.starts_with("http://") || s.webhook_url.starts_with("https://")) {
|
||||
return Err(bad("webhook URL must start with http:// or https://"));
|
||||
}
|
||||
if !matches!(s.webhook_kind.as_str(), "json" | "discord" | "slack" | "ntfy") {
|
||||
return Err(bad("unknown webhook type"));
|
||||
}
|
||||
let c = app.db.lock().unwrap();
|
||||
db::setting_set(&c, "webhook_url", &s.webhook_url)?;
|
||||
db::setting_set(&c, "webhook_kind", &s.webhook_kind)?;
|
||||
ok()
|
||||
}
|
||||
|
||||
async fn test_webhook(State(app): Ctx) -> R {
|
||||
alerts::send(&app, "labdeck test", "If you can read this, alerts work.", true)
|
||||
.await
|
||||
.map_err(bad)?;
|
||||
ok()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- export / import (no secrets)
|
||||
|
||||
async fn export(State(app): Ctx) -> Response {
|
||||
let body = {
|
||||
let c = app.db.lock().unwrap();
|
||||
let hosts: Vec<Value> = db::hosts_list(&c)
|
||||
.unwrap_or_default()
|
||||
.into_iter()
|
||||
.map(|h| {
|
||||
json!({
|
||||
"id": h.id, "name": h.name, "grp": h.grp, "address": h.address, "port": h.port,
|
||||
"username": h.username, "auth": h.auth, "startup": h.startup, "notes": h.notes
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
let snippets: Vec<Value> = c
|
||||
.prepare("SELECT name, command FROM snippets ORDER BY id")
|
||||
.and_then(|mut st| {
|
||||
st.query_map([], |r| Ok(json!({ "name": r.get::<_, String>(0)?, "command": r.get::<_, String>(1)? })))
|
||||
.map(|it| it.filter_map(|x| x.ok()).collect())
|
||||
})
|
||||
.unwrap_or_default();
|
||||
json!({
|
||||
"labdeck_export": 1,
|
||||
"exported": db::now(),
|
||||
"note": "SSH passwords and private keys are NOT included.",
|
||||
"services": db::services_list(&c).unwrap_or_default(),
|
||||
"hosts": hosts,
|
||||
"snippets": snippets,
|
||||
"settings": {
|
||||
"webhook_url": db::setting_get(&c, "webhook_url"),
|
||||
"webhook_kind": db::setting_get(&c, "webhook_kind"),
|
||||
}
|
||||
})
|
||||
};
|
||||
(
|
||||
[
|
||||
(header::CONTENT_TYPE, "application/json".to_string()),
|
||||
(header::CONTENT_DISPOSITION, "attachment; filename=\"labdeck-export.json\"".to_string()),
|
||||
],
|
||||
serde_json::to_string_pretty(&body).unwrap_or_default(),
|
||||
)
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ImportBody {
|
||||
#[serde(default)]
|
||||
services: Vec<Service>,
|
||||
#[serde(default)]
|
||||
hosts: Vec<Value>,
|
||||
#[serde(default)]
|
||||
snippets: Vec<Snippet>,
|
||||
}
|
||||
|
||||
async fn import(State(app): Ctx, Json(b): Json<ImportBody>) -> R {
|
||||
let mut c = app.db.lock().unwrap();
|
||||
let tx = c.transaction()?;
|
||||
let mut host_map: HashMap<i64, i64> = HashMap::new();
|
||||
let (mut nh, mut ns, mut nn) = (0, 0, 0);
|
||||
for hv in &b.hosts {
|
||||
let old = hv.get("id").and_then(|v| v.as_i64()).unwrap_or(0);
|
||||
let mut h: HostIn = serde_json::from_value(hv.clone()).map_err(|e| bad(format!("bad host entry: {e}")))?;
|
||||
h.validate().map_err(bad)?;
|
||||
let id = db::host_insert(&tx, &h, None)?;
|
||||
host_map.insert(old, id);
|
||||
nh += 1;
|
||||
}
|
||||
for mut s in b.services {
|
||||
s.host_id = s.host_id.and_then(|h| host_map.get(&h).copied());
|
||||
s.validate().map_err(|e| bad(format!("service '{}': {e}", s.name)))?;
|
||||
db::service_insert(&tx, &s)?;
|
||||
ns += 1;
|
||||
}
|
||||
for mut sn in b.snippets {
|
||||
sn.validate()?;
|
||||
tx.execute("INSERT INTO snippets(name,command) VALUES(?1,?2)", rusqlite::params![sn.name, sn.command])?;
|
||||
nn += 1;
|
||||
}
|
||||
tx.commit()?;
|
||||
db::event_add(&c, "system", "config imported", &format!("{ns} services, {nh} hosts, {nn} snippets"));
|
||||
drop(c);
|
||||
app.wake.notify_one();
|
||||
Ok(Json(json!({ "services": ns, "hosts": nh, "snippets": nn })))
|
||||
}
|
||||
+233
@@ -0,0 +1,233 @@
|
||||
use crate::{App, db, vault};
|
||||
use axum::http::{HeaderMap, header};
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
pub const COOKIE: &str = "labdeck_session";
|
||||
const SESSION_TTL: i64 = 12 * 3600;
|
||||
const TICKET_TTL: i64 = 30;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct LoginGuard {
|
||||
fails: u32,
|
||||
locked_until: i64,
|
||||
}
|
||||
|
||||
pub fn random_token() -> String {
|
||||
use base64::Engine;
|
||||
let mut b = [0u8; 32];
|
||||
rand::fill(&mut b);
|
||||
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(b)
|
||||
}
|
||||
|
||||
pub fn is_initialized(app: &App) -> bool {
|
||||
let c = app.db.lock().unwrap();
|
||||
db::meta_get(&c, "salt").is_some() && db::meta_get(&c, "verifier").is_some()
|
||||
}
|
||||
|
||||
pub fn session_from_headers(headers: &HeaderMap) -> Option<String> {
|
||||
let raw = headers.get(header::COOKIE)?.to_str().ok()?;
|
||||
for part in raw.split(';') {
|
||||
let part = part.trim();
|
||||
if let Some(v) = part.strip_prefix(&format!("{COOKIE}=")) {
|
||||
return Some(v.to_string());
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn session_valid(app: &App, headers: &HeaderMap) -> bool {
|
||||
let Some(tok) = session_from_headers(headers) else {
|
||||
return false;
|
||||
};
|
||||
let sessions = app.sessions.lock().unwrap();
|
||||
sessions.get(&tok).map(|exp| *exp > db::now()).unwrap_or(false)
|
||||
}
|
||||
|
||||
pub fn session_create(app: &App) -> String {
|
||||
let tok = random_token();
|
||||
app.sessions
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(tok.clone(), db::now() + SESSION_TTL);
|
||||
tok
|
||||
}
|
||||
|
||||
pub fn session_drop(app: &App, tok: &str) {
|
||||
let empty = {
|
||||
let mut s = app.sessions.lock().unwrap();
|
||||
s.remove(tok);
|
||||
s.is_empty()
|
||||
};
|
||||
if empty {
|
||||
*app.vault_key.write().unwrap() = None;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn cookie_header(token: &str, secure: bool, max_age: i64) -> String {
|
||||
format!(
|
||||
"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Strict; Max-Age={max_age}{}",
|
||||
if secure { "; Secure" } else { "" }
|
||||
)
|
||||
}
|
||||
|
||||
pub fn session_ttl() -> i64 {
|
||||
SESSION_TTL
|
||||
}
|
||||
|
||||
pub fn ticket_create(app: &App) -> String {
|
||||
let t = random_token();
|
||||
app.tickets
|
||||
.lock()
|
||||
.unwrap()
|
||||
.insert(t.clone(), db::now() + TICKET_TTL);
|
||||
t
|
||||
}
|
||||
|
||||
/// Single use.
|
||||
pub fn ticket_redeem(app: &App, ticket: &str) -> bool {
|
||||
let mut t = app.tickets.lock().unwrap();
|
||||
matches!(t.remove(ticket), Some(exp) if exp > db::now())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- setup / login
|
||||
|
||||
pub fn setup(app: &App, password: &str) -> Result<(), String> {
|
||||
if password.chars().count() < 10 {
|
||||
return Err("master password must be at least 10 characters".into());
|
||||
}
|
||||
if is_initialized(app) {
|
||||
return Err("already initialized".into());
|
||||
}
|
||||
let salt = vault::new_salt();
|
||||
let key = vault::derive(password, &salt)?;
|
||||
let verifier = vault::make_verifier(&key);
|
||||
let c = app.db.lock().unwrap();
|
||||
db::meta_set(&c, "salt", &salt).map_err(|e| e.to_string())?;
|
||||
db::meta_set(&c, "verifier", &verifier).map_err(|e| e.to_string())?;
|
||||
db::event_add(&c, "system", "vault initialized", "");
|
||||
drop(c);
|
||||
*app.vault_key.write().unwrap() = Some(key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub enum LoginErr {
|
||||
Locked(i64),
|
||||
Bad,
|
||||
NotInit,
|
||||
Internal(String),
|
||||
}
|
||||
|
||||
/// Checks the master password; on success the vault key is held in memory.
|
||||
pub fn login(app: &App, password: &str) -> Result<(), LoginErr> {
|
||||
{
|
||||
let g = app.login_guard.lock().unwrap();
|
||||
let now = db::now();
|
||||
if g.locked_until > now {
|
||||
return Err(LoginErr::Locked(g.locked_until - now));
|
||||
}
|
||||
}
|
||||
let (salt, verifier) = {
|
||||
let c = app.db.lock().unwrap();
|
||||
(db::meta_get(&c, "salt"), db::meta_get(&c, "verifier"))
|
||||
};
|
||||
let (Some(salt), Some(verifier)) = (salt, verifier) else {
|
||||
return Err(LoginErr::NotInit);
|
||||
};
|
||||
let key = vault::derive(password, &salt).map_err(LoginErr::Internal)?;
|
||||
if vault::check_verifier(&key, &verifier) {
|
||||
app.login_guard.lock().unwrap().fails = 0;
|
||||
*app.vault_key.write().unwrap() = Some(key);
|
||||
let c = app.db.lock().unwrap();
|
||||
db::event_add(&c, "login", "login", "");
|
||||
Ok(())
|
||||
} else {
|
||||
let mut g = app.login_guard.lock().unwrap();
|
||||
g.fails += 1;
|
||||
// 0,0,0,2s,4s,8s ... capped at 5 minutes
|
||||
if g.fails > 3 {
|
||||
let wait = (2i64 << (g.fails - 4).min(7)).min(300);
|
||||
g.locked_until = db::now() + wait;
|
||||
}
|
||||
let c = app.db.lock().unwrap();
|
||||
db::event_add(&c, "login_fail", "failed login", &format!("attempt {}", g.fails));
|
||||
Err(LoginErr::Bad)
|
||||
}
|
||||
}
|
||||
|
||||
pub fn change_password(app: &App, old: &str, new: &str) -> Result<(), String> {
|
||||
if new.chars().count() < 10 {
|
||||
return Err("new password must be at least 10 characters".into());
|
||||
}
|
||||
let (salt, verifier) = {
|
||||
let c = app.db.lock().unwrap();
|
||||
(db::meta_get(&c, "salt"), db::meta_get(&c, "verifier"))
|
||||
};
|
||||
let (Some(salt), Some(verifier)) = (salt, verifier) else {
|
||||
return Err("not initialized".into());
|
||||
};
|
||||
let old_key = vault::derive(old, &salt)?;
|
||||
if !vault::check_verifier(&old_key, &verifier) {
|
||||
return Err("current password is wrong".into());
|
||||
}
|
||||
let new_salt = vault::new_salt();
|
||||
let new_key = vault::derive(new, &new_salt)?;
|
||||
|
||||
let mut c = app.db.lock().unwrap();
|
||||
let tx = c.transaction().map_err(|e| e.to_string())?;
|
||||
{
|
||||
let mut rows: Vec<(i64, Vec<u8>)> = Vec::new();
|
||||
let mut st = tx
|
||||
.prepare("SELECT id, secret FROM hosts WHERE secret IS NOT NULL")
|
||||
.map_err(|e| e.to_string())?;
|
||||
let it = st
|
||||
.query_map([], |r| Ok((r.get::<_, i64>(0)?, r.get::<_, Vec<u8>>(1)?)))
|
||||
.map_err(|e| e.to_string())?;
|
||||
for r in it {
|
||||
rows.push(r.map_err(|e| e.to_string())?);
|
||||
}
|
||||
drop(st);
|
||||
for (id, blob) in rows {
|
||||
let plain = vault::decrypt(&old_key, &blob)
|
||||
.ok_or_else(|| format!("could not decrypt secret of host {id}"))?;
|
||||
let nb = vault::encrypt(&new_key, &plain);
|
||||
tx.execute(
|
||||
"UPDATE hosts SET secret=?1 WHERE id=?2",
|
||||
rusqlite::params![nb, id],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
db::meta_set(&tx, "salt", &new_salt).map_err(|e| e.to_string())?;
|
||||
db::meta_set(&tx, "verifier", &vault::make_verifier(&new_key)).map_err(|e| e.to_string())?;
|
||||
tx.commit().map_err(|e| e.to_string())?;
|
||||
db::event_add(&c, "system", "master password changed", "");
|
||||
drop(c);
|
||||
*app.vault_key.write().unwrap() = Some(new_key);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Periodic cleanup: expired sessions/tickets, old history; locks the vault when nobody is logged in.
|
||||
pub async fn janitor(app: Arc<App>) {
|
||||
let mut tick = tokio::time::interval(Duration::from_secs(60));
|
||||
let mut n: u64 = 0;
|
||||
loop {
|
||||
tick.tick().await;
|
||||
n += 1;
|
||||
let now = db::now();
|
||||
let none_left = {
|
||||
let mut s = app.sessions.lock().unwrap();
|
||||
s.retain(|_, exp| *exp > now);
|
||||
s.is_empty()
|
||||
};
|
||||
app.tickets.lock().unwrap().retain(|_, exp| *exp > now);
|
||||
if none_left {
|
||||
*app.vault_key.write().unwrap() = None;
|
||||
}
|
||||
if n % 10 == 1 {
|
||||
let c = app.db.lock().unwrap();
|
||||
let _ = c.execute("DELETE FROM checks WHERE ts < ?1", [now - 14 * 86_400]);
|
||||
let _ = c.execute("DELETE FROM events WHERE ts < ?1", [now - 180 * 86_400]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,483 @@
|
||||
use rusqlite::{Connection, OptionalExtension, Row, params};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
pub fn now() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
pub fn open(path: &str) -> rusqlite::Result<Connection> {
|
||||
let c = Connection::open(path)?;
|
||||
c.execute_batch(
|
||||
"PRAGMA journal_mode=WAL;
|
||||
PRAGMA synchronous=NORMAL;
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA busy_timeout=5000;
|
||||
CREATE TABLE IF NOT EXISTS meta(key TEXT PRIMARY KEY, value BLOB NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS settings(key TEXT PRIMARY KEY, value TEXT NOT NULL);
|
||||
CREATE TABLE IF NOT EXISTS hosts(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
grp TEXT NOT NULL DEFAULT '',
|
||||
address TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 22,
|
||||
username TEXT NOT NULL DEFAULT '',
|
||||
auth TEXT NOT NULL DEFAULT 'password',
|
||||
secret BLOB,
|
||||
host_key TEXT NOT NULL DEFAULT '',
|
||||
startup TEXT NOT NULL DEFAULT '',
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
last_connected INTEGER,
|
||||
created INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS services(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
grp TEXT NOT NULL DEFAULT '',
|
||||
kind TEXT NOT NULL DEFAULT 'http',
|
||||
address TEXT NOT NULL,
|
||||
interval_s INTEGER NOT NULL DEFAULT 30,
|
||||
timeout_s INTEGER NOT NULL DEFAULT 5,
|
||||
expect TEXT NOT NULL DEFAULT '200-399',
|
||||
keyword TEXT NOT NULL DEFAULT '',
|
||||
verify_tls INTEGER NOT NULL DEFAULT 1,
|
||||
down_after INTEGER NOT NULL DEFAULT 2,
|
||||
host_id INTEGER REFERENCES hosts(id) ON DELETE SET NULL,
|
||||
notes TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort INTEGER NOT NULL DEFAULT 0,
|
||||
created INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS checks(
|
||||
service_id INTEGER NOT NULL REFERENCES services(id) ON DELETE CASCADE,
|
||||
ts INTEGER NOT NULL,
|
||||
ok INTEGER NOT NULL,
|
||||
latency_ms INTEGER,
|
||||
code INTEGER,
|
||||
error TEXT
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_checks ON checks(service_id, ts);
|
||||
CREATE TABLE IF NOT EXISTS events(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ts INTEGER NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
detail TEXT NOT NULL DEFAULT ''
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS snippets(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
command TEXT NOT NULL
|
||||
);",
|
||||
)?;
|
||||
Ok(c)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- meta / settings
|
||||
|
||||
pub fn meta_get(c: &Connection, key: &str) -> Option<Vec<u8>> {
|
||||
c.query_row("SELECT value FROM meta WHERE key=?1", params![key], |r| r.get(0))
|
||||
.optional()
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
pub fn meta_set(c: &Connection, key: &str, value: &[u8]) -> rusqlite::Result<()> {
|
||||
c.execute(
|
||||
"INSERT INTO meta(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||
params![key, value],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn setting_get(c: &Connection, key: &str) -> String {
|
||||
c.query_row("SELECT value FROM settings WHERE key=?1", params![key], |r| r.get(0))
|
||||
.optional()
|
||||
.ok()
|
||||
.flatten()
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
pub fn setting_set(c: &Connection, key: &str, value: &str) -> rusqlite::Result<()> {
|
||||
c.execute(
|
||||
"INSERT INTO settings(key,value) VALUES(?1,?2) ON CONFLICT(key) DO UPDATE SET value=excluded.value",
|
||||
params![key, value],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- events
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Event {
|
||||
pub id: i64,
|
||||
pub ts: i64,
|
||||
pub kind: String,
|
||||
pub title: String,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
pub fn event_add(c: &Connection, kind: &str, title: &str, detail: &str) {
|
||||
let _ = c.execute(
|
||||
"INSERT INTO events(ts,kind,title,detail) VALUES(?1,?2,?3,?4)",
|
||||
params![now(), kind, title, detail],
|
||||
);
|
||||
}
|
||||
|
||||
pub fn events_list(c: &Connection, limit: i64) -> rusqlite::Result<Vec<Event>> {
|
||||
let mut st = c.prepare("SELECT id,ts,kind,title,detail FROM events ORDER BY id DESC LIMIT ?1")?;
|
||||
let rows = st.query_map(params![limit], |r| {
|
||||
Ok(Event {
|
||||
id: r.get(0)?,
|
||||
ts: r.get(1)?,
|
||||
kind: r.get(2)?,
|
||||
title: r.get(3)?,
|
||||
detail: r.get(4)?,
|
||||
})
|
||||
})?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- services
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(default)]
|
||||
pub struct Service {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub grp: String,
|
||||
pub kind: String,
|
||||
pub address: String,
|
||||
pub interval_s: i64,
|
||||
pub timeout_s: i64,
|
||||
pub expect: String,
|
||||
pub keyword: String,
|
||||
pub verify_tls: bool,
|
||||
pub down_after: i64,
|
||||
pub host_id: Option<i64>,
|
||||
pub notes: String,
|
||||
pub enabled: bool,
|
||||
pub sort: i64,
|
||||
}
|
||||
|
||||
impl Default for Service {
|
||||
fn default() -> Self {
|
||||
Service {
|
||||
id: 0,
|
||||
name: String::new(),
|
||||
grp: String::new(),
|
||||
kind: "http".into(),
|
||||
address: String::new(),
|
||||
interval_s: 30,
|
||||
timeout_s: 5,
|
||||
expect: "200-399".into(),
|
||||
keyword: String::new(),
|
||||
verify_tls: true,
|
||||
down_after: 2,
|
||||
host_id: None,
|
||||
notes: String::new(),
|
||||
enabled: true,
|
||||
sort: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_hostish(s: &str) -> bool {
|
||||
!s.is_empty()
|
||||
&& !s.starts_with('-')
|
||||
&& s.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | ':' | '[' | ']'))
|
||||
}
|
||||
|
||||
impl Service {
|
||||
pub fn validate(&mut self) -> Result<(), String> {
|
||||
self.name = self.name.trim().to_string();
|
||||
self.grp = self.grp.trim().to_string();
|
||||
self.address = self.address.trim().to_string();
|
||||
self.expect = self.expect.trim().to_string();
|
||||
self.keyword = self.keyword.trim().to_string();
|
||||
if self.name.is_empty() {
|
||||
return Err("name is required".into());
|
||||
}
|
||||
if self.address.is_empty() {
|
||||
return Err("address is required".into());
|
||||
}
|
||||
if self.name.len() > 100 || self.address.len() > 500 || self.notes.len() > 5000 {
|
||||
return Err("field too long".into());
|
||||
}
|
||||
match self.kind.as_str() {
|
||||
"http" => {
|
||||
if self.expect.is_empty() {
|
||||
self.expect = "200-399".into();
|
||||
}
|
||||
if parse_expect(&self.expect).is_none() {
|
||||
return Err("expect: use e.g. 200, 200-299 or 200,204,301-302".into());
|
||||
}
|
||||
}
|
||||
"tcp" => {
|
||||
let ok = self
|
||||
.address
|
||||
.rsplit_once(':')
|
||||
.map(|(h, p)| !h.is_empty() && p.parse::<u16>().is_ok())
|
||||
.unwrap_or(false);
|
||||
if !ok {
|
||||
return Err("tcp address must be host:port".into());
|
||||
}
|
||||
}
|
||||
"ping" => {
|
||||
if !is_hostish(&self.address) {
|
||||
return Err("ping address must be a hostname or IP".into());
|
||||
}
|
||||
}
|
||||
_ => return Err("kind must be http, tcp or ping".into()),
|
||||
}
|
||||
self.interval_s = self.interval_s.clamp(5, 86_400);
|
||||
self.timeout_s = self.timeout_s.clamp(1, 60);
|
||||
if self.timeout_s > self.interval_s {
|
||||
self.timeout_s = self.interval_s;
|
||||
}
|
||||
self.down_after = self.down_after.clamp(1, 10);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// "200", "200-299", "200,204,301-302" -> list of inclusive ranges.
|
||||
pub fn parse_expect(s: &str) -> Option<Vec<(u16, u16)>> {
|
||||
let mut out = Vec::new();
|
||||
for part in s.split(',') {
|
||||
let part = part.trim();
|
||||
if part.is_empty() {
|
||||
return None;
|
||||
}
|
||||
if let Some((a, b)) = part.split_once('-') {
|
||||
let (a, b) = (a.trim().parse::<u16>().ok()?, b.trim().parse::<u16>().ok()?);
|
||||
if a > b {
|
||||
return None;
|
||||
}
|
||||
out.push((a, b));
|
||||
} else {
|
||||
let a = part.parse::<u16>().ok()?;
|
||||
out.push((a, a));
|
||||
}
|
||||
}
|
||||
if out.is_empty() { None } else { Some(out) }
|
||||
}
|
||||
|
||||
const SVC_COLS: &str =
|
||||
"id,name,grp,kind,address,interval_s,timeout_s,expect,keyword,verify_tls,down_after,host_id,notes,enabled,sort";
|
||||
|
||||
fn row_service(r: &Row) -> rusqlite::Result<Service> {
|
||||
Ok(Service {
|
||||
id: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
grp: r.get(2)?,
|
||||
kind: r.get(3)?,
|
||||
address: r.get(4)?,
|
||||
interval_s: r.get(5)?,
|
||||
timeout_s: r.get(6)?,
|
||||
expect: r.get(7)?,
|
||||
keyword: r.get(8)?,
|
||||
verify_tls: r.get::<_, i64>(9)? != 0,
|
||||
down_after: r.get(10)?,
|
||||
host_id: r.get(11)?,
|
||||
notes: r.get(12)?,
|
||||
enabled: r.get::<_, i64>(13)? != 0,
|
||||
sort: r.get(14)?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn services_list(c: &Connection) -> rusqlite::Result<Vec<Service>> {
|
||||
let mut st = c.prepare(&format!(
|
||||
"SELECT {SVC_COLS} FROM services ORDER BY grp COLLATE NOCASE, sort, name COLLATE NOCASE"
|
||||
))?;
|
||||
let rows = st.query_map([], row_service)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn service_get(c: &Connection, id: i64) -> Option<Service> {
|
||||
c.query_row(
|
||||
&format!("SELECT {SVC_COLS} FROM services WHERE id=?1"),
|
||||
params![id],
|
||||
row_service,
|
||||
)
|
||||
.optional()
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
pub fn service_insert(c: &Connection, s: &Service) -> rusqlite::Result<i64> {
|
||||
c.execute(
|
||||
"INSERT INTO services(name,grp,kind,address,interval_s,timeout_s,expect,keyword,verify_tls,down_after,host_id,notes,enabled,sort,created)
|
||||
VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,?12,?13,?14,?15)",
|
||||
params![
|
||||
s.name, s.grp, s.kind, s.address, s.interval_s, s.timeout_s, s.expect, s.keyword,
|
||||
s.verify_tls as i64, s.down_after, s.host_id, s.notes, s.enabled as i64, s.sort, now()
|
||||
],
|
||||
)?;
|
||||
Ok(c.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn service_update(c: &Connection, s: &Service) -> rusqlite::Result<usize> {
|
||||
c.execute(
|
||||
"UPDATE services SET name=?1,grp=?2,kind=?3,address=?4,interval_s=?5,timeout_s=?6,expect=?7,keyword=?8,
|
||||
verify_tls=?9,down_after=?10,host_id=?11,notes=?12,enabled=?13,sort=?14 WHERE id=?15",
|
||||
params![
|
||||
s.name, s.grp, s.kind, s.address, s.interval_s, s.timeout_s, s.expect, s.keyword,
|
||||
s.verify_tls as i64, s.down_after, s.host_id, s.notes, s.enabled as i64, s.sort, s.id
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- hosts
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
#[serde(default)]
|
||||
pub struct HostIn {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub grp: String,
|
||||
pub address: String,
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
pub auth: String,
|
||||
pub startup: String,
|
||||
pub notes: String,
|
||||
/// password, or private key (PEM/OpenSSH). `None` / empty on update = keep existing.
|
||||
pub secret: Option<String>,
|
||||
pub passphrase: Option<String>,
|
||||
}
|
||||
|
||||
impl Default for HostIn {
|
||||
fn default() -> Self {
|
||||
HostIn {
|
||||
id: 0,
|
||||
name: String::new(),
|
||||
grp: String::new(),
|
||||
address: String::new(),
|
||||
port: 22,
|
||||
username: String::new(),
|
||||
auth: "password".into(),
|
||||
startup: String::new(),
|
||||
notes: String::new(),
|
||||
secret: None,
|
||||
passphrase: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl HostIn {
|
||||
pub fn validate(&mut self) -> Result<(), String> {
|
||||
self.name = self.name.trim().to_string();
|
||||
self.grp = self.grp.trim().to_string();
|
||||
self.address = self.address.trim().to_string();
|
||||
self.username = self.username.trim().to_string();
|
||||
if self.name.is_empty() {
|
||||
return Err("name is required".into());
|
||||
}
|
||||
if !is_hostish(&self.address) {
|
||||
return Err("address must be a hostname or IP".into());
|
||||
}
|
||||
if self.username.is_empty() {
|
||||
return Err("username is required".into());
|
||||
}
|
||||
if self.port == 0 {
|
||||
self.port = 22;
|
||||
}
|
||||
if self.auth != "password" && self.auth != "key" {
|
||||
return Err("auth must be password or key".into());
|
||||
}
|
||||
if self.startup.len() > 2000 || self.notes.len() > 5000 {
|
||||
return Err("field too long".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct HostOut {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub grp: String,
|
||||
pub address: String,
|
||||
pub port: u16,
|
||||
pub username: String,
|
||||
pub auth: String,
|
||||
pub startup: String,
|
||||
pub notes: String,
|
||||
pub host_key: String,
|
||||
pub last_connected: Option<i64>,
|
||||
pub has_secret: bool,
|
||||
}
|
||||
|
||||
const HOST_COLS: &str =
|
||||
"id,name,grp,address,port,username,auth,startup,notes,host_key,last_connected,secret IS NOT NULL";
|
||||
|
||||
fn row_host(r: &Row) -> rusqlite::Result<HostOut> {
|
||||
Ok(HostOut {
|
||||
id: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
grp: r.get(2)?,
|
||||
address: r.get(3)?,
|
||||
port: r.get::<_, i64>(4)? as u16,
|
||||
username: r.get(5)?,
|
||||
auth: r.get(6)?,
|
||||
startup: r.get(7)?,
|
||||
notes: r.get(8)?,
|
||||
host_key: r.get(9)?,
|
||||
last_connected: r.get(10)?,
|
||||
has_secret: r.get::<_, i64>(11)? != 0,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn hosts_list(c: &Connection) -> rusqlite::Result<Vec<HostOut>> {
|
||||
let mut st = c.prepare(&format!(
|
||||
"SELECT {HOST_COLS} FROM hosts ORDER BY grp COLLATE NOCASE, name COLLATE NOCASE"
|
||||
))?;
|
||||
let rows = st.query_map([], row_host)?;
|
||||
rows.collect()
|
||||
}
|
||||
|
||||
pub fn host_get(c: &Connection, id: i64) -> Option<HostOut> {
|
||||
c.query_row(
|
||||
&format!("SELECT {HOST_COLS} FROM hosts WHERE id=?1"),
|
||||
params![id],
|
||||
row_host,
|
||||
)
|
||||
.optional()
|
||||
.ok()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
pub fn host_secret(c: &Connection, id: i64) -> Option<Vec<u8>> {
|
||||
c.query_row("SELECT secret FROM hosts WHERE id=?1", params![id], |r| {
|
||||
r.get::<_, Option<Vec<u8>>>(0)
|
||||
})
|
||||
.optional()
|
||||
.ok()
|
||||
.flatten()
|
||||
.flatten()
|
||||
}
|
||||
|
||||
pub fn host_insert(c: &Connection, h: &HostIn, secret: Option<&[u8]>) -> rusqlite::Result<i64> {
|
||||
c.execute(
|
||||
"INSERT INTO hosts(name,grp,address,port,username,auth,secret,startup,notes,created)
|
||||
VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10)",
|
||||
params![h.name, h.grp, h.address, h.port as i64, h.username, h.auth, secret, h.startup, h.notes, now()],
|
||||
)?;
|
||||
Ok(c.last_insert_rowid())
|
||||
}
|
||||
|
||||
/// `secret`: `Some(blob)` replaces the stored secret, `None` keeps it.
|
||||
pub fn host_update(c: &Connection, h: &HostIn, secret: Option<&[u8]>) -> rusqlite::Result<usize> {
|
||||
let n = c.execute(
|
||||
"UPDATE hosts SET name=?1,grp=?2,address=?3,port=?4,username=?5,auth=?6,startup=?7,notes=?8 WHERE id=?9",
|
||||
params![h.name, h.grp, h.address, h.port as i64, h.username, h.auth, h.startup, h.notes, h.id],
|
||||
)?;
|
||||
if let Some(blob) = secret {
|
||||
c.execute("UPDATE hosts SET secret=?1 WHERE id=?2", params![blob, h.id])?;
|
||||
}
|
||||
Ok(n)
|
||||
}
|
||||
+347
@@ -0,0 +1,347 @@
|
||||
use crate::{App, alerts, db};
|
||||
use db::Service;
|
||||
use serde::Serialize;
|
||||
use std::collections::{HashMap, HashSet};
|
||||
use std::error::Error as _;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
use tokio::net::TcpStream;
|
||||
use tokio::sync::Semaphore;
|
||||
|
||||
#[derive(Serialize, Clone, Debug, Default)]
|
||||
pub struct Live {
|
||||
/// up | down | unknown | paused
|
||||
pub status: String,
|
||||
pub since: i64,
|
||||
pub last_check: i64,
|
||||
pub latency_ms: Option<i64>,
|
||||
pub code: Option<i64>,
|
||||
pub error: String,
|
||||
/// consecutive failed checks
|
||||
pub fails: i64,
|
||||
pub tls_days: Option<i64>,
|
||||
#[serde(skip)]
|
||||
pub tls_warned: i64,
|
||||
}
|
||||
|
||||
pub struct CheckResult {
|
||||
pub ok: bool,
|
||||
pub latency_ms: Option<i64>,
|
||||
pub code: Option<i64>,
|
||||
pub error: String,
|
||||
pub tls_days: Option<i64>,
|
||||
}
|
||||
|
||||
impl CheckResult {
|
||||
fn fail(error: impl Into<String>) -> Self {
|
||||
CheckResult {
|
||||
ok: false,
|
||||
latency_ms: None,
|
||||
code: None,
|
||||
error: error.into(),
|
||||
tls_days: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- scheduler
|
||||
|
||||
pub async fn scheduler(app: Arc<App>) {
|
||||
let sem = Arc::new(Semaphore::new(32));
|
||||
let running: Arc<Mutex<HashSet<i64>>> = Arc::new(Mutex::new(HashSet::new()));
|
||||
let mut due: HashMap<i64, Instant> = HashMap::new();
|
||||
let mut sigs: HashMap<i64, String> = HashMap::new();
|
||||
|
||||
loop {
|
||||
let services = {
|
||||
let c = app.db.lock().unwrap();
|
||||
db::services_list(&c).unwrap_or_default()
|
||||
};
|
||||
let ids: HashSet<i64> = services.iter().map(|s| s.id).collect();
|
||||
due.retain(|id, _| ids.contains(id));
|
||||
sigs.retain(|id, _| ids.contains(id));
|
||||
app.live.write().unwrap().retain(|id, _| ids.contains(id));
|
||||
|
||||
let now = Instant::now();
|
||||
for s in services {
|
||||
if !s.enabled {
|
||||
let mut live = app.live.write().unwrap();
|
||||
let l = live.entry(s.id).or_default();
|
||||
if l.status != "paused" {
|
||||
l.status = "paused".into();
|
||||
l.since = db::now();
|
||||
l.fails = 0;
|
||||
}
|
||||
due.remove(&s.id);
|
||||
continue;
|
||||
}
|
||||
// a changed definition is checked right away
|
||||
let sig = format!("{:?}", s);
|
||||
if sigs.get(&s.id) != Some(&sig) {
|
||||
sigs.insert(s.id, sig);
|
||||
due.insert(s.id, now);
|
||||
}
|
||||
let when = *due.entry(s.id).or_insert(now);
|
||||
if when <= now && !running.lock().unwrap().contains(&s.id) {
|
||||
due.insert(s.id, now + Duration::from_secs(s.interval_s as u64));
|
||||
running.lock().unwrap().insert(s.id);
|
||||
let (app2, running2, sem2) = (app.clone(), running.clone(), sem.clone());
|
||||
tokio::spawn(async move {
|
||||
let _permit = sem2.acquire().await;
|
||||
check_and_record(&app2, &s).await;
|
||||
running2.lock().unwrap().remove(&s.id);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
tokio::select! {
|
||||
_ = tokio::time::sleep(Duration::from_secs(1)) => {}
|
||||
_ = app.wake.notified() => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn check_and_record(app: &Arc<App>, s: &Service) {
|
||||
let r = run_check(app, s).await;
|
||||
record(app, s, &r);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- recording / state machine
|
||||
|
||||
enum Transition {
|
||||
Down,
|
||||
Up(i64),
|
||||
TlsSoon(i64),
|
||||
}
|
||||
|
||||
fn record(app: &Arc<App>, s: &Service, r: &CheckResult) {
|
||||
let now = db::now();
|
||||
{
|
||||
let c = app.db.lock().unwrap();
|
||||
let _ = c.execute(
|
||||
"INSERT INTO checks(service_id,ts,ok,latency_ms,code,error) VALUES(?1,?2,?3,?4,?5,?6)",
|
||||
rusqlite::params![s.id, now, r.ok as i64, r.latency_ms, r.code, r.error],
|
||||
);
|
||||
}
|
||||
let mut transition = None;
|
||||
{
|
||||
let mut live = app.live.write().unwrap();
|
||||
let l = live.entry(s.id).or_insert_with(|| Live {
|
||||
status: "unknown".into(),
|
||||
since: now,
|
||||
..Default::default()
|
||||
});
|
||||
l.last_check = now;
|
||||
l.latency_ms = r.latency_ms;
|
||||
l.code = r.code;
|
||||
l.error = r.error.clone();
|
||||
l.tls_days = r.tls_days;
|
||||
let prev = l.status.clone();
|
||||
if r.ok {
|
||||
l.fails = 0;
|
||||
if prev != "up" {
|
||||
let was_down_for = now - l.since;
|
||||
l.status = "up".into();
|
||||
l.since = now;
|
||||
if prev == "down" {
|
||||
transition = Some(Transition::Up(was_down_for));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
l.fails += 1;
|
||||
if l.fails >= s.down_after && prev != "down" {
|
||||
l.status = "down".into();
|
||||
l.since = now;
|
||||
transition = Some(Transition::Down);
|
||||
}
|
||||
}
|
||||
if let Some(d) = r.tls_days {
|
||||
if d <= 7 && now - l.tls_warned > 86_400 && transition.is_none() {
|
||||
l.tls_warned = now;
|
||||
transition = Some(Transition::TlsSoon(d));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match transition {
|
||||
Some(Transition::Down) => {
|
||||
let title = format!("{} is DOWN", s.name);
|
||||
let detail = format!("{} ({}) - {}", s.address, s.kind, r.error);
|
||||
{
|
||||
let c = app.db.lock().unwrap();
|
||||
db::event_add(&c, "down", &title, &detail);
|
||||
}
|
||||
alerts::notify(app.clone(), title, detail, false);
|
||||
}
|
||||
Some(Transition::Up(secs)) => {
|
||||
let title = format!("{} is UP again", s.name);
|
||||
let detail = format!("{} - was down for {}", s.address, human_duration(secs));
|
||||
{
|
||||
let c = app.db.lock().unwrap();
|
||||
db::event_add(&c, "up", &title, &detail);
|
||||
}
|
||||
alerts::notify(app.clone(), title, detail, true);
|
||||
}
|
||||
Some(Transition::TlsSoon(days)) => {
|
||||
let title = format!("{}: TLS certificate expires in {} day(s)", s.name, days.max(0));
|
||||
let detail = s.address.clone();
|
||||
{
|
||||
let c = app.db.lock().unwrap();
|
||||
db::event_add(&c, "tls", &title, &detail);
|
||||
}
|
||||
alerts::notify(app.clone(), title, detail, false);
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn human_duration(secs: i64) -> String {
|
||||
let s = secs.max(0);
|
||||
if s < 90 {
|
||||
format!("{s}s")
|
||||
} else if s < 5400 {
|
||||
format!("{}m", s / 60)
|
||||
} else if s < 172_800 {
|
||||
format!("{}h {}m", s / 3600, (s % 3600) / 60)
|
||||
} else {
|
||||
format!("{}d {}h", s / 86_400, (s % 86_400) / 3600)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- checks
|
||||
|
||||
pub async fn run_check(app: &App, s: &Service) -> CheckResult {
|
||||
let timeout = Duration::from_secs(s.timeout_s.max(1) as u64);
|
||||
match s.kind.as_str() {
|
||||
"http" => check_http(app, s, timeout).await,
|
||||
"tcp" => check_tcp(s, timeout).await,
|
||||
"ping" => check_ping(s, timeout).await,
|
||||
other => CheckResult::fail(format!("unknown check kind '{other}'")),
|
||||
}
|
||||
}
|
||||
|
||||
fn describe(e: &reqwest::Error) -> String {
|
||||
if e.is_timeout() {
|
||||
return "timeout".into();
|
||||
}
|
||||
let mut msg = e.to_string();
|
||||
let mut cur = e.source();
|
||||
while let Some(s) = cur {
|
||||
msg = s.to_string();
|
||||
cur = s.source();
|
||||
}
|
||||
if e.is_connect() {
|
||||
format!("connect failed: {msg}")
|
||||
} else {
|
||||
msg
|
||||
}
|
||||
}
|
||||
|
||||
fn cert_days_left(der: &[u8]) -> Option<i64> {
|
||||
let (_, cert) = x509_parser::parse_x509_certificate(der).ok()?;
|
||||
let not_after = cert.validity().not_after.timestamp();
|
||||
Some((not_after - db::now()).div_euclid(86_400))
|
||||
}
|
||||
|
||||
async fn check_http(app: &App, s: &Service, timeout: Duration) -> CheckResult {
|
||||
let url = if s.address.contains("://") {
|
||||
s.address.clone()
|
||||
} else {
|
||||
format!("http://{}", s.address)
|
||||
};
|
||||
let client = if s.verify_tls { &app.http_strict } else { &app.http_insecure };
|
||||
let start = Instant::now();
|
||||
let mut resp = match client.get(&url).timeout(timeout).send().await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
let mut f = CheckResult::fail(describe(&e));
|
||||
f.latency_ms = Some(start.elapsed().as_millis() as i64);
|
||||
return f;
|
||||
}
|
||||
};
|
||||
let latency = start.elapsed().as_millis() as i64;
|
||||
let code = resp.status().as_u16();
|
||||
let tls_days = resp
|
||||
.extensions()
|
||||
.get::<reqwest::tls::TlsInfo>()
|
||||
.and_then(|t| t.peer_certificate())
|
||||
.and_then(cert_days_left);
|
||||
|
||||
let ranges = db::parse_expect(&s.expect).unwrap_or_else(|| vec![(200, 399)]);
|
||||
let mut ok = ranges.iter().any(|(a, b)| code >= *a && code <= *b);
|
||||
let mut error = if ok { String::new() } else { format!("unexpected status {code}") };
|
||||
|
||||
if ok && !s.keyword.is_empty() {
|
||||
let mut body: Vec<u8> = Vec::new();
|
||||
let read = async {
|
||||
while let Ok(Some(chunk)) = resp.chunk().await {
|
||||
body.extend_from_slice(&chunk);
|
||||
if body.len() > 256 * 1024 {
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
let _ = tokio::time::timeout(timeout, read).await;
|
||||
if !String::from_utf8_lossy(&body).contains(&s.keyword) {
|
||||
ok = false;
|
||||
error = format!("keyword '{}' not found", s.keyword);
|
||||
}
|
||||
}
|
||||
|
||||
CheckResult {
|
||||
ok,
|
||||
latency_ms: Some(latency),
|
||||
code: Some(code as i64),
|
||||
error,
|
||||
tls_days,
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_tcp(s: &Service, timeout: Duration) -> CheckResult {
|
||||
let start = Instant::now();
|
||||
match tokio::time::timeout(timeout, TcpStream::connect(&s.address)).await {
|
||||
Err(_) => CheckResult::fail("timeout"),
|
||||
Ok(Err(e)) => CheckResult::fail(format!("connect failed: {e}")),
|
||||
Ok(Ok(_)) => CheckResult {
|
||||
ok: true,
|
||||
latency_ms: Some(start.elapsed().as_millis() as i64),
|
||||
code: None,
|
||||
error: String::new(),
|
||||
tls_days: None,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async fn check_ping(s: &Service, timeout: Duration) -> CheckResult {
|
||||
let start = Instant::now();
|
||||
let mut cmd = tokio::process::Command::new("ping");
|
||||
cmd.args(["-c", "1", "-W", &timeout.as_secs().max(1).to_string(), &s.address])
|
||||
.kill_on_drop(true)
|
||||
.stdin(std::process::Stdio::null());
|
||||
let out = tokio::time::timeout(timeout + Duration::from_secs(2), cmd.output()).await;
|
||||
match out {
|
||||
Err(_) => CheckResult::fail("timeout"),
|
||||
Ok(Err(e)) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
CheckResult::fail("'ping' binary not found (install iputils-ping)")
|
||||
}
|
||||
Ok(Err(e)) => CheckResult::fail(format!("ping failed: {e}")),
|
||||
Ok(Ok(o)) if o.status.success() => {
|
||||
let text = String::from_utf8_lossy(&o.stdout);
|
||||
let ms = text
|
||||
.split("time=")
|
||||
.nth(1)
|
||||
.and_then(|t| t.split_whitespace().next())
|
||||
.and_then(|t| t.trim_end_matches("ms").parse::<f64>().ok())
|
||||
.map(|f| f.round() as i64)
|
||||
.unwrap_or_else(|| start.elapsed().as_millis() as i64);
|
||||
CheckResult {
|
||||
ok: true,
|
||||
latency_ms: Some(ms),
|
||||
code: None,
|
||||
error: String::new(),
|
||||
tls_days: None,
|
||||
}
|
||||
}
|
||||
Ok(Ok(_)) => CheckResult::fail("no reply"),
|
||||
}
|
||||
}
|
||||
+98
@@ -0,0 +1,98 @@
|
||||
mod alerts;
|
||||
mod api;
|
||||
mod auth;
|
||||
mod db;
|
||||
mod health;
|
||||
mod term;
|
||||
mod vault;
|
||||
mod web;
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::{Arc, Mutex, RwLock};
|
||||
use std::time::Duration;
|
||||
|
||||
pub struct App {
|
||||
pub db: Mutex<rusqlite::Connection>,
|
||||
/// Derived vault key; only present while at least one session is logged in.
|
||||
pub vault_key: RwLock<Option<vault::VaultKey>>,
|
||||
/// session token -> expiry (unix seconds)
|
||||
pub sessions: Mutex<HashMap<String, i64>>,
|
||||
/// single-use websocket tickets -> expiry
|
||||
pub tickets: Mutex<HashMap<String, i64>>,
|
||||
pub live: RwLock<HashMap<i64, health::Live>>,
|
||||
pub login_guard: Mutex<auth::LoginGuard>,
|
||||
pub http_strict: reqwest::Client,
|
||||
pub http_insecure: reqwest::Client,
|
||||
pub wake: tokio::sync::Notify,
|
||||
pub started: i64,
|
||||
}
|
||||
|
||||
fn build_client(insecure: bool) -> reqwest::Client {
|
||||
reqwest::Client::builder()
|
||||
.user_agent(concat!("labdeck/", env!("CARGO_PKG_VERSION")))
|
||||
.redirect(reqwest::redirect::Policy::limited(5))
|
||||
.danger_accept_invalid_certs(insecure)
|
||||
.tls_info(true)
|
||||
.pool_max_idle_per_host(0)
|
||||
.build()
|
||||
.expect("http client")
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
let addr: SocketAddr = std::env::var("LABDECK_ADDR")
|
||||
.unwrap_or_else(|_| "127.0.0.1:8787".into())
|
||||
.parse()
|
||||
.expect("LABDECK_ADDR must look like 127.0.0.1:8787");
|
||||
let data_dir = std::env::var("LABDECK_DATA").unwrap_or_else(|_| "./data".into());
|
||||
std::fs::create_dir_all(&data_dir).expect("cannot create data dir");
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let _ = std::fs::set_permissions(&data_dir, std::fs::Permissions::from_mode(0o700));
|
||||
}
|
||||
let db_path = format!("{}/labdeck.db", data_dir.trim_end_matches('/'));
|
||||
let conn = db::open(&db_path).expect("cannot open database");
|
||||
|
||||
let app = Arc::new(App {
|
||||
db: Mutex::new(conn),
|
||||
vault_key: RwLock::new(None),
|
||||
sessions: Mutex::new(HashMap::new()),
|
||||
tickets: Mutex::new(HashMap::new()),
|
||||
live: RwLock::new(HashMap::new()),
|
||||
login_guard: Mutex::new(auth::LoginGuard::default()),
|
||||
http_strict: build_client(false),
|
||||
http_insecure: build_client(true),
|
||||
wake: tokio::sync::Notify::new(),
|
||||
started: db::now(),
|
||||
});
|
||||
|
||||
let router = api::router(app.clone());
|
||||
let listener = tokio::net::TcpListener::bind(addr)
|
||||
.await
|
||||
.unwrap_or_else(|e| panic!("cannot bind {addr}: {e}"));
|
||||
|
||||
// start background work only once we are reachable (self-checks, alerts)
|
||||
tokio::spawn(health::scheduler(app.clone()));
|
||||
tokio::spawn(auth::janitor(app.clone()));
|
||||
|
||||
eprintln!(
|
||||
"labdeck {} listening on http://{} (data: {})",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
addr,
|
||||
db_path
|
||||
);
|
||||
if !addr.ip().is_loopback() {
|
||||
eprintln!("note: not bound to loopback - put this behind HTTPS (reverse proxy / tunnel).");
|
||||
}
|
||||
|
||||
axum::serve(listener, router)
|
||||
.with_graceful_shutdown(async {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
eprintln!("shutting down");
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
})
|
||||
.await
|
||||
.expect("server error");
|
||||
}
|
||||
+259
@@ -0,0 +1,259 @@
|
||||
//! WebSocket <-> SSH PTY bridge.
|
||||
//!
|
||||
//! Browser -> server: binary frames = keystrokes, text frames = JSON control ({"t":"resize","cols":..,"rows":..}).
|
||||
//! Server -> browser: binary frames = terminal output, text frames = JSON status ({"t":"info|error|closed","msg":..}).
|
||||
|
||||
use crate::{App, auth, db, vault};
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{
|
||||
Path, Query, State,
|
||||
ws::{Message, WebSocket, WebSocketUpgrade},
|
||||
},
|
||||
http::{HeaderMap, StatusCode},
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use russh::keys::{HashAlg, PrivateKeyWithHashAlg, PublicKeyOrCertificate, decode_secret_key};
|
||||
use russh::{ChannelMsg, client};
|
||||
use serde::Deserialize;
|
||||
use std::sync::{
|
||||
Arc, Mutex,
|
||||
atomic::{AtomicBool, Ordering},
|
||||
};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TermQ {
|
||||
ticket: String,
|
||||
cols: Option<u32>,
|
||||
rows: Option<u32>,
|
||||
}
|
||||
|
||||
pub async fn ws_term(
|
||||
State(app): State<Arc<App>>,
|
||||
Path(id): Path<i64>,
|
||||
Query(q): Query<TermQ>,
|
||||
headers: HeaderMap,
|
||||
ws: WebSocketUpgrade,
|
||||
) -> Response {
|
||||
if !auth::session_valid(&app, &headers) || !auth::ticket_redeem(&app, &q.ticket) {
|
||||
return StatusCode::UNAUTHORIZED.into_response();
|
||||
}
|
||||
let cols = q.cols.unwrap_or(80).clamp(10, 500);
|
||||
let rows = q.rows.unwrap_or(24).clamp(2, 200);
|
||||
ws.max_message_size(1 << 20)
|
||||
.on_upgrade(move |sock| run(app, id, cols, rows, sock))
|
||||
}
|
||||
|
||||
async fn ctl(sock: &mut WebSocket, kind: &str, msg: &str) {
|
||||
let text = serde_json::json!({ "t": kind, "msg": msg }).to_string();
|
||||
let _ = sock.send(Message::Text(text.into())).await;
|
||||
}
|
||||
|
||||
async fn run(app: Arc<App>, id: i64, cols: u32, rows: u32, mut sock: WebSocket) {
|
||||
match session(&app, id, cols, rows, &mut sock).await {
|
||||
Ok(reason) => ctl(&mut sock, "closed", &reason).await,
|
||||
Err(e) => {
|
||||
{
|
||||
let c = app.db.lock().unwrap();
|
||||
db::event_add(&c, "ssh_fail", &format!("ssh failed (host #{id})"), &e);
|
||||
}
|
||||
ctl(&mut sock, "error", &e).await;
|
||||
}
|
||||
}
|
||||
let _ = sock.send(Message::Close(None)).await;
|
||||
}
|
||||
|
||||
struct Handler {
|
||||
expected: String,
|
||||
seen: Arc<Mutex<String>>,
|
||||
mismatch: Arc<AtomicBool>,
|
||||
}
|
||||
|
||||
impl client::Handler for Handler {
|
||||
type Error = russh::Error;
|
||||
|
||||
async fn check_server_key(&mut self, key: &PublicKeyOrCertificate) -> Result<bool, Self::Error> {
|
||||
let fp = match key {
|
||||
PublicKeyOrCertificate::PublicKey { key, .. } => key.fingerprint(HashAlg::Sha256).to_string(),
|
||||
PublicKeyOrCertificate::Certificate(c) => c.public_key().fingerprint(HashAlg::Sha256).to_string(),
|
||||
};
|
||||
*self.seen.lock().unwrap() = fp.clone();
|
||||
if !self.expected.is_empty() && self.expected != fp {
|
||||
self.mismatch.store(true, Ordering::SeqCst);
|
||||
return Ok(false);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
}
|
||||
|
||||
async fn session(app: &Arc<App>, id: i64, cols: u32, rows: u32, sock: &mut WebSocket) -> Result<String, String> {
|
||||
let (host, blob) = {
|
||||
let c = app.db.lock().unwrap();
|
||||
(db::host_get(&c, id), db::host_secret(&c, id))
|
||||
};
|
||||
let host = host.ok_or("host not found")?;
|
||||
let blob = blob.ok_or("no credentials stored for this host")?;
|
||||
let key = (*app.vault_key.read().unwrap()).ok_or("vault is locked - please log in again")?;
|
||||
let plain = vault::decrypt(&key, &blob).ok_or("cannot decrypt stored credentials")?;
|
||||
let secret: serde_json::Value = serde_json::from_slice(&plain).map_err(|_| "stored credentials are corrupt")?;
|
||||
|
||||
ctl(
|
||||
sock,
|
||||
"info",
|
||||
&format!("connecting to {}@{}:{} ...", host.username, host.address, host.port),
|
||||
)
|
||||
.await;
|
||||
|
||||
let config = Arc::new(client::Config {
|
||||
keepalive_interval: Some(Duration::from_secs(30)),
|
||||
keepalive_max: 4,
|
||||
inactivity_timeout: None,
|
||||
..Default::default()
|
||||
});
|
||||
let seen = Arc::new(Mutex::new(String::new()));
|
||||
let mismatch = Arc::new(AtomicBool::new(false));
|
||||
let handler = Handler {
|
||||
expected: host.host_key.clone(),
|
||||
seen: seen.clone(),
|
||||
mismatch: mismatch.clone(),
|
||||
};
|
||||
|
||||
let connect = tokio::time::timeout(
|
||||
Duration::from_secs(10),
|
||||
client::connect(config, (host.address.as_str(), host.port), handler),
|
||||
)
|
||||
.await;
|
||||
let mut handle = match connect {
|
||||
Err(_) => return Err("connection timed out".into()),
|
||||
Ok(Err(e)) => {
|
||||
if mismatch.load(Ordering::SeqCst) {
|
||||
let c = app.db.lock().unwrap();
|
||||
db::event_add(
|
||||
&c,
|
||||
"hostkey",
|
||||
&format!("HOST KEY CHANGED: {}", host.name),
|
||||
&format!("expected {}, got {}", host.host_key, seen.lock().unwrap()),
|
||||
);
|
||||
return Err(format!(
|
||||
"HOST KEY CHANGED for {}!\r\n expected {}\r\n got {}\r\nPossible man-in-the-middle, or the host was reinstalled.\r\nIf this is expected, use \"forget host key\" in the host settings.",
|
||||
host.name,
|
||||
host.host_key,
|
||||
seen.lock().unwrap()
|
||||
));
|
||||
}
|
||||
return Err(format!("connection failed: {e}"));
|
||||
}
|
||||
Ok(Ok(h)) => h,
|
||||
};
|
||||
|
||||
let fp = seen.lock().unwrap().clone();
|
||||
if host.host_key.is_empty() && !fp.is_empty() {
|
||||
{
|
||||
let c = app.db.lock().unwrap();
|
||||
let _ = c.execute("UPDATE hosts SET host_key=?1 WHERE id=?2", rusqlite::params![fp, id]);
|
||||
}
|
||||
ctl(sock, "info", &format!("first connection - trusting host key {fp}")).await;
|
||||
} else {
|
||||
ctl(sock, "info", &format!("host key {fp} verified")).await;
|
||||
}
|
||||
|
||||
// ---- authentication
|
||||
let authed = if host.auth == "key" {
|
||||
let pem = secret.get("key").and_then(|v| v.as_str()).ok_or("no private key stored")?;
|
||||
let pass = secret.get("passphrase").and_then(|v| v.as_str()).filter(|p| !p.is_empty());
|
||||
let pk = decode_secret_key(pem, pass).map_err(|e| format!("cannot read private key: {e}"))?;
|
||||
let hash = handle
|
||||
.best_supported_rsa_hash()
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.flatten();
|
||||
handle
|
||||
.authenticate_publickey(host.username.clone(), PrivateKeyWithHashAlg::new(Arc::new(pk), hash))
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.success()
|
||||
} else {
|
||||
let pw = secret.get("password").and_then(|v| v.as_str()).ok_or("no password stored")?;
|
||||
handle
|
||||
.authenticate_password(host.username.clone(), pw.to_string())
|
||||
.await
|
||||
.map_err(|e| e.to_string())?
|
||||
.success()
|
||||
};
|
||||
if !authed {
|
||||
return Err("authentication failed (wrong credentials, or the server does not allow this method)".into());
|
||||
}
|
||||
|
||||
// ---- shell
|
||||
let mut ch = handle.channel_open_session().await.map_err(|e| e.to_string())?;
|
||||
ch.request_pty(true, "xterm-256color", cols, rows, 0, 0, &[])
|
||||
.await
|
||||
.map_err(|e| e.to_string())?;
|
||||
ch.request_shell(true).await.map_err(|e| e.to_string())?;
|
||||
if !host.startup.trim().is_empty() {
|
||||
let line = format!("{}\n", host.startup.trim());
|
||||
ch.data_bytes(line.into_bytes()).await.map_err(|e| e.to_string())?;
|
||||
}
|
||||
{
|
||||
let c = app.db.lock().unwrap();
|
||||
let _ = c.execute("UPDATE hosts SET last_connected=?1 WHERE id=?2", rusqlite::params![db::now(), id]);
|
||||
db::event_add(
|
||||
&c,
|
||||
"ssh",
|
||||
&format!("ssh session: {}", host.name),
|
||||
&format!("{}@{}:{}", host.username, host.address, host.port),
|
||||
);
|
||||
}
|
||||
ctl(sock, "ready", "").await;
|
||||
|
||||
// ---- bridge
|
||||
let mut exit_status: Option<u32> = None;
|
||||
let mut ping = tokio::time::interval(Duration::from_secs(25));
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = ch.wait() => {
|
||||
match msg {
|
||||
Some(ChannelMsg::Data { data }) => {
|
||||
if sock.send(Message::Binary(Bytes::copy_from_slice(&data))).await.is_err() { break; }
|
||||
}
|
||||
Some(ChannelMsg::ExtendedData { data, .. }) => {
|
||||
if sock.send(Message::Binary(Bytes::copy_from_slice(&data))).await.is_err() { break; }
|
||||
}
|
||||
Some(ChannelMsg::ExitStatus { exit_status: s }) => { exit_status = Some(s); }
|
||||
Some(ChannelMsg::Eof) | Some(ChannelMsg::Close) | None => break,
|
||||
Some(_) => {}
|
||||
}
|
||||
}
|
||||
inbound = sock.recv() => {
|
||||
match inbound {
|
||||
Some(Ok(Message::Binary(b))) => {
|
||||
if ch.data_bytes(b).await.is_err() { break; }
|
||||
}
|
||||
Some(Ok(Message::Text(t))) => {
|
||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(t.as_str()) {
|
||||
if v.get("t").and_then(|x| x.as_str()) == Some("resize") {
|
||||
let c = v.get("cols").and_then(|x| x.as_u64()).unwrap_or(80).clamp(10, 500) as u32;
|
||||
let r = v.get("rows").and_then(|x| x.as_u64()).unwrap_or(24).clamp(2, 200) as u32;
|
||||
let _ = ch.window_change(c, r, 0, 0).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None | Some(Err(_)) => break,
|
||||
Some(Ok(_)) => {}
|
||||
}
|
||||
}
|
||||
_ = ping.tick() => {
|
||||
if sock.send(Message::Ping(Bytes::new())).await.is_err() { break; }
|
||||
}
|
||||
}
|
||||
}
|
||||
let _ = ch.close().await;
|
||||
let _ = handle
|
||||
.disconnect(russh::Disconnect::ByApplication, "", "en")
|
||||
.await;
|
||||
Ok(match exit_status {
|
||||
Some(0) | None => "session closed".to_string(),
|
||||
Some(n) => format!("session closed (exit status {n})"),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Credential vault: Argon2id key derivation + ChaCha20-Poly1305 encryption.
|
||||
//! Blob layout: nonce (12 bytes) || ciphertext+tag.
|
||||
|
||||
use argon2::{Algorithm, Argon2, Params, Version};
|
||||
use chacha20poly1305::{
|
||||
ChaCha20Poly1305, Key, KeyInit, Nonce,
|
||||
aead::Aead,
|
||||
};
|
||||
|
||||
pub type VaultKey = [u8; 32];
|
||||
|
||||
const VERIFIER_PLAIN: &[u8] = b"labdeck-vault-v1";
|
||||
|
||||
pub fn new_salt() -> [u8; 16] {
|
||||
let mut s = [0u8; 16];
|
||||
rand::fill(&mut s);
|
||||
s
|
||||
}
|
||||
|
||||
pub fn derive(password: &str, salt: &[u8]) -> Result<VaultKey, String> {
|
||||
// 64 MiB, 3 passes, 1 lane: sensible for an interactive login on a homelab box.
|
||||
let params = Params::new(64 * 1024, 3, 1, Some(32)).map_err(|e| e.to_string())?;
|
||||
let argon = Argon2::new(Algorithm::Argon2id, Version::V0x13, params);
|
||||
let mut out = [0u8; 32];
|
||||
argon
|
||||
.hash_password_into(password.as_bytes(), salt, &mut out)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn encrypt(key: &VaultKey, plain: &[u8]) -> Vec<u8> {
|
||||
let cipher = ChaCha20Poly1305::new(&Key::from(*key));
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
rand::fill(&mut nonce_bytes);
|
||||
let nonce = Nonce::from(nonce_bytes);
|
||||
let ct = cipher.encrypt(&nonce, plain).expect("encryption failed");
|
||||
let mut out = Vec::with_capacity(12 + ct.len());
|
||||
out.extend_from_slice(&nonce_bytes);
|
||||
out.extend_from_slice(&ct);
|
||||
out
|
||||
}
|
||||
|
||||
pub fn decrypt(key: &VaultKey, blob: &[u8]) -> Option<Vec<u8>> {
|
||||
if blob.len() < 12 + 16 {
|
||||
return None;
|
||||
}
|
||||
let cipher = ChaCha20Poly1305::new(&Key::from(*key));
|
||||
let mut nonce_bytes = [0u8; 12];
|
||||
nonce_bytes.copy_from_slice(&blob[..12]);
|
||||
let nonce = Nonce::from(nonce_bytes);
|
||||
cipher.decrypt(&nonce, &blob[12..]).ok()
|
||||
}
|
||||
|
||||
pub fn make_verifier(key: &VaultKey) -> Vec<u8> {
|
||||
encrypt(key, VERIFIER_PLAIN)
|
||||
}
|
||||
|
||||
pub fn check_verifier(key: &VaultKey, blob: &[u8]) -> bool {
|
||||
decrypt(key, blob).map(|p| p == VERIFIER_PLAIN).unwrap_or(false)
|
||||
}
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
use axum::{
|
||||
extract::Request,
|
||||
http::{HeaderValue, StatusCode, Uri, header},
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
|
||||
struct Asset {
|
||||
path: &'static str,
|
||||
mime: &'static str,
|
||||
bytes: &'static [u8],
|
||||
long_cache: bool,
|
||||
}
|
||||
|
||||
static ASSETS: &[Asset] = &[
|
||||
Asset { path: "/", mime: "text/html; charset=utf-8", bytes: include_bytes!("../web/index.html"), long_cache: false },
|
||||
Asset { path: "/static/app.js", mime: "text/javascript; charset=utf-8", bytes: include_bytes!("../web/app.js"), long_cache: false },
|
||||
Asset { path: "/static/style.css", mime: "text/css; charset=utf-8", bytes: include_bytes!("../web/style.css"), long_cache: false },
|
||||
Asset { path: "/static/favicon.svg", mime: "image/svg+xml", bytes: include_bytes!("../web/favicon.svg"), long_cache: true },
|
||||
Asset { path: "/static/vendor/xterm.js", mime: "text/javascript; charset=utf-8", bytes: include_bytes!("../web/vendor/xterm.js"), long_cache: true },
|
||||
Asset { path: "/static/vendor/xterm.css", mime: "text/css; charset=utf-8", bytes: include_bytes!("../web/vendor/xterm.css"), long_cache: true },
|
||||
Asset { path: "/static/vendor/addon-fit.js", mime: "text/javascript; charset=utf-8", bytes: include_bytes!("../web/vendor/addon-fit.js"), long_cache: true },
|
||||
Asset { path: "/static/vendor/addon-web-links.js", mime: "text/javascript; charset=utf-8", bytes: include_bytes!("../web/vendor/addon-web-links.js"), long_cache: true },
|
||||
];
|
||||
|
||||
pub async fn serve(uri: Uri) -> Response {
|
||||
let path = uri.path();
|
||||
match ASSETS.iter().find(|a| a.path == path) {
|
||||
Some(a) => (
|
||||
[
|
||||
(header::CONTENT_TYPE, a.mime),
|
||||
(header::CACHE_CONTROL, if a.long_cache { "public, max-age=86400" } else { "no-cache" }),
|
||||
],
|
||||
a.bytes,
|
||||
)
|
||||
.into_response(),
|
||||
None => (StatusCode::NOT_FOUND, "not found").into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn security_headers(req: Request, next: Next) -> Response {
|
||||
let mut resp = next.run(req).await;
|
||||
let h = resp.headers_mut();
|
||||
h.insert(
|
||||
header::CONTENT_SECURITY_POLICY,
|
||||
HeaderValue::from_static(
|
||||
"default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; connect-src 'self' ws: wss:; frame-ancestors 'none'; base-uri 'none'; form-action 'self'",
|
||||
),
|
||||
);
|
||||
h.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff"));
|
||||
h.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY"));
|
||||
h.insert(header::REFERRER_POLICY, HeaderValue::from_static("no-referrer"));
|
||||
if !h.contains_key(header::CACHE_CONTROL) {
|
||||
h.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
|
||||
}
|
||||
resp
|
||||
}
|
||||
+1018
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="5" fill="#0a0d0a"/><path d="M7 10l7 6-7 6" fill="none" stroke="#39ff7a" stroke-width="3" stroke-linecap="round" stroke-linejoin="round"/><rect x="17" y="21" width="9" height="3" rx="1" fill="#39ff7a"/></svg>
|
||||
|
After Width: | Height: | Size: 301 B |
@@ -0,0 +1,21 @@
|
||||
<!doctype html>
|
||||
<html lang="en" data-theme="slate">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="dark light">
|
||||
<title>labdeck</title>
|
||||
<link rel="icon" href="/static/favicon.svg" type="image/svg+xml">
|
||||
<link rel="stylesheet" href="/static/vendor/xterm.css">
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<div id="toasts" aria-live="polite"></div>
|
||||
<noscript>labdeck needs JavaScript.</noscript>
|
||||
<script src="/static/vendor/xterm.js"></script>
|
||||
<script src="/static/vendor/addon-fit.js"></script>
|
||||
<script src="/static/vendor/addon-web-links.js"></script>
|
||||
<script src="/static/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
+250
@@ -0,0 +1,250 @@
|
||||
:root {
|
||||
--mono: "JetBrains Mono", "IBM Plex Mono", "Cascadia Code", "SF Mono", ui-monospace, Menlo, Consolas, "Liberation Mono", monospace;
|
||||
--bg: #0d1317;
|
||||
--bg2: #121a20;
|
||||
--bg3: #19232b;
|
||||
--line: #25323b;
|
||||
--fg: #cdd6d2;
|
||||
--dim: #7d8d92;
|
||||
--accent: #ff9e3d;
|
||||
--accent-ink: #1a0f02;
|
||||
--up: #62d6a4;
|
||||
--down: #ff6b6b;
|
||||
--warn: #f2c14e;
|
||||
--blue: #6fb1ff;
|
||||
--mag: #c79bff;
|
||||
--cyan: #5fd0e0;
|
||||
--sel: #1d2a33;
|
||||
--sel2: #34495a;
|
||||
--radius: 3px;
|
||||
color-scheme: dark;
|
||||
}
|
||||
:root[data-theme="amber"] {
|
||||
--bg: #120d05; --bg2: #19120a; --bg3: #221910; --line: #3a2c18;
|
||||
--fg: #ecd5a3; --dim: #8f7a4f; --accent: #ffb000; --accent-ink: #1a1000;
|
||||
--up: #b5d96b; --down: #ff7043; --warn: #ffd166; --sel: #2a1f10; --sel2: #5a4624;
|
||||
}
|
||||
:root[data-theme="green"] {
|
||||
--bg: #070b08; --bg2: #0c120d; --bg3: #121b14; --line: #1d3022;
|
||||
--fg: #bfe8c9; --dim: #5f8a69; --accent: #4dff8f; --accent-ink: #02140a;
|
||||
--up: #4dff8f; --down: #ff5f6d; --warn: #e8d44d; --sel: #14241a; --sel2: #2b5a3a;
|
||||
}
|
||||
:root[data-theme="paper"] {
|
||||
--bg: #eef1f0; --bg2: #e4e9e7; --bg3: #d9dfdc; --line: #c3ccc8;
|
||||
--fg: #1c2528; --dim: #66757a; --accent: #b4540a; --accent-ink: #fff7ef;
|
||||
--up: #1d7f5a; --down: #c0392b; --warn: #9a6b00; --blue: #1f5fae; --mag: #7a3fb0; --cyan: #137a88;
|
||||
--sel: #d3dbd8; --sel2: #aebcc4; color-scheme: light;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
margin: 0; background: var(--bg); color: var(--fg);
|
||||
font: 14px/1.5 var(--mono); font-variant-ligatures: none;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
::selection { background: var(--accent); color: var(--accent-ink); }
|
||||
a { color: var(--accent); text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
button, input, select, textarea { font: inherit; color: inherit; }
|
||||
:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
|
||||
.dim { color: var(--dim); }
|
||||
.up { color: var(--up); } .down { color: var(--down); } .warn { color: var(--warn); }
|
||||
.hidden { display: none !important; }
|
||||
.right { text-align: right; }
|
||||
.trunc { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||
|
||||
/* ---------- shell ---------- */
|
||||
#app { display: flex; flex-direction: column; height: 100vh; height: 100dvh; }
|
||||
header.top {
|
||||
display: flex; align-items: center; gap: 18px; padding: 0 20px; height: 44px; flex: none;
|
||||
border-bottom: 1px solid var(--line); background: var(--bg);
|
||||
}
|
||||
.brand { font-weight: 700; color: var(--fg); letter-spacing: 0.02em; white-space: nowrap; }
|
||||
.brand b { color: var(--accent); }
|
||||
nav.tabs { display: flex; gap: 2px; flex: 1; overflow-x: auto; scrollbar-width: none; }
|
||||
nav.tabs button {
|
||||
background: none; border: 0; padding: 0 12px; height: 44px; color: var(--dim); cursor: pointer; white-space: nowrap;
|
||||
border-bottom: 2px solid transparent; margin-bottom: -1px;
|
||||
}
|
||||
nav.tabs button:hover { color: var(--fg); }
|
||||
nav.tabs button.on { color: var(--fg); border-bottom-color: var(--accent); }
|
||||
nav.tabs kbd { color: var(--dim); font-size: 11px; margin-right: 6px; }
|
||||
nav.tabs .count { color: var(--accent); margin-left: 6px; }
|
||||
.top .tools { display: flex; gap: 6px; align-items: center; }
|
||||
main { flex: 1; min-height: 0; position: relative; display: flex; flex-direction: column; }
|
||||
#page { flex: 1; overflow: auto; padding: 20px; }
|
||||
#termview { flex: 1; min-height: 0; display: none; flex-direction: column; }
|
||||
#termview.show { display: flex; }
|
||||
#page.hidden { display: none; }
|
||||
footer.status {
|
||||
display: flex; gap: 20px; align-items: center; padding: 0 20px; height: 28px; flex: none; font-size: 12px;
|
||||
border-top: 1px solid var(--line); background: var(--bg2); color: var(--dim); white-space: nowrap; overflow: hidden;
|
||||
}
|
||||
footer.status .grow { flex: 1; }
|
||||
|
||||
/* ---------- controls ---------- */
|
||||
.btn {
|
||||
background: var(--bg3); border: 1px solid var(--line); border-radius: var(--radius); padding: 3px 12px; cursor: pointer;
|
||||
color: var(--fg); line-height: 1.5; white-space: nowrap;
|
||||
}
|
||||
.btn:hover { border-color: var(--dim); }
|
||||
.btn:active { transform: translateY(1px); }
|
||||
.btn.primary { background: var(--accent); color: var(--accent-ink); border-color: var(--accent); font-weight: 700; }
|
||||
.btn.primary:hover { filter: brightness(1.08); }
|
||||
.btn.danger { color: var(--down); }
|
||||
.btn.danger:hover { border-color: var(--down); }
|
||||
.btn.ghost { background: none; border-color: transparent; color: var(--dim); }
|
||||
.btn.ghost:hover { color: var(--fg); border-color: var(--line); }
|
||||
.btn.sm { padding: 0 8px; font-size: 12px; }
|
||||
.btn[disabled] { opacity: 0.5; cursor: default; }
|
||||
input[type=text], input[type=password], input[type=number], input[type=search], select, textarea {
|
||||
background: var(--bg2); border: 1px solid var(--line); border-radius: var(--radius); padding: 4px 8px; width: 100%;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus { border-color: var(--accent); outline: none; }
|
||||
textarea { resize: vertical; min-height: 64px; }
|
||||
input[type=checkbox] { accent-color: var(--accent); width: 16px; height: 16px; vertical-align: middle; }
|
||||
select { appearance: none; background-image: linear-gradient(45deg, transparent 50%, var(--dim) 50%), linear-gradient(135deg, var(--dim) 50%, transparent 50%);
|
||||
background-position: calc(100% - 14px) 50%, calc(100% - 9px) 50%; background-size: 5px 5px; background-repeat: no-repeat; padding-right: 26px; }
|
||||
|
||||
/* ---------- page heads ---------- */
|
||||
.prompt { display: flex; align-items: baseline; gap: 10px; flex-wrap: wrap; margin-bottom: 4px; }
|
||||
.prompt .ps1 { color: var(--accent); }
|
||||
.prompt .ps1 i { color: var(--dim); font-style: normal; }
|
||||
.cursor { display: inline-block; width: 0.6em; height: 1.05em; background: var(--accent); vertical-align: text-bottom; animation: blink 1.1s steps(1) infinite; }
|
||||
@keyframes blink { 50% { opacity: 0; } }
|
||||
.summary { display: flex; gap: 22px; flex-wrap: wrap; margin: 6px 0 16px; }
|
||||
.toolbar { display: flex; gap: 8px; align-items: center; margin-bottom: 16px; flex-wrap: wrap; }
|
||||
.toolbar input[type=search] { max-width: 280px; }
|
||||
.toolbar .grow { flex: 1; }
|
||||
|
||||
/* ---------- dashboard ---------- */
|
||||
.group { margin-bottom: 22px; }
|
||||
.group h2 { font-size: 14px; font-weight: 400; margin: 0 0 4px; color: var(--dim); display: flex; gap: 10px; align-items: center; }
|
||||
.group h2::before { content: "┌─"; color: var(--line); }
|
||||
.group h2::after { content: ""; flex: 1; border-top: 1px solid var(--line); }
|
||||
.svc {
|
||||
display: grid; align-items: center; column-gap: 14px; padding: 5px 8px; border-radius: var(--radius); cursor: pointer;
|
||||
grid-template-columns: 1.5ch minmax(12ch, 1.1fr) minmax(0, 1.6fr) 8ch 8ch 31ch 13ch 188px;
|
||||
}
|
||||
.svc:hover { background: var(--sel); }
|
||||
.svc.open { background: var(--sel); }
|
||||
.svc .led { color: var(--dim); font-size: 12px; }
|
||||
.svc.s-up .led { color: var(--up); }
|
||||
.svc.s-down .led { color: var(--down); animation: pulse 2.2s ease-in-out infinite; }
|
||||
.svc.s-retry .led { color: var(--warn); }
|
||||
.svc.s-paused { opacity: 0.55; }
|
||||
@keyframes pulse { 50% { opacity: 0.35; } }
|
||||
.svc .name { font-weight: 700; }
|
||||
.svc .addr { color: var(--dim); }
|
||||
.svc .spark { letter-spacing: 0; white-space: pre; overflow: hidden; }
|
||||
.svc .spark .x { color: var(--down); }
|
||||
.svc .spark .ok { color: var(--up); }
|
||||
.svc .acts { display: flex; gap: 4px; opacity: 0; justify-content: flex-end; }
|
||||
.svc:hover .acts, .svc.open .acts, .svc:focus-within .acts { opacity: 1; }
|
||||
.detail { padding: 6px 8px 14px 3.4ch; background: var(--sel); border-radius: 0 0 var(--radius) var(--radius); margin: -2px 0 6px; }
|
||||
.detail dl { display: grid; grid-template-columns: max-content 1fr; gap: 2px 18px; margin: 0 0 10px; }
|
||||
.detail dt { color: var(--dim); }
|
||||
.detail dd { margin: 0; overflow-wrap: anywhere; }
|
||||
.strip { display: flex; gap: 1px; height: 20px; align-items: stretch; margin: 6px 0 2px; }
|
||||
.strip i { flex: 1; background: var(--line); min-width: 2px; }
|
||||
.strip i.g { background: var(--up); } .strip i.y { background: var(--warn); } .strip i.r { background: var(--down); }
|
||||
.strip-legend { display: flex; justify-content: space-between; color: var(--dim); font-size: 12px; }
|
||||
.empty { border: 1px dashed var(--line); border-radius: var(--radius); padding: 28px; text-align: center; color: var(--dim); }
|
||||
.empty p { margin: 4px 0 14px; }
|
||||
|
||||
/* ---------- tables ---------- */
|
||||
table.t { width: 100%; border-collapse: collapse; }
|
||||
table.t th { text-align: left; font-weight: 400; color: var(--dim); padding: 4px 10px; border-bottom: 1px solid var(--line); white-space: nowrap; }
|
||||
table.t td { padding: 5px 10px; border-bottom: 1px solid var(--line); vertical-align: top; }
|
||||
table.t tr.row:hover { background: var(--sel); }
|
||||
table.t td.acts { white-space: nowrap; text-align: right; }
|
||||
.chip { display: inline-block; padding: 0 7px; border: 1px solid var(--line); border-radius: 99px; font-size: 12px; color: var(--dim); }
|
||||
.chip.down, .chip.hostkey, .chip.ssh_fail, .chip.login_fail { color: var(--down); border-color: var(--down); }
|
||||
.chip.up { color: var(--up); border-color: var(--up); }
|
||||
.chip.tls { color: var(--warn); border-color: var(--warn); }
|
||||
.chip.ssh { color: var(--cyan); border-color: var(--cyan); }
|
||||
|
||||
/* ---------- terminals ---------- */
|
||||
.termbar { display: flex; align-items: stretch; gap: 0; border-bottom: 1px solid var(--line); background: var(--bg2); flex: none; overflow-x: auto; scrollbar-width: none; }
|
||||
.termbar .tab { display: flex; align-items: center; gap: 8px; padding: 0 10px 0 14px; height: 34px; cursor: pointer; color: var(--dim); border-right: 1px solid var(--line); white-space: nowrap; }
|
||||
.termbar .tab.on { background: var(--bg); color: var(--fg); box-shadow: inset 0 -2px 0 var(--accent); }
|
||||
.termbar .tab .dot { font-size: 11px; }
|
||||
.termbar .tab .dot.connected { color: var(--up); } .termbar .tab .dot.connecting { color: var(--warn); } .termbar .tab .dot.closed, .termbar .tab .dot.error { color: var(--down); }
|
||||
.termbar .tab button { background: none; border: 0; cursor: pointer; color: var(--dim); padding: 0 4px; border-radius: 2px; }
|
||||
.termbar .tab button:hover { color: var(--fg); background: var(--bg3); }
|
||||
.termbar .spacer { flex: 1; }
|
||||
.termbar .tools { display: flex; align-items: center; gap: 6px; padding: 0 10px; }
|
||||
.panes { flex: 1; min-height: 0; position: relative; background: var(--bg); }
|
||||
.pane { position: absolute; inset: 0; padding: 8px 0 4px 12px; display: none; }
|
||||
.pane.on { display: block; }
|
||||
.pane .xterm { height: 100%; }
|
||||
.pane .xterm-viewport { background: transparent !important; }
|
||||
.term-empty { padding: 24px; color: var(--dim); }
|
||||
.term-empty .hostlist { margin-top: 14px; display: flex; flex-direction: column; gap: 2px; max-width: 520px; }
|
||||
.term-empty .hostlist button { text-align: left; background: none; border: 0; padding: 4px 8px; cursor: pointer; border-radius: var(--radius); }
|
||||
.term-empty .hostlist button:hover { background: var(--sel); }
|
||||
|
||||
/* ---------- login ---------- */
|
||||
.login { max-width: 560px; margin: 12vh auto 0; padding: 0 20px; }
|
||||
.login h1 { font-size: 14px; margin: 0 0 18px; font-weight: 700; }
|
||||
.login h1 b { color: var(--accent); }
|
||||
.login p { color: var(--dim); margin: 0 0 18px; }
|
||||
.login label { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
|
||||
.login label span { width: 18ch; color: var(--dim); flex: none; }
|
||||
.login .err { color: var(--down); min-height: 1.5em; margin: 6px 0; }
|
||||
|
||||
/* ---------- modal / palette / toast ---------- */
|
||||
.overlay { position: fixed; inset: 0; background: rgba(4, 8, 10, 0.66); display: flex; align-items: flex-start; justify-content: center; padding: 8vh 16px 16px; z-index: 50; overflow: auto; }
|
||||
:root[data-theme="paper"] .overlay { background: rgba(30, 40, 44, 0.4); }
|
||||
.modal { background: var(--bg); border: 1px solid var(--dim); border-radius: var(--radius); width: 100%; max-width: 640px; box-shadow: 0 18px 60px rgba(0, 0, 0, 0.5); }
|
||||
.modal header { padding: 10px 16px; border-bottom: 1px solid var(--line); display: flex; justify-content: space-between; align-items: center; }
|
||||
.modal header h3 { margin: 0; font-size: 14px; font-weight: 700; }
|
||||
.modal .body { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
|
||||
.modal footer { padding: 10px 16px; border-top: 1px solid var(--line); display: flex; gap: 8px; justify-content: flex-end; flex-wrap: wrap; }
|
||||
.modal footer .grow { flex: 1; display: flex; gap: 8px; }
|
||||
.field { display: flex; flex-direction: column; gap: 3px; }
|
||||
.field > label { color: var(--dim); }
|
||||
.field .hint { color: var(--dim); font-size: 12px; }
|
||||
.field.check { flex-direction: row; align-items: center; gap: 10px; }
|
||||
.field.check label { color: var(--fg); }
|
||||
.grid2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
|
||||
.form-error { color: var(--down); min-height: 1.4em; }
|
||||
.palette { background: var(--bg); border: 1px solid var(--dim); border-radius: var(--radius); width: 100%; max-width: 620px; box-shadow: 0 18px 60px rgba(0, 0, 0, 0.5); }
|
||||
.palette input { border: 0; border-bottom: 1px solid var(--line); border-radius: 0; padding: 10px 16px; background: transparent; }
|
||||
.palette ul { list-style: none; margin: 0; padding: 4px; max-height: 50vh; overflow: auto; }
|
||||
.palette li { display: flex; justify-content: space-between; gap: 16px; padding: 5px 12px; border-radius: var(--radius); cursor: pointer; }
|
||||
.palette li.on { background: var(--sel2); }
|
||||
.palette li .hint { color: var(--dim); }
|
||||
.palette .none { padding: 12px 16px; color: var(--dim); }
|
||||
#toasts { position: fixed; right: 16px; bottom: 44px; display: flex; flex-direction: column; gap: 8px; z-index: 80; max-width: min(420px, calc(100vw - 32px)); }
|
||||
.toast { background: var(--bg3); border: 1px solid var(--line); border-left: 3px solid var(--accent); padding: 8px 12px; border-radius: var(--radius); box-shadow: 0 6px 24px rgba(0, 0, 0, 0.4); animation: tin 0.16s ease-out; }
|
||||
.toast.err { border-left-color: var(--down); }
|
||||
@keyframes tin { from { transform: translateY(6px); opacity: 0; } }
|
||||
|
||||
.section { margin-bottom: 30px; max-width: 760px; }
|
||||
.section h2 { font-size: 14px; margin: 0 0 8px; font-weight: 700; }
|
||||
.section h2::before { content: "# "; color: var(--accent); }
|
||||
.section p { color: var(--dim); margin: 0 0 10px; }
|
||||
.row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 8px; }
|
||||
.row > .grow { flex: 1; min-width: 180px; }
|
||||
.themes { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
.themes button { display: flex; align-items: center; gap: 8px; }
|
||||
.themes .sw { width: 12px; height: 12px; border-radius: 50%; border: 1px solid var(--line); }
|
||||
|
||||
/* ---------- small screens ---------- */
|
||||
@media (max-width: 900px) {
|
||||
.svc { grid-template-columns: 1.5ch 1fr 8ch 8ch 150px; row-gap: 0; }
|
||||
.svc .addr, .svc .spark, .svc .since { display: none; }
|
||||
#page { padding: 14px; }
|
||||
header.top { padding: 0 12px; gap: 10px; }
|
||||
.grid2 { grid-template-columns: 1fr; }
|
||||
nav.tabs kbd { display: none; }
|
||||
.login label { flex-direction: column; align-items: stretch; gap: 3px; }
|
||||
.login label span { width: auto; }
|
||||
.svc .acts { opacity: 1; }
|
||||
footer.status .hide-s { display: none; }
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.cursor, .svc.s-down .led, .toast { animation: none; }
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.FitAddon=t():e.FitAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={};return(()=>{var t=e;Object.defineProperty(t,"__esModule",{value:!0}),t.FitAddon=void 0,t.FitAddon=class{activate(e){this._terminal=e}dispose(){}fit(){const e=this.proposeDimensions();if(!e||!this._terminal||isNaN(e.cols)||isNaN(e.rows))return;const t=this._terminal._core;this._terminal.rows===e.rows&&this._terminal.cols===e.cols||(t._renderService.clear(),this._terminal.resize(e.cols,e.rows))}proposeDimensions(){if(!this._terminal)return;if(!this._terminal.element||!this._terminal.element.parentElement)return;const e=this._terminal._core._renderService.dimensions;if(0===e.css.cell.width||0===e.css.cell.height)return;const t=0===this._terminal.options.scrollback?0:this._terminal.options.overviewRuler?.width||14,r=window.getComputedStyle(this._terminal.element.parentElement),i=parseInt(r.getPropertyValue("height")),o=Math.max(0,parseInt(r.getPropertyValue("width"))),s=window.getComputedStyle(this._terminal.element),n=i-(parseInt(s.getPropertyValue("padding-top"))+parseInt(s.getPropertyValue("padding-bottom"))),l=o-(parseInt(s.getPropertyValue("padding-right"))+parseInt(s.getPropertyValue("padding-left")))-t;return{cols:Math.max(2,Math.floor(l/e.css.cell.width)),rows:Math.max(1,Math.floor(n/e.css.cell.height))}}}})(),e})()));
|
||||
//# sourceMappingURL=addon-fit.js.map
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
!function(e,t){"object"==typeof exports&&"object"==typeof module?module.exports=t():"function"==typeof define&&define.amd?define([],t):"object"==typeof exports?exports.WebLinksAddon=t():e.WebLinksAddon=t()}(globalThis,(()=>(()=>{"use strict";var e={490:(e,t)=>{function n(e){try{const t=new URL(e),n=t.password&&t.username?`${t.protocol}//${t.username}:${t.password}@${t.host}`:t.username?`${t.protocol}//${t.username}@${t.host}`:`${t.protocol}//${t.host}`;return e.toLocaleLowerCase().startsWith(n.toLocaleLowerCase())}catch(e){return!1}}Object.defineProperty(t,"__esModule",{value:!0}),t.LinkComputer=t.WebLinkProvider=void 0,t.WebLinkProvider=class{constructor(e,t,n,o={}){this._terminal=e,this._regex=t,this._handler=n,this._options=o}provideLinks(e,t){const n=o.computeLink(e,this._regex,this._terminal,this._handler);t(this._addCallbacks(n))}_addCallbacks(e){return e.map((e=>(e.leave=this._options.leave,e.hover=(t,n)=>{if(this._options.hover){const{range:o}=e;this._options.hover(t,n,o)}},e)))}};class o{static computeLink(e,t,r,i){const s=new RegExp(t.source,(t.flags||"")+"g"),[a,c]=o._getWindowedLineStrings(e-1,r),l=a.join("");let d;const p=[];for(;d=s.exec(l);){const e=d[0];if(!n(e))continue;const[t,s]=o._mapStrIdx(r,c,0,d.index),[a,l]=o._mapStrIdx(r,t,s,e.length);if(-1===t||-1===s||-1===a||-1===l)continue;const h={start:{x:s+1,y:t+1},end:{x:l,y:a+1}};p.push({range:h,text:e,activate:i})}return p}static _getWindowedLineStrings(e,t){let n,o=e,r=e,i=0,s="";const a=[];if(n=t.buffer.active.getLine(e)){const e=n.translateToString(!0);if(n.isWrapped&&" "!==e[0]){for(i=0;(n=t.buffer.active.getLine(--o))&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),n.isWrapped&&-1===s.indexOf(" ")););a.reverse()}for(a.push(e),i=0;(n=t.buffer.active.getLine(++r))&&n.isWrapped&&i<2048&&(s=n.translateToString(!0),i+=s.length,a.push(s),-1===s.indexOf(" ")););}return[a,o]}static _mapStrIdx(e,t,n,o){const r=e.buffer.active,i=r.getNullCell();let s=n;for(;o;){const e=r.getLine(t);if(!e)return[-1,-1];for(let n=s;n<e.length;++n){e.getCell(n,i);const s=i.getChars();if(i.getWidth()&&(o-=s.length||1,n===e.length-1&&""===s)){const e=r.getLine(t+1);e&&e.isWrapped&&(e.getCell(0,i),2===i.getWidth()&&(o+=1))}if(o<0)return[t,n]}t++,s=0}return[t,s]}}t.LinkComputer=o}},t={};function n(o){var r=t[o];if(void 0!==r)return r.exports;var i=t[o]={exports:{}};return e[o](i,i.exports,n),i.exports}var o={};return(()=>{var e=o;Object.defineProperty(e,"__esModule",{value:!0}),e.WebLinksAddon=void 0;const t=n(490),r=/(https?|HTTPS?):[/]{2}[^\s"'!*(){}|\\\^<>`]*[^\s"':,.!?{}|\\\^~\[\]`()<>]/;function i(e,t){const n=window.open();if(n){try{n.opener=null}catch{}n.location.href=t}else console.warn("Opening link blocked as opener could not be cleared")}e.WebLinksAddon=class{constructor(e=i,t={}){this._handler=e,this._options=t}activate(e){this._terminal=e;const n=this._options,o=n.urlRegex||r;this._linkProvider=this._terminal.registerLinkProvider(new t.WebLinkProvider(this._terminal,o,this._handler,n))}dispose(){this._linkProvider?.dispose()}}})(),o})()));
|
||||
//# sourceMappingURL=addon-web-links.js.map
|
||||
Vendored
+285
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Copyright (c) 2014 The xterm.js authors. All rights reserved.
|
||||
* Copyright (c) 2012-2013, Christopher Jeffrey (MIT License)
|
||||
* https://github.com/chjj/term.js
|
||||
* @license MIT
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
* of this software and associated documentation files (the "Software"), to deal
|
||||
* in the Software without restriction, including without limitation the rights
|
||||
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
* copies of the Software, and to permit persons to whom the Software is
|
||||
* furnished to do so, subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in
|
||||
* all copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
||||
* THE SOFTWARE.
|
||||
*
|
||||
* Originally forked from (with the author's permission):
|
||||
* Fabrice Bellard's javascript vt100 for jslinux:
|
||||
* http://bellard.org/jslinux/
|
||||
* Copyright (c) 2011 Fabrice Bellard
|
||||
* The original design remains. The terminal itself
|
||||
* has been extended to include xterm CSI codes, among
|
||||
* other features.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Default styles for xterm.js
|
||||
*/
|
||||
|
||||
.xterm {
|
||||
cursor: text;
|
||||
position: relative;
|
||||
user-select: none;
|
||||
-ms-user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.xterm.focus,
|
||||
.xterm:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-helpers {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
/**
|
||||
* The z-index of the helpers must be higher than the canvases in order for
|
||||
* IMEs to appear on top.
|
||||
*/
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
.xterm .xterm-helper-textarea {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
/* Move textarea out of the screen to the far left, so that the cursor is not visible */
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
left: -9999em;
|
||||
top: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: -5;
|
||||
/** Prevent wrapping so the IME appears against the textarea at the correct position */
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
.xterm .composition-view {
|
||||
/* TODO: Composition position got messed up somewhere */
|
||||
background: #000;
|
||||
color: #FFF;
|
||||
display: none;
|
||||
position: absolute;
|
||||
white-space: nowrap;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.xterm .composition-view.active {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.xterm .xterm-viewport {
|
||||
/* On OS X this is required in order for the scroll bar to appear fully opaque */
|
||||
background-color: #000;
|
||||
overflow-y: scroll;
|
||||
cursor: default;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.xterm .xterm-screen canvas {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.xterm-char-measure-element {
|
||||
display: inline-block;
|
||||
visibility: hidden;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -9999em;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.xterm.enable-mouse-events {
|
||||
/* When mouse events are enabled (eg. tmux), revert to the standard pointer cursor */
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.xterm.xterm-cursor-pointer,
|
||||
.xterm .xterm-cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.xterm.column-select.focus {
|
||||
/* Column selection mode */
|
||||
cursor: crosshair;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility:not(.debug),
|
||||
.xterm .xterm-message {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
color: transparent;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree:not(.debug) *::selection {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree {
|
||||
font-family: monospace;
|
||||
user-select: text;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.xterm .xterm-accessibility-tree > div {
|
||||
transform-origin: left;
|
||||
width: fit-content;
|
||||
}
|
||||
|
||||
.xterm .live-region {
|
||||
position: absolute;
|
||||
left: -9999px;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.xterm-dim {
|
||||
/* Dim should not apply to background, so the opacity of the foreground color is applied
|
||||
* explicitly in the generated class and reset to 1 here */
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.xterm-underline-1 { text-decoration: underline; }
|
||||
.xterm-underline-2 { text-decoration: double underline; }
|
||||
.xterm-underline-3 { text-decoration: wavy underline; }
|
||||
.xterm-underline-4 { text-decoration: dotted underline; }
|
||||
.xterm-underline-5 { text-decoration: dashed underline; }
|
||||
|
||||
.xterm-overline {
|
||||
text-decoration: overline;
|
||||
}
|
||||
|
||||
.xterm-overline.xterm-underline-1 { text-decoration: overline underline; }
|
||||
.xterm-overline.xterm-underline-2 { text-decoration: overline double underline; }
|
||||
.xterm-overline.xterm-underline-3 { text-decoration: overline wavy underline; }
|
||||
.xterm-overline.xterm-underline-4 { text-decoration: overline dotted underline; }
|
||||
.xterm-overline.xterm-underline-5 { text-decoration: overline dashed underline; }
|
||||
|
||||
.xterm-strikethrough {
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration {
|
||||
z-index: 6;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.xterm-screen .xterm-decoration-container .xterm-decoration.xterm-decoration-top-layer {
|
||||
z-index: 7;
|
||||
}
|
||||
|
||||
.xterm-decoration-overview-ruler {
|
||||
z-index: 8;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.xterm-decoration-top {
|
||||
z-index: 2;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/* Derived from vs/base/browser/ui/scrollbar/media/scrollbar.css */
|
||||
|
||||
/* xterm.js customization: Override xterm's cursor style */
|
||||
.xterm .xterm-scrollable-element > .scrollbar {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
/* Arrows */
|
||||
.xterm .xterm-scrollable-element > .scrollbar > .scra {
|
||||
cursor: pointer;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
.xterm .xterm-scrollable-element > .visible {
|
||||
opacity: 1;
|
||||
|
||||
/* Background rule added for IE9 - to allow clicks on dom node */
|
||||
background:rgba(0,0,0,0);
|
||||
|
||||
transition: opacity 100ms linear;
|
||||
/* In front of peek view */
|
||||
z-index: 11;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .invisible {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .invisible.fade {
|
||||
transition: opacity 800ms linear;
|
||||
}
|
||||
|
||||
/* Scrollable Content Inset Shadow */
|
||||
.xterm .xterm-scrollable-element > .shadow {
|
||||
position: absolute;
|
||||
display: none;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .shadow.top {
|
||||
display: block;
|
||||
top: 0;
|
||||
left: 3px;
|
||||
height: 3px;
|
||||
width: 100%;
|
||||
box-shadow: var(--vscode-scrollbar-shadow, #000) 0 6px 6px -6px inset;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .shadow.left {
|
||||
display: block;
|
||||
top: 3px;
|
||||
left: 0;
|
||||
height: 100%;
|
||||
width: 3px;
|
||||
box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .shadow.top-left-corner {
|
||||
display: block;
|
||||
top: 0;
|
||||
left: 0;
|
||||
height: 3px;
|
||||
width: 3px;
|
||||
}
|
||||
.xterm .xterm-scrollable-element > .shadow.top.left {
|
||||
box-shadow: var(--vscode-scrollbar-shadow, #000) 6px 0 6px -6px inset;
|
||||
}
|
||||
Vendored
+2
@@ -0,0 +1,2 @@
|
||||
!function(e,t){if("object"==typeof exports&&"object"==typeof module)module.exports=t();else if("function"==typeof define&&define.amd)define([],t);else{var i=t();for(var s in i)("object"==typeof exports?exports:e)[s]=i[s]}}(globalThis,(()=>(()=>{"use strict";var e={2840:function(e,t,i){var s=this&&this.__decorate||function(e,t,i,s){var r,n=arguments.length,o=n<3?t:null===s?s=Object.getOwnPropertyDescriptor(t,i):s;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)o=Reflect.decorate(e,t,i,s);else for(var a=e.length-1;a>=0;a--)(r=e[a])&&(o=(n<3?r(o):n>3?r(t,i,o):r(t,i))||o);return n>3&&o&&Object.defineProperty(t,i,o),o},r=this&&this.__param||function(e,t){return function(i,s){t(i,s,e)}};Object.defineProperty(t,"__esModule",{value:!0}),t.AccessibilityManager=void 0;const n=i(7721),o=i(4292),a=i(7150),l=i(7098),h=i(6501),c=i(7093);let d=class extends a.Disposable{constructor(e,t,i,s){super(),this._terminal=e,this._coreBrowserService=i,this._renderService=s,this._rowColumns=new WeakMap,this._liveRegionLineCount=0,this._charsToConsume=[],this._charsToAnnounce="";const r=this._coreBrowserService.mainDocument;this._accessibilityContainer=r.createElement("div"),this._accessibilityContainer.classList.add("xterm-accessibility"),this._rowContainer=r.createElement("div"),this._rowContainer.setAttribute("role","list"),this._rowContainer.classList.add("xterm-accessibility-tree"),this._rowElements=[];for(let e=0;e<this._terminal.rows;e++)this._rowElements[e]=this._createAccessibilityTreeNode(),this._rowContainer.appendChild(this._rowElements[e]);if(this._topBoundaryFocusListener=e=>this._handleBoundaryFocus(e,0),this._bottomBoundaryFocusListener=e=>this._handleBoundaryFocus(e,1),this._rowElements[0].addEventListener("focus",this._topBoundaryFocusListener),this._rowElements[this._rowElements.length-1].addEventListener("focus",this._bottomBoundaryFocusListener),this._accessibilityContainer.appendChild(this._rowContainer),this._liveRegion=r.createElement("div"),this._liveRegion.classList.add("live-region"),this._liveRegion.setAttribute("aria-live","assertive"),this._accessibilityContainer.appendChild(this._liveRegion),this._liveRegionDebouncer=this._register(new o.TimeBasedDebouncer(this._renderRows.bind(this))),!this._terminal.element)throw new Error("Cannot enable accessibility before Terminal.open");this._terminal.element.insertAdjacentElement("afterbegin",this._accessibilityContainer),this._register(this._terminal.onResize((e=>this._handleResize(e.rows)))),this._register(this._terminal.onRender((e=>this._refreshRows(e.start,e.end)))),this._register(this._terminal.onScroll((()=>this._refreshRows()))),this._register(this._terminal.onA11yChar((e=>this._handleChar(e)))),this._register(this._terminal.onLineFeed((()=>this._handleChar("\n")))),this._register(this._terminal.onA11yTab((e=>this._handleTab(e)))),this._register(this._terminal.onKey((e=>this._handleKey(e.key)))),this._register(this._terminal.onBlur((()=>this._clearLiveRegion()))),this._register(this._renderService.onDimensionsChange((()=>this._refreshRowsDimensions()))),this._register((0,c.addDisposableListener)(r,"selectionchange",(()=>this._handleSelectionChange()))),this._register(this._coreBrowserService.onDprChange((()=>this._refreshRowsDimensions()))),this._refreshRowsDimensions(),this._refreshRows(),this._register((0,a.toDisposable)((()=>{this._accessibilityContainer.remove(),this._rowElements.length=0})))}_handleTab(e){for(let t=0;t<e;t++)this._handleChar(" ")}_handleChar(e){this._liveRegionLineCount<21&&(this._charsToConsume.length>0?this._charsToConsume.shift()!==e&&(this._charsToAnnounce+=e):this._charsToAnnounce+=e,"\n"===e&&(this._liveRegionLineCount++,21===this._liveRegionLineCount&&(this._liveRegion.textContent+=n.tooMuchOutput.get())))}_clearLiveRegion(){this._liveRegion.textContent="",this._liveRegionLineCount=0}_handleKey(e){this._clearLiveRegion(),/\p{Control}/u.test(e)||this._charsToConsume.push(e)}_refreshRows(e,t){this._liveRegionDebouncer.refresh(e,t,this._terminal.rows)}_renderRows(e,t){const i=this._terminal.buffer,s=i.lines.length.toString();for(let r=e;r<=t;r++){const e=i.lines.get(i.ydisp+r),t=[],n=e?.translateToString(!0,void 0,void 0,t)||"",o=(i.ydisp+r+1).toString(),a=this._rowElements[r];a&&(0===n.length?(a.textContent=" ",this._rowColumns.set(a,[0,1])):(a.textContent=n,this._rowColumns.set(a,t)),a.setAttribute("aria-posinset",o),a.setAttribute("aria-setsize",s),this._alignRowWidth(a))}this._announceCharacters()}_announceCharacters(){0!==this._charsToAnnounce.length&&(this._liveRegion.textContent+=this._charsToAnnounce,this._charsToAnnounce="")}_handleBoundaryFocus(e,t){const i=e.target,s=this._rowElements[0===t?1:this._rowElements.length-2];if(i.getAttribute("aria-posinset")===(0===t?"1":`${this._terminal.buffer.lines.length}`))return;if(e.relatedTarget!==s)return;let r,n;if(0===t?(r=i,n=this._rowElements.pop(),this._rowContainer.removeChild(n)):(r=this._rowElements.shift(),n=i,this._rowContainer.removeChild(r)),r.removeLine truncated
|
||||
//# sourceMappingURL=xterm.js.map
|
||||
Reference in new issue
Block a user