Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,11 @@ DASHBOARD_BYPASS_LOGIN_IPS= # IPs that skip the login form (behind a tru
DASHBOARD_ALLOW_CLOUDFLARE_LOGIN=0 # 1 = accept any request carrying Cloudflare headers
DASHBOARD_TRUST_CF_ACCESS_EMAIL=0 # 1 = trust Cf-Access-Authenticated-User-Email
DASHBOARD_ALLOWED_EMAILS= # comma-separated emails allowed via Cloudflare Access
DASHBOARD_SKIP_LOGIN=0 # 1 = skip ShellDeck's own login password and trust the
# network gate above. Only enable when an external layer
# (e.g. Cloudflare Access / Zero Trust) already
# authenticates who can reach the dashboard. The shell
# unlock (second) password is unaffected.

# --- Optional: AI "Current Work" summary ---
# Either run a shell command (gets the context on $SHELLDECK_CONTEXT, prints the summary)…
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ It was built to babysit a fleet of long-running agent sessions from a phone or a
- **Send / Paste / keys** — send input to a pane (with or without Enter), plus Enter / Ctrl-C / Clear, image paste, and command history.
- **Mobile-friendly** — one shell at a time with a sticky tab switcher; Enter sends.
- **Quick links & tickers** — configurable sidebar links (`DASHBOARD_LINKS`) to related services, plus an optional stock/crypto ticker bar (`DASHBOARD_TICKERS`).
- **Machine metrics** — a sidebar widget shows live CPU, RAM, load average, and hardware temperatures (read from `/proc` and `/sys/class/hwmon`) for the host ShellDeck runs on.
- **Locked down** — primary login + a second "unlock" password to gate shell control, optional IP allowlists, and first-class support for sitting behind **Cloudflare Access** (trusts the verified email).

