Archival commit, newer version exists, will push

This commit is contained in:
nke4 committed 2026-10-27 11:22:24 +01:00
commit b6094834bb
22 files changed
+7369

No files matched your search

+2
View File
@@ -0,0 +1,2 @@
/target
/data
Generated
+3518
View File
File diff suppressed because it is too large. Load diff
+26
View File
@@ -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
View File
@@ -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"]
+11
View File
@@ -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.
+68
View File
@@ -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
View File
@@ -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
View File
@@ -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]);
}
}
}
+483
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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})"),
})
}
+60
View File
@@ -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
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+1
View File
@@ -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

+21
View File
@@ -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
View File
@@ -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; }
}
+2
View File
@@ -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
+2
View File
@@ -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
+285
View File
@@ -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;
}
+2
View File
@@ -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