## Requirements
Expand Down Expand Up @@ -56,7 +57,7 @@ ExecStart=%h/shelldeck/target/release/shelldeck
Restart=always
```

Put it behind a reverse proxy / Cloudflare Tunnel for remote access, and ideally a Cloudflare Access policy restricting to your email.
Put it behind a reverse proxy / Cloudflare Tunnel for remote access, and ideally a Cloudflare Access policy restricting to your email. When such an external layer already authenticates who can reach the dashboard, set `DASHBOARD_SKIP_LOGIN=1` to skip ShellDeck's own login password and let Cloudflare Access (or your proxy) be the front door — the shell unlock (second) password still gates shell control.

## Security notes

Expand Down
4 changes: 4 additions & 0 deletions frontend/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -357,3 +357,7 @@ setInterval(() => (window as any).updateLastActivityTimes?.(), 30000);
// Live tickers (if any configured)
queueMicrotask(() => loadTickers().catch(() => {}));
setInterval(() => loadTickers().catch(() => {}), 60000);

// Live host machine stats (CPU / RAM / temps)
queueMicrotask(() => loadMetrics().catch(() => {}));
setInterval(() => loadMetrics().catch(() => {}), 5000);
81 changes: 81 additions & 0 deletions frontend/metrics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
// Live host machine stats (CPU / RAM / temps), polled from /api/metrics.
// Mirrors the CachyOS system-monitor widget: at-a-glance CPU%, RAM, and temperatures.

interface MetricTemp {
label: string;
celsius: number;
}

interface MachineMetrics {
hostname: string;
cpu_pct: number;
cpu_cores: number;
load1: number;
load5: number;
load15: number;
mem_total_kb: number;
mem_used_kb: number;
mem_pct: number;
swap_total_kb: number;
swap_used_kb: number;
uptime_secs: number;
temps: MetricTemp[];
}

function meterLevel(pct: number): string {
return pct >= 90 ? 'crit' : pct >= 70 ? 'warn' : 'ok';
}

function tempLevel(c: number): string {
return c >= 85 ? 'crit' : c >= 70 ? 'warn' : 'ok';
}

function fmtGiB(kb: number): string {
return (kb / 1048576).toFixed(1);
}

function fmtUptime(secs: number): string {
const d = Math.floor(secs / 86400);
const h = Math.floor((secs % 86400) / 3600);
const m = Math.floor((secs % 3600) / 60);
if (d > 0) return `${d}d ${h}h`;
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}

function setMeter(name: string, pct: number, text: string): void {
const bar = document.querySelector<HTMLElement>(`[data-bar="${name}"]`);
const val = document.querySelector<HTMLElement>(`[data-m="${name}"]`);
if (bar) {
bar.style.width = `${Math.max(0, Math.min(100, pct)).toFixed(0)}%`;
bar.className = meterLevel(pct);
}
if (val) val.textContent = text;
}

function renderMetrics(m: MachineMetrics): void {
const host = document.getElementById('metricsHost');
if (host) host.textContent = `${m.hostname} · ${m.cpu_cores} cores · up ${fmtUptime(m.uptime_secs)}`;

setMeter('cpu', m.cpu_pct, `${m.cpu_pct.toFixed(0)}%`);
setMeter('mem', m.mem_pct, `${fmtGiB(m.mem_used_kb)} / ${fmtGiB(m.mem_total_kb)} GiB`);

const load = document.getElementById('metricLoad');
if (load) load.textContent = `load ${m.load1.toFixed(2)} · ${m.load5.toFixed(2)} · ${m.load15.toFixed(2)}`;

const temps = document.getElementById('metricTemps');
if (temps) {
temps.innerHTML = (m.temps || [])
.map((t) => `<span class="temp-chip ${tempLevel(t.celsius)}" title="${escapeHtml(t.label)}">${escapeHtml(t.label)} <b>${t.celsius.toFixed(0)}°</b></span>`)
.join('');
}
}

async function loadMetrics(): Promise<void> {
if (!document.getElementById('metricsPanel')) return;
const response = await fetch('/api/metrics', { cache: 'no-store', credentials: 'same-origin' });
if (!response.ok) return;
renderMetrics(await response.json() as MachineMetrics);
}

(window as any).loadMetrics = loadMetrics;
15 changes: 15 additions & 0 deletions public/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -260,3 +260,18 @@ button:disabled{opacity:.48;cursor:not-allowed}
/* Remove resize handle on mobile (touch has its own resize) */
@media (max-width:760px){.card-resize-handle{display:none}}
.links-menu .ic{color:var(--cyan);flex:0 0 auto}
/* Machine metrics widget (CPU / RAM / temps) — CachyOS-monitor style */
.metrics-panel .panel-header h2{margin-bottom:2px}
.metrics{display:flex;flex-direction:column;gap:10px}
.metric .metric-top{display:flex;justify-content:space-between;font-size:12px;color:var(--muted);margin-bottom:4px}
.metric .metric-top span:last-child{color:var(--text);font-variant-numeric:tabular-nums}
.meter{height:7px;border-radius:5px;background:rgba(139,246,255,.1);overflow:hidden}
.meter i{display:block;height:100%;width:0;border-radius:5px;background:var(--green);transition:width .5s ease,background .3s}
.meter i.warn{background:var(--amber)}
.meter i.crit{background:var(--red)}
.metric-temps{display:flex;flex-wrap:wrap;gap:4px;margin-top:2px}
.temp-chip{font-size:11px;padding:2px 6px;border-radius:6px;background:rgba(139,246,255,.08);border:1px solid rgba(139,246,255,.16);color:var(--muted);white-space:nowrap}
.temp-chip b{color:var(--text);font-variant-numeric:tabular-nums}
.temp-chip.warn{border-color:rgba(255,200,87,.4);color:var(--amber)}
.temp-chip.crit{border-color:rgba(255,106,122,.5);color:var(--red)}
.metric-load{font-size:11px;font-variant-numeric:tabular-nums}
3 changes: 3 additions & 0 deletions public/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -376,3 +376,6 @@ setInterval(() => window.updateLastActivityTimes?.(), 30000);
// Live tickers (if any configured)
queueMicrotask(() => loadTickers().catch(() => { }));
setInterval(() => loadTickers().catch(() => { }), 60000);
// Live host machine stats (CPU / RAM / temps)
queueMicrotask(() => loadMetrics().catch(() => { }));
setInterval(() => loadMetrics().catch(() => { }), 5000);
57 changes: 57 additions & 0 deletions public/metrics.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
"use strict";
// Live host machine stats (CPU / RAM / temps), polled from /api/metrics.
// Mirrors the CachyOS system-monitor widget: at-a-glance CPU%, RAM, and temperatures.
function meterLevel(pct) {
return pct >= 90 ? 'crit' : pct >= 70 ? 'warn' : 'ok';
}
function tempLevel(c) {
return c >= 85 ? 'crit' : c >= 70 ? 'warn' : 'ok';
}
function fmtGiB(kb) {
return (kb / 1048576).toFixed(1);
}
function fmtUptime(secs) {
const d = Math.floor(secs / 86400);
const h = Math.floor((secs % 86400) / 3600);
const m = Math.floor((secs % 3600) / 60);
if (d > 0)
return `${d}d ${h}h`;
if (h > 0)
return `${h}h ${m}m`;
return `${m}m`;
}
function setMeter(name, pct, text) {
const bar = document.querySelector(`[data-bar="${name}"]`);
const val = document.querySelector(`[data-m="${name}"]`);
if (bar) {
bar.style.width = `${Math.max(0, Math.min(100, pct)).toFixed(0)}%`;
bar.className = meterLevel(pct);
}
if (val)
val.textContent = text;
}
function renderMetrics(m) {
const host = document.getElementById('metricsHost');
if (host)
host.textContent = `${m.hostname} · ${m.cpu_cores} cores · up ${fmtUptime(m.uptime_secs)}`;
setMeter('cpu', m.cpu_pct, `${m.cpu_pct.toFixed(0)}%`);
setMeter('mem', m.mem_pct, `${fmtGiB(m.mem_used_kb)} / ${fmtGiB(m.mem_total_kb)} GiB`);
const load = document.getElementById('metricLoad');
if (load)
load.textContent = `load ${m.load1.toFixed(2)} · ${m.load5.toFixed(2)} · ${m.load15.toFixed(2)}`;
const temps = document.getElementById('metricTemps');
if (temps) {
temps.innerHTML = (m.temps || [])
.map((t) => `<span class="temp-chip ${tempLevel(t.celsius)}" title="${escapeHtml(t.label)}">${escapeHtml(t.label)} <b>${t.celsius.toFixed(0)}°</b></span>`)
.join('');
}
}
async function loadMetrics() {
if (!document.getElementById('metricsPanel'))
return;
const response = await fetch('/api/metrics', { cache: 'no-store', credentials: 'same-origin' });
if (!response.ok)
return;
renderMetrics(await response.json());
}
window.loadMetrics = loadMetrics;
6 changes: 6 additions & 0 deletions src/auth.rs
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ pub fn network_allowed(config: &Config, headers: &HeaderMap, remote: Option<&str
}

pub fn authenticated(config: &Config, headers: &HeaderMap, remote: Option<&str>) -> bool {
// When fronted by an external auth layer (e.g. Cloudflare Access / Zero Trust),
// skip ShellDeck's own login password — reaching this point already means the
// request passed the network gate in `network_allowed`. Shells still need unlock.
if config.skip_login {
return true;
}
let email = cf_email(headers);
if config.trust_cf_access_email && !email.is_empty() && config.allowed_emails.contains(&email) {
return true;
Expand Down
5 changes: 5 additions & 0 deletions src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ pub struct Config {
pub allowed_emails: Vec<String>,
pub trust_cf_access_email: bool,
pub allow_cloudflare_login: bool,
pub skip_login: bool,
pub bypass_login_ips: Vec<String>,
pub unlock_password: String,
pub root_dir: PathBuf,
Expand Down Expand Up @@ -94,6 +95,10 @@ impl Config {
== Some("1"),
allow_cloudflare_login: env::var("DASHBOARD_ALLOW_CLOUDFLARE_LOGIN").ok().as_deref()
== Some("1"),
// Skip ShellDeck's own login password entirely and trust the network gate
// (allowed IP / Cloudflare Access). Only enable when an external layer like
// Cloudflare Access already authenticates who can reach the dashboard.
skip_login: env::var("DASHBOARD_SKIP_LOGIN").ok().as_deref() == Some("1"),
bypass_login_ips: split_env("DASHBOARD_BYPASS_LOGIN_IPS"),
unlock_password: env::var("DASHBOARD_UNLOCK_PASSWORD")
.unwrap_or_else(|_| "change-me".to_string()),
Expand Down
1 change: 1 addition & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod auth;
mod config;
mod metrics;
mod pages;
mod routes;
mod stream;
Expand Down
Loading