NetDesk 1.5.0 — source release
This commit is contained in:
commit
40318aafdd
227 files changed
+80798
No files matched your search
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,42 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>About — NetDesk</title>
|
||||
<meta name="description" content="NetDesk builds remote access that respects your ownership — self-hosted, private, open source.">
|
||||
<link rel="canonical" href="https://netdesk.space/about">
|
||||
<meta property="og:title" content="About — NetDesk">
|
||||
<meta property="og:description" content="NetDesk builds remote access that respects your ownership — self-hosted, private, open source.">
|
||||
<meta property="og:url" content="https://netdesk.space/about">
|
||||
<meta name="theme-color" content="#0a0d14">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%233d7eff'/%3E%3Cpath d='M8 20l8-12 8 12-8-4z' fill='white'/%3E%3Crect x='12' y='18' width='8' height='7' rx='2' fill='white'/%3E%3C/svg%3E">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="site.css?v=2">
|
||||
</head>
|
||||
<body data-page="about">
|
||||
<div id="siteHeader"></div>
|
||||
|
||||
<section class="section"><div class="wrap"><div class="prose">
|
||||
<h1>About NetDesk</h1><p class="updated">Building remote access that respects your ownership.</p>
|
||||
<p>NetDesk exists because remote access shouldn't mean handing your screen, your files and your trust to someone else's
|
||||
cloud. Tools like TeamViewer and AnyDesk are powerful, but they route your sessions through their servers and charge
|
||||
per seat. RustDesk proved a self-hosted model works — NetDesk takes it further with a first-class web client, an
|
||||
account and device system, and a polished desktop app.</p>
|
||||
<h2>Our principles</h2>
|
||||
<ul>
|
||||
<li><strong>You own it.</strong> Your server, your keys, your data. No NetDesk cloud in the path.</li>
|
||||
<li><strong>Private by default.</strong> End-to-end encryption is on out of the box, not an upsell.</li>
|
||||
<li><strong>Open source.</strong> Every line is auditable. Trust is earned by transparency.</li>
|
||||
<li><strong>Free for individuals.</strong> The core is free forever; teams pay for convenience, not for the right to connect.</li>
|
||||
</ul>
|
||||
<h2>Built for scale</h2>
|
||||
<p>NetDesk runs a stateless relay you can deploy on a $5 VPS or a fleet of them. It handles NAT traversal without port
|
||||
forwarding, survives reconnections, and streams hardware-accelerated H.264 at up to 1080p60.</p>
|
||||
<p>We're just getting started. <a href="/contact">Talk to us</a> or <a href="/git/akronlogic/netdesk">browse the full source</a>.</p>
|
||||
</div></div></section>
|
||||
<div id="siteFooter"></div>
|
||||
<script src="site.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,230 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>NetDesk — Admin Console</title>
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%233d7eff'/%3E%3Cpath d='M8 20l8-12 8 12-8-4z' fill='white'/%3E%3Crect x='12' y='18' width='8' height='7' rx='2' fill='white'/%3E%3C/svg%3E">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="site.css?v=2">
|
||||
<style>
|
||||
body{min-height:100vh}
|
||||
.gate{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:14px;text-align:center;padding:24px}
|
||||
.gate h1{font-size:26px;letter-spacing:-.6px}
|
||||
.gate p{color:var(--muted);max-width:420px}
|
||||
.admin-shell{display:grid;grid-template-columns:248px 1fr;min-height:100vh}
|
||||
.admin-side{position:sticky;top:0;height:100vh;border-right:1px solid var(--hair);background:color-mix(in srgb,var(--bg2) 70%,transparent);backdrop-filter:blur(12px);padding:20px 14px;display:flex;flex-direction:column}
|
||||
.as-brand{display:flex;align-items:center;gap:10px;font-weight:800;font-size:18px;letter-spacing:-.4px;padding:4px 8px 18px}
|
||||
.as-brand .nz-logo{width:30px;height:30px}
|
||||
.as-nav{display:flex;flex-direction:column;gap:3px}
|
||||
.as-nav a{display:flex;align-items:center;gap:11px;padding:10px 12px;border-radius:9px;color:var(--muted);font-weight:600;font-size:14.5px;cursor:pointer;transition:.14s}
|
||||
.as-nav a:hover{background:var(--hair);color:var(--fg)}
|
||||
.as-nav a.on{background:var(--accent-dim);color:var(--fg)}
|
||||
.as-nav a.on svg{color:var(--accent2)}
|
||||
.as-nav svg{width:18px;height:18px;flex-shrink:0;opacity:.85}
|
||||
.as-foot{margin-top:auto;padding-top:14px;border-top:1px solid var(--hair);display:flex;flex-direction:column;gap:3px}
|
||||
.as-foot a{color:var(--faint);font-size:13.5px;padding:8px 12px;border-radius:8px}
|
||||
.as-foot a:hover{color:var(--fg);background:var(--hair)}
|
||||
.admin-main{padding:30px clamp(18px,4vw,44px);max-width:1200px}
|
||||
.pg-head{margin-bottom:22px}
|
||||
.pg-head h1{font-size:26px;letter-spacing:-.7px}
|
||||
.pg-head p{color:var(--muted);font-size:14.5px;margin-top:4px}
|
||||
.ov-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:16px}
|
||||
.ov-card{border:1px solid transparent;border-radius:15px;padding:20px 22px;background:linear-gradient(var(--card),var(--card)) padding-box,linear-gradient(180deg,var(--hair-strong),rgba(255,255,255,.015)) border-box}
|
||||
.ov-card b{display:block;font-size:30px;font-weight:850;letter-spacing:-1px;background:var(--accent-grad);-webkit-background-clip:text;background-clip:text;color:transparent;line-height:1}
|
||||
.ov-card span{display:block;margin-top:7px;color:var(--muted);font-size:13px;font-weight:500}
|
||||
.a-card{border:1px solid var(--hair);border-radius:15px;background:var(--card);overflow:hidden;box-shadow:var(--shadow-sm)}
|
||||
.a-card-h{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px 20px;border-bottom:1px solid var(--hair);flex-wrap:wrap}
|
||||
.a-card-h h2{font-size:16px;letter-spacing:-.3px}
|
||||
table.adt{width:100%;border-collapse:collapse;font-size:14px}
|
||||
table.adt th{text-align:left;color:var(--faint);font-size:11.5px;text-transform:uppercase;letter-spacing:.6px;font-weight:700;padding:11px 16px;border-bottom:1px solid var(--hair)}
|
||||
table.adt td{padding:11px 16px;border-bottom:1px solid var(--hair-strong);vertical-align:middle}
|
||||
table.adt tr:last-child td{border-bottom:none}
|
||||
table.adt tr:hover td{background:color-mix(in srgb,var(--accent) 4%,transparent)}
|
||||
.pill{display:inline-block;font-size:11px;font-weight:700;padding:3px 9px;border-radius:999px;text-transform:uppercase;letter-spacing:.4px}
|
||||
.pill.on{background:rgba(52,211,153,.16);color:#6ee7b7}.pill.off{background:var(--hair);color:var(--faint)}
|
||||
.pill.admin{background:rgba(77,139,255,.18);color:#9cc0ff}.pill.member{background:var(--hair);color:var(--muted)}
|
||||
.in{background:var(--bg2);border:1px solid var(--border);border-radius:8px;color:var(--fg);padding:7px 10px;font-size:13px;font-family:inherit;outline:none}
|
||||
.in:focus{border-color:var(--accent);box-shadow:var(--ring)}
|
||||
.in-sm{width:70px}
|
||||
.b{background:var(--bg3);border:1px solid var(--border);border-radius:8px;color:var(--fg);padding:7px 13px;font-size:13px;font-weight:600;cursor:pointer;transition:.14s}
|
||||
.b:hover{border-color:var(--accent);transform:translateY(-1px)}
|
||||
.b-cta{background:var(--accent-grad);border-color:transparent;color:#fff}
|
||||
.b-danger:hover{border-color:#ef4444;color:#fca5a5}
|
||||
.dot{display:inline-block;width:8px;height:8px;border-radius:50%;margin-right:6px}
|
||||
.dot.on{background:#34d399;box-shadow:0 0 8px #34d399}.dot.off{background:var(--faint)}
|
||||
.tag{display:inline-block;font-size:11px;background:var(--hair);color:var(--muted);padding:2px 8px;border-radius:6px;margin:2px 3px 2px 0}
|
||||
.pol-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px 24px;padding:20px}
|
||||
.pol-field{display:flex;flex-direction:column;gap:5px}
|
||||
.pol-field label{font-size:12.5px;font-weight:600;color:var(--muted)}
|
||||
.pol-check{display:flex;align-items:center;gap:9px;font-size:14px;padding:6px 0}
|
||||
.muted{color:var(--muted)}.faint{color:var(--faint)}.sp{margin-top:22px}
|
||||
.toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);background:var(--card);border:1px solid var(--hair);border-radius:10px;padding:11px 18px;font-size:14px;box-shadow:var(--shadow-lg);z-index:100;opacity:0;transition:.2s;pointer-events:none}
|
||||
.toast.show{opacity:1}
|
||||
/* teams */
|
||||
.teams-wrap{display:grid;grid-template-columns:320px 1fr;gap:18px;align-items:start}
|
||||
.t-list{display:flex;flex-direction:column}
|
||||
.t-item{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:12px 20px;border-bottom:1px solid var(--hair);cursor:pointer;transition:.12s}
|
||||
.t-item:hover{background:var(--hair)}
|
||||
.t-item.on{background:linear-gradient(90deg,rgba(77,139,255,.14),transparent);box-shadow:inset 2px 0 0 var(--accent)}
|
||||
.t-item b{font-size:14.5px}
|
||||
.t-item .t-sub{font-size:12px;color:var(--faint);margin-top:2px}
|
||||
.td-body{display:grid;grid-template-columns:1fr 1fr;gap:26px;padding:20px}
|
||||
.td-col h3{font-size:13px;text-transform:uppercase;letter-spacing:.6px;color:var(--muted);margin-bottom:12px}
|
||||
.t-row{display:flex;align-items:center;justify-content:space-between;gap:10px;padding:8px 0;border-bottom:1px solid var(--hair);font-size:13.5px}
|
||||
.t-row .t-meta{color:var(--faint);font-size:12px}
|
||||
/* access rules */
|
||||
.ac-days{display:flex;gap:6px;flex-wrap:wrap}
|
||||
.ac-days label{display:inline-flex;align-items:center;gap:5px;font-size:13px;background:var(--hair);padding:5px 10px;border-radius:7px;cursor:pointer}
|
||||
.ac-pill{display:inline-block;font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:.4px;padding:2px 8px;border-radius:999px}
|
||||
.ac-allow{background:rgba(52,211,153,.16);color:#6ee7b7}.ac-deny{background:rgba(239,68,68,.16);color:#fca5a5}
|
||||
.ac-cond{font-size:12px;color:var(--muted);line-height:1.5}
|
||||
.ac-res-ok{color:#6ee7b7;font-weight:600}.ac-res-no{color:#fca5a5;font-weight:600}
|
||||
@media (max-width:820px){.admin-shell{grid-template-columns:1fr}.admin-side{position:static;height:auto;flex-direction:row;flex-wrap:wrap;overflow:auto}.as-foot{flex-direction:row;margin:0}.pol-grid{grid-template-columns:1fr}.teams-wrap{grid-template-columns:1fr}.td-body{grid-template-columns:1fr}}
|
||||
</style>
|
||||
</head>
|
||||
<body data-page="admin">
|
||||
<div id="gate" class="gate"><div class="muted">Checking access…</div></div>
|
||||
|
||||
<div id="shell" class="admin-shell" hidden>
|
||||
<aside class="admin-side">
|
||||
<div class="as-brand"><span class="nz-logo"><svg width="18" height="18" viewBox="0 0 32 32"><path d="M8 20l8-12 8 12-8-4z" fill="#fff"/><rect x="12" y="18" width="8" height="7" rx="2" fill="#fff"/></svg></span>Admin</div>
|
||||
<nav class="as-nav" id="nav">
|
||||
<a data-p="overview" class="on"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/></svg> Overview</a>
|
||||
<a data-p="users"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg> Users & roles</a>
|
||||
<a data-p="devices"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg> Devices</a>
|
||||
<a data-p="policy"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg> MDM policy</a>
|
||||
<a data-p="keys"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2l-2 2m-7.6 7.6a5 5 0 1 0-7 7 5 5 0 0 0 7-7zm0 0L15 7m0 0l3 3L22 6l-3-3"/></svg> API keys</a>
|
||||
<a data-p="relays"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="2"/><path d="M16.24 7.76a6 6 0 0 1 0 8.49M7.76 16.24a6 6 0 0 1 0-8.49M19.07 4.93a10 10 0 0 1 0 14.14M4.93 19.07a10 10 0 0 1 0-14.14"/></svg> Relays</a>
|
||||
<a data-p="teams"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87M16 3.13a4 4 0 0 1 0 7.75"/></svg> Teams</a>
|
||||
<a data-p="access"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> Access rules</a>
|
||||
<a data-p="recordings"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3.5" fill="currentColor" stroke="none"/></svg> Recordings</a>
|
||||
</nav>
|
||||
<div class="as-foot">
|
||||
<a href="/portal">Operator console</a>
|
||||
<a href="/app">Open viewer</a>
|
||||
<a href="/">Website</a>
|
||||
</div>
|
||||
</aside>
|
||||
<main class="admin-main">
|
||||
<section id="p-overview">
|
||||
<div class="pg-head"><h1>Overview</h1><p>Your NetDesk organization at a glance.</p></div>
|
||||
<div class="ov-grid" id="ovGrid"></div>
|
||||
</section>
|
||||
<section id="p-users" hidden>
|
||||
<div class="pg-head"><h1>Users & roles</h1><p>Every account on this server — promote admins, set per-account seat limits.</p></div>
|
||||
<div class="a-card"><div class="a-card-h"><h2>Accounts</h2><span class="faint" id="uCount"></span></div>
|
||||
<table class="adt"><thead><tr><th>Email</th><th>Role</th><th>Seats</th><th>Devices</th><th>2FA</th><th>Verified</th></tr></thead><tbody id="uBody"></tbody></table>
|
||||
</div>
|
||||
</section>
|
||||
<section id="p-devices" hidden>
|
||||
<div class="pg-head"><h1>Devices</h1><p>Every registered computer across all accounts — tag, group, hide or remove.</p></div>
|
||||
<div class="a-card"><div class="a-card-h"><h2>Computers</h2><span class="faint" id="dCount"></span></div>
|
||||
<table class="adt"><thead><tr><th>Computer</th><th>Owner</th><th>Status</th><th>Group</th><th>Tags</th><th></th></tr></thead><tbody id="dBody"></tbody></table>
|
||||
</div>
|
||||
</section>
|
||||
<section id="p-policy" hidden>
|
||||
<div class="pg-head"><h1>MDM policy</h1><p>Push a managed, locked configuration to an account's hosts (applied at registration).</p></div>
|
||||
<div class="a-card"><div class="a-card-h"><h2>Account policy</h2>
|
||||
<div style="display:flex;gap:8px;align-items:center"><select id="polAcct" class="in"></select><button class="b" id="polLoad">Load</button></div></div>
|
||||
<div class="pol-grid" id="polForm"></div>
|
||||
<div style="padding:0 20px 20px;display:flex;gap:10px"><button class="b b-cta" id="polSave">Save & push policy</button><button class="b b-danger" id="polClear">Clear policy</button></div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="p-keys" hidden>
|
||||
<div class="pg-head"><h1>API keys</h1><p>Programmatic access (X-API-Key) to the /api/v1 REST surface + webhooks.</p></div>
|
||||
<div class="a-card"><div class="a-card-h"><h2>Your keys</h2>
|
||||
<div style="display:flex;gap:8px"><input id="kName" class="in" placeholder="Key name"><button class="b b-cta" id="kNew">Create key</button></div></div>
|
||||
<table class="adt"><thead><tr><th>Name</th><th>Key</th><th>Created</th><th>Last used</th><th></th></tr></thead><tbody id="kBody"></tbody></table>
|
||||
</div>
|
||||
<div id="kNewBox" class="muted sp" hidden></div>
|
||||
|
||||
<div class="a-card sp">
|
||||
<div class="a-card-h"><h2>SCIM 2.0 provisioning</h2>
|
||||
<div style="display:flex;gap:8px"><input id="scName" class="in" placeholder="Token label (e.g. Okta)"><button class="b b-cta" id="scNew">Mint token</button></div></div>
|
||||
<div style="padding:14px 20px 0" class="muted" id="scBase"></div>
|
||||
<table class="adt"><thead><tr><th>Label</th><th>Token</th><th>Created</th><th>Last used</th><th></th></tr></thead><tbody id="scBody"></tbody></table>
|
||||
<div id="scNewBox" class="muted" style="padding:0 20px 18px" hidden></div>
|
||||
</div>
|
||||
</section>
|
||||
<section id="p-relays" hidden>
|
||||
<div class="pg-head"><h1>Relays</h1><p>Configured relay nodes for nearest-relay geo selection (set with --relays).</p></div>
|
||||
<div class="a-card"><div class="a-card-h"><h2>Relay nodes</h2></div>
|
||||
<table class="adt"><thead><tr><th>ID</th><th>Region</th><th>URL</th><th>Tier</th><th>Lat, Lon</th></tr></thead><tbody id="rBody"></tbody></table>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="p-teams" hidden>
|
||||
<div class="pg-head"><h1>Teams</h1><p>Shared address books — a team of operators shares one device list. The team owner (or any account admin) manages its members and shared devices.</p></div>
|
||||
<div class="teams-wrap">
|
||||
<div class="a-card t-list-card">
|
||||
<div class="a-card-h"><h2>Teams</h2><span class="faint" id="tCount"></span></div>
|
||||
<div style="display:flex;gap:8px;padding:0 20px 14px"><input id="tName" class="in" placeholder="New team name"><button class="b b-cta" id="tNew">Create</button></div>
|
||||
<div id="tList" class="t-list"></div>
|
||||
</div>
|
||||
<div class="a-card teams-detail" id="tDetail" hidden>
|
||||
<div class="a-card-h"><h2 id="tdName">—</h2><span style="white-space:nowrap"><button class="b" id="tdRename">Rename</button> <button class="b b-danger" id="tdDelete">Delete</button></span></div>
|
||||
<div class="td-body">
|
||||
<div class="td-col">
|
||||
<h3>Members</h3>
|
||||
<div style="display:flex;gap:8px;margin-bottom:10px"><input id="tmEmail" class="in" placeholder="account email" style="flex:1"><select id="tmRole" class="in in-sm" style="width:auto"><option value="member">member</option><option value="owner">owner</option></select><button class="b" id="tmAdd">Add</button></div>
|
||||
<div id="tmList"></div>
|
||||
</div>
|
||||
<div class="td-col">
|
||||
<h3>Shared devices</h3>
|
||||
<div style="display:flex;gap:8px;margin-bottom:10px"><select id="tdDev" class="in" style="flex:1"></select><button class="b" id="tdShare">Share</button></div>
|
||||
<div id="tdDevList"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="p-access" hidden>
|
||||
<div class="pg-head"><h1>Conditional access</h1><p>First-match-wins allow/deny rules, evaluated the moment a viewer connects. Scope by user, device, group, IP range, weekday and time-of-day. When nothing matches, the connection is allowed.</p></div>
|
||||
<div class="a-card">
|
||||
<div class="a-card-h"><h2>Rules for</h2><div style="display:flex;gap:8px;align-items:center"><select id="acAcct" class="in"></select><button class="b" id="acLoad">Load</button></div></div>
|
||||
<table class="adt"><thead><tr><th>#</th><th>Action</th><th>Applies to</th><th>Conditions</th><th>Reason</th><th>On</th><th></th></tr></thead><tbody id="acBody"></tbody></table>
|
||||
</div>
|
||||
<div class="a-card sp">
|
||||
<div class="a-card-h"><h2 id="acFormTitle">Add a rule</h2><button class="b" id="acReset" hidden>Cancel edit</button></div>
|
||||
<div class="pol-grid" id="acForm">
|
||||
<div class="pol-field"><label>Priority (lower is evaluated first)</label><input id="acPrio" class="in" type="number" value="100"></div>
|
||||
<div class="pol-field"><label>Action</label><select id="acAction" class="in"><option value="deny">deny</option><option value="allow">allow</option></select></div>
|
||||
<div class="pol-field"><label>Applies to</label><select id="acSubType" class="in"><option value="any">any connection</option><option value="user">a user (email)</option><option value="device">a device (id)</option><option value="group">a device group</option></select></div>
|
||||
<div class="pol-field"><label>Subject value</label><input id="acSubject" class="in" placeholder="email / device id / group name"></div>
|
||||
<div class="pol-field"><label>From IP range (CIDR, optional)</label><input id="acCidr" class="in" placeholder="e.g. 203.0.113.0/24"></div>
|
||||
<div class="pol-field"><label>Deny reason (shown to the viewer)</label><input id="acReason" class="in" placeholder="Access denied by policy"></div>
|
||||
<div class="pol-field" style="grid-column:1/-1"><label>Weekdays (leave all off = any day)</label><div id="acDays" class="ac-days"></div></div>
|
||||
<div class="pol-field"><label>Time from (HH:MM, optional)</label><input id="acTimeStart" class="in" placeholder="09:00"></div>
|
||||
<div class="pol-field"><label>Time to (HH:MM, optional)</label><input id="acTimeEnd" class="in" placeholder="18:00"></div>
|
||||
<div class="pol-field"><label>UTC offset for the times, minutes</label><input id="acTz" class="in" type="number" value="0" title="e.g. 60 = CET, -300 = US Eastern"></div>
|
||||
</div>
|
||||
<div style="padding:0 20px 20px;display:flex;gap:10px"><button class="b b-cta" id="acSave">Save rule</button></div>
|
||||
</div>
|
||||
<div class="a-card sp">
|
||||
<div class="a-card-h"><h2>Test a connection</h2><span class="faint">dry-run the rules above</span></div>
|
||||
<div class="pol-grid">
|
||||
<div class="pol-field"><label>Viewer email</label><input id="tViewer" class="in" placeholder="user@example.com"></div>
|
||||
<div class="pol-field"><label>Device id</label><input id="tHost" class="in" placeholder="123456789"></div>
|
||||
<div class="pol-field"><label>Device group</label><input id="tGroup" class="in"></div>
|
||||
<div class="pol-field"><label>From IP</label><input id="tIp" class="in" placeholder="203.0.113.5"></div>
|
||||
</div>
|
||||
<div style="padding:0 20px 20px;display:flex;gap:12px;align-items:center"><button class="b b-cta" id="tRun">Evaluate</button><span id="tResult"></span></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="p-recordings" hidden>
|
||||
<div class="pg-head"><h1>Session recordings</h1><p>Centrally-stored recordings uploaded by hosts governed by a <b>record_required</b> MDM policy (set it per account in MDM policy). <span id="recRetention" class="faint"></span></p></div>
|
||||
<div class="a-card"><div class="a-card-h"><h2>Recordings</h2><span class="faint" id="recCount"></span></div>
|
||||
<table class="adt"><thead><tr><th>#</th><th>Host</th><th>Owner</th><th>Viewer</th><th>Size</th><th>Length</th><th>Recorded</th><th></th></tr></thead><tbody id="recBody"></tbody></table>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
<div class="toast" id="toast"></div>
|
||||
<script src="admin.js?v=6"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,447 @@
|
||||
/* NetDesk admin console — manages users/roles, devices, MDM policy, API keys,
|
||||
relays. Account admin-role gated (the /api/admin/* endpoints return 403 for
|
||||
non-admins; the server is the authority, this UI just reacts). */
|
||||
(function () {
|
||||
"use strict";
|
||||
var $ = function (s) { return document.querySelector(s); };
|
||||
function esc(s) { return String(s == null ? "" : s).replace(/[&<>"']/g, function (c) {
|
||||
return { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]; }); }
|
||||
function fmtTs(t) { return t ? new Date(t * 1000).toLocaleDateString() : "—"; }
|
||||
function fmtAgo(t) { if (!t) return "never"; var s = Date.now() / 1000 - t;
|
||||
if (s < 60) return "just now"; if (s < 3600) return Math.floor(s / 60) + "m ago";
|
||||
if (s < 86400) return Math.floor(s / 3600) + "h ago"; return Math.floor(s / 86400) + "d ago"; }
|
||||
var toastT;
|
||||
function toast(msg, bad) { var t = $("#toast"); t.textContent = msg; t.style.borderColor = bad ? "#ef4444" : "";
|
||||
t.classList.add("show"); clearTimeout(toastT); toastT = setTimeout(function () { t.classList.remove("show"); }, 2400); }
|
||||
|
||||
function getJSON(p) { return fetch(p, { credentials: "same-origin", cache: "no-store" }).then(function (r) {
|
||||
return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }).catch(function () { return { ok: r.ok, status: r.status, d: {} }; }); }); }
|
||||
function post(p, body) { return fetch(p, { method: "POST", credentials: "same-origin",
|
||||
headers: { "Content-Type": "application/json" }, body: JSON.stringify(body || {}) }).then(function (r) {
|
||||
return r.json().then(function (d) { return { ok: r.ok, status: r.status, d: d }; }).catch(function () { return { ok: r.ok, status: r.status, d: {} }; }); }); }
|
||||
|
||||
/* ---- access gate ---- */
|
||||
getJSON("/api/admin/overview").then(function (r) {
|
||||
if (r.status === 401) { location.href = "/app"; return; }
|
||||
if (r.status === 403) {
|
||||
$("#gate").innerHTML = '<h1>Admin access required</h1><p>Your account is signed in but is not an administrator. Ask an admin to grant you the admin role, then reload.</p><a class="btn btn-ghost" href="/app" style="margin-top:8px">Back to app</a>';
|
||||
return;
|
||||
}
|
||||
if (!r.ok) { $("#gate").innerHTML = '<p class="muted">Could not reach the server.</p>'; return; }
|
||||
$("#gate").hidden = true; $("#shell").hidden = false;
|
||||
renderOverview(r.d);
|
||||
wireNav(); loadUsersForPolicy();
|
||||
});
|
||||
|
||||
/* ---- nav ---- */
|
||||
var loaded = {};
|
||||
function wireNav() {
|
||||
$("#nav").querySelectorAll("a").forEach(function (a) {
|
||||
a.onclick = function () {
|
||||
$("#nav").querySelectorAll("a").forEach(function (x) { x.classList.remove("on"); });
|
||||
a.classList.add("on");
|
||||
var p = a.dataset.p;
|
||||
["overview", "users", "devices", "policy", "keys", "relays", "teams", "access", "recordings"].forEach(function (s) {
|
||||
var el = $("#p-" + s); if (el) el.hidden = s !== p; });
|
||||
if (p === "users" && !loaded.users) loadUsers();
|
||||
if (p === "devices" && !loaded.devices) loadDevices();
|
||||
if (p === "keys" && !loaded.keys) loadKeys();
|
||||
if (p === "relays" && !loaded.relays) loadRelays();
|
||||
if (p === "teams" && !loaded.teams) loadTeams();
|
||||
if (p === "access" && !loaded.access) loadAccess();
|
||||
if (p === "recordings" && !loaded.recordings) loadRecordings();
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- overview ---- */
|
||||
function renderOverview(d) {
|
||||
var items = [["users", "Accounts"], ["admins", "Administrators"], ["devices", "Registered devices"],
|
||||
["hosts_online", "Hosts online"], ["in_session", "In session now"], ["api_keys", "API keys"], ["relays", "Relay nodes"]];
|
||||
$("#ovGrid").innerHTML = items.map(function (i) {
|
||||
return '<div class="ov-card"><b>' + (d[i[0]] != null ? d[i[0]] : "—") + '</b><span>' + i[1] + '</span></div>'; }).join("");
|
||||
}
|
||||
|
||||
/* ---- users + roles + seats ---- */
|
||||
var USERS = [];
|
||||
function loadUsers() {
|
||||
loaded.users = true;
|
||||
getJSON("/api/admin/users").then(function (r) {
|
||||
if (!r.ok) { toast(r.d.error || "failed", true); return; }
|
||||
USERS = r.d.users || [];
|
||||
$("#uCount").textContent = USERS.length + " account" + (USERS.length === 1 ? "" : "s");
|
||||
$("#uBody").innerHTML = USERS.map(function (u) {
|
||||
var rsel = '<select class="in" data-role="' + esc(u.email) + '">' +
|
||||
'<option value="member"' + (u.role === "member" ? " selected" : "") + '>member</option>' +
|
||||
'<option value="admin"' + (u.role === "admin" ? " selected" : "") + '>admin</option></select>';
|
||||
var seat = '<input class="in in-sm" type="number" min="0" value="' + (u.seats || 0) + '" data-seat="' + esc(u.email) + '" title="0 = unlimited">';
|
||||
return '<tr><td>' + esc(u.email) + (u.email === (r.d.me || "") ? ' <span class="faint">(you)</span>' : "") + '</td>' +
|
||||
'<td>' + rsel + '</td><td>' + seat + '</td><td>' + (u.devices || 0) + '</td>' +
|
||||
'<td><span class="pill ' + (u.twofa ? "on" : "off") + '">' + (u.twofa ? "on" : "off") + '</span></td>' +
|
||||
'<td><span class="pill ' + (u.verified ? "on" : "off") + '">' + (u.verified ? "yes" : "no") + '</span></td></tr>';
|
||||
}).join("");
|
||||
$("#uBody").querySelectorAll("[data-role]").forEach(function (sel) {
|
||||
sel.onchange = function () { post("/api/admin/role", { email: sel.dataset.role, role: sel.value })
|
||||
.then(function (x) { toast(x.ok ? "Role updated" : (x.d.error || "failed"), !x.ok); }); };
|
||||
});
|
||||
$("#uBody").querySelectorAll("[data-seat]").forEach(function (inp) {
|
||||
inp.onchange = function () { post("/api/admin/license", { email: inp.dataset.seat, seats: parseInt(inp.value, 10) || 0 })
|
||||
.then(function (x) { toast(x.ok ? "Seat limit saved" : (x.d.error || "failed"), !x.ok); }); };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- devices ---- */
|
||||
function loadDevices() {
|
||||
loaded.devices = true;
|
||||
getJSON("/api/devices?scope=all").then(function (r) {
|
||||
if (!r.ok) { toast(r.d.error || "failed", true); return; }
|
||||
var ds = r.d.devices || [];
|
||||
$("#dCount").textContent = ds.length + " device" + (ds.length === 1 ? "" : "s");
|
||||
$("#dBody").innerHTML = ds.map(function (dv) {
|
||||
var st = dv.online ? '<span class="dot on"></span>' + (dv.busy ? "in session" : "online") : '<span class="dot off"></span>offline';
|
||||
var tags = (dv.tags || []).map(function (t) { return '<span class="tag">' + esc(t) + '</span>'; }).join("") || '<span class="faint">—</span>';
|
||||
return '<tr data-id="' + esc(dv.id) + '"><td><b>' + esc(dv.name) + '</b><div class="faint" style="font-size:12px">' + esc(dv.id) + '</div></td>' +
|
||||
'<td class="muted">' + esc(dv.owner || "—") + '</td><td>' + st + '</td>' +
|
||||
'<td><input class="in in-sm" style="width:110px" value="' + esc(dv.group || "") + '" data-grp placeholder="group"></td>' +
|
||||
'<td><input class="in" style="width:150px" value="' + esc((dv.tags || []).join(", ")) + '" data-tags placeholder="tag1, tag2"></td>' +
|
||||
'<td style="white-space:nowrap"><button class="b" data-save>Save</button> <button class="b b-danger" data-del>Remove</button></td></tr>';
|
||||
}).join("") || '<tr><td colspan="6" class="muted" style="padding:24px">No devices yet.</td></tr>';
|
||||
$("#dBody").querySelectorAll("tr[data-id]").forEach(function (tr) {
|
||||
var id = tr.dataset.id;
|
||||
var save = tr.querySelector("[data-save]"); if (save) save.onclick = function () {
|
||||
post("/api/devices/meta", { id: id, group: tr.querySelector("[data-grp]").value, tags: tr.querySelector("[data-tags]").value })
|
||||
.then(function (x) { toast(x.ok ? "Saved" : (x.d.error || "failed"), !x.ok); }); };
|
||||
var del = tr.querySelector("[data-del]"); if (del) del.onclick = function () {
|
||||
if (!confirm("Remove " + id + "? It must re-register to return.")) return;
|
||||
post("/api/devices/remove", { id: id }).then(function (x) { if (x.ok) { tr.remove(); toast("Removed"); } else toast(x.d.error || "failed", true); }); };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- MDM policy editor ---- */
|
||||
var STRKEYS = [["server", "Relay server (wss://…)"], ["reg_key", "Registration key"], ["allow_ips", "Viewer IP allowlist (CIDR, comma-sep)"], ["totp", "2FA secret (base32)"]];
|
||||
function loadUsersForPolicy() {
|
||||
getJSON("/api/admin/users").then(function (r) {
|
||||
var sel = $("#polAcct"); if (!sel) return;
|
||||
sel.innerHTML = (r.d.users || []).map(function (u) { return '<option value="' + esc(u.email) + '">' + esc(u.email) + '</option>'; }).join("");
|
||||
renderPolicyForm({});
|
||||
});
|
||||
$("#polLoad").onclick = function () {
|
||||
getJSON("/api/admin/policy?email=" + encodeURIComponent($("#polAcct").value)).then(function (r) { renderPolicyForm(r.d.policy || {}); toast("Loaded"); }); };
|
||||
$("#polSave").onclick = function () {
|
||||
post("/api/admin/policy", { email: $("#polAcct").value, policy: collectPolicy() })
|
||||
.then(function (x) { toast(x.ok ? "Policy saved & will push on host registration" : (x.d.error || "failed"), !x.ok); if (x.ok) renderPolicyForm(x.d.policy || {}); }); };
|
||||
$("#polClear").onclick = function () {
|
||||
post("/api/admin/policy", { email: $("#polAcct").value, policy: {} }).then(function (x) { toast(x.ok ? "Policy cleared" : "failed", !x.ok); renderPolicyForm({}); }); };
|
||||
}
|
||||
function renderPolicyForm(pol) {
|
||||
var perms = pol.perms || {};
|
||||
var h = STRKEYS.map(function (k) {
|
||||
return '<div class="pol-field"><label>' + k[1] + '</label><input class="in" data-pk="' + k[0] + '" value="' + esc(pol[k[0]] || "") + '"></div>'; }).join("");
|
||||
function chk(key, lbl, on) { return '<label class="pol-check"><input type="checkbox" data-pb="' + key + '"' + (on ? " checked" : "") + '> ' + lbl + '</label>'; }
|
||||
h += '<div class="pol-field"><label>Behaviour</label>' + chk("lock_on_end", "Lock the PC when the session ends", !!pol.lock_on_end) + chk("approve_click", "Require the host user to approve each connect", !!pol.approve_click) + chk("record_required", "Force central session recording (upload to the server)", !!pol.record_required) + '</div>';
|
||||
h += '<div class="pol-field"><label>Allowed channels (perms)</label>' +
|
||||
chk("perm_input", "Keyboard / mouse input", perms.input !== false) + chk("perm_clipboard", "Clipboard sync", perms.clipboard !== false) +
|
||||
chk("perm_files", "File transfer", perms.files !== false) + chk("perm_audio", "Audio", perms.audio !== false) + '</div>';
|
||||
$("#polForm").innerHTML = h;
|
||||
}
|
||||
function collectPolicy() {
|
||||
var pol = {}, perms = {};
|
||||
$("#polForm").querySelectorAll("[data-pk]").forEach(function (i) { if (i.value.trim()) pol[i.dataset.pk] = i.value.trim(); });
|
||||
$("#polForm").querySelectorAll("[data-pb]").forEach(function (c) {
|
||||
var k = c.dataset.pb;
|
||||
if (k.indexOf("perm_") === 0) perms[k.slice(5)] = c.checked; else if (c.checked) pol[k] = true;
|
||||
});
|
||||
pol.perms = perms;
|
||||
return pol;
|
||||
}
|
||||
|
||||
/* ---- API keys ---- */
|
||||
function loadKeys() {
|
||||
loaded.keys = true;
|
||||
getJSON("/api/keys").then(function (r) {
|
||||
var ks = (r.d && r.d.keys) || [];
|
||||
$("#kBody").innerHTML = ks.map(function (k) {
|
||||
return '<tr><td><b>' + esc(k.name) + '</b></td><td class="mono faint">' + esc(k.key_preview) + '</td><td>' + fmtTs(k.created) + '</td><td>' + fmtAgo(k.last_used) + '</td>' +
|
||||
'<td><button class="b b-danger" data-rev="' + esc(k.key_preview) + '">Revoke</button></td></tr>'; }).join("") ||
|
||||
'<tr><td colspan="5" class="muted" style="padding:24px">No API keys yet.</td></tr>';
|
||||
$("#kBody").querySelectorAll("[data-rev]").forEach(function (b) {
|
||||
b.onclick = function () { if (!confirm("Revoke this key?")) return;
|
||||
post("/api/keys/revoke", { key: b.dataset.rev }).then(function (x) { toast(x.ok ? "Revoked" : "failed", !x.ok); loaded.keys = false; loadKeys(); }); };
|
||||
});
|
||||
});
|
||||
$("#kNew").onclick = function () {
|
||||
var name = $("#kName").value.trim() || "key";
|
||||
post("/api/keys", { name: name }).then(function (x) {
|
||||
if (!x.ok) { toast(x.d.error || "failed", true); return; }
|
||||
$("#kName").value = ""; $("#kNewBox").hidden = false;
|
||||
$("#kNewBox").innerHTML = 'New key (copy it now — shown once): <code class="mono" style="color:var(--accent2)">' + esc(x.d.key) + '</code>';
|
||||
loaded.keys = false; loadKeys();
|
||||
});
|
||||
};
|
||||
loadScim();
|
||||
$("#scNew").onclick = function () {
|
||||
var label = $("#scName").value.trim() || "SCIM";
|
||||
post("/api/admin/scim/tokens", { label: label }).then(function (x) {
|
||||
if (!x.ok) { toast(x.d.error || "failed", true); return; }
|
||||
$("#scName").value = ""; $("#scNewBox").hidden = false;
|
||||
$("#scNewBox").innerHTML = 'New SCIM token (copy now — shown once): <code class="mono" style="color:var(--accent2)">' + esc(x.d.token) + '</code>';
|
||||
loadScim();
|
||||
});
|
||||
};
|
||||
}
|
||||
function loadScim() {
|
||||
getJSON("/api/admin/scim/tokens").then(function (r) {
|
||||
if (!r.ok) return;
|
||||
var ts = (r.d && r.d.tokens) || [];
|
||||
if (r.d.base) $("#scBase").innerHTML = 'Point your IdP at <code class="mono" style="color:var(--accent2)">' + esc(r.d.base) + '</code> with the token as an <b>HTTP Bearer</b> header. Provisions accounts (Users) & teams (Groups).';
|
||||
$("#scBody").innerHTML = ts.map(function (t) {
|
||||
return '<tr><td><b>' + esc(t.label) + '</b></td><td class="mono faint">' + esc(t.token_preview) + '</td><td>' + fmtTs(t.created) + '</td><td>' + fmtAgo(t.last_used) + '</td>' +
|
||||
'<td><button class="b b-danger" data-screv="' + esc(t.token_preview) + '">Revoke</button></td></tr>'; }).join("") ||
|
||||
'<tr><td colspan="5" class="muted" style="padding:20px">No provisioning tokens yet.</td></tr>';
|
||||
$("#scBody").querySelectorAll("[data-screv]").forEach(function (b) {
|
||||
b.onclick = function () { if (!confirm("Revoke this SCIM token? The IdP will stop provisioning.")) return;
|
||||
post("/api/admin/scim/tokens/revoke", { token: b.dataset.screv }).then(function (x) { toast(x.ok ? "Revoked" : "failed", !x.ok); loadScim(); }); };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- relays ---- */
|
||||
function loadRelays() {
|
||||
loaded.relays = true;
|
||||
getJSON("/api/relays").then(function (r) {
|
||||
var rs = (r.d && r.d.relays) || [];
|
||||
$("#rBody").innerHTML = rs.map(function (x) {
|
||||
return '<tr><td><b>' + esc(x.id) + '</b></td><td>' + esc(x.region || "—") + '</td><td class="mono faint">' + esc(x.url) + '</td><td>' + esc(x.tier || "—") + '</td><td class="muted">' + x.lat + ", " + x.lon + '</td></tr>'; }).join("");
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- teams (shared address books) ---- */
|
||||
var TEAMS = [], curTeam = null, ALLDEV = [];
|
||||
function refreshCounts() { loaded.teams = false; loadTeams(); }
|
||||
function loadTeams() {
|
||||
loaded.teams = true;
|
||||
getJSON("/api/admin/teams").then(function (r) {
|
||||
if (!r.ok) { toast(r.d.error || "failed", true); return; }
|
||||
TEAMS = r.d.teams || [];
|
||||
$("#tCount").textContent = TEAMS.length + " team" + (TEAMS.length === 1 ? "" : "s");
|
||||
$("#tList").innerHTML = TEAMS.map(function (t) {
|
||||
return '<div class="t-item" data-tid="' + t.id + '"><div><b>' + esc(t.name) + '</b>' +
|
||||
'<div class="t-sub">' + t.members + ' member' + (t.members === 1 ? "" : "s") + ' · ' + t.devices + ' device' + (t.devices === 1 ? "" : "s") + '</div></div>' +
|
||||
'<span class="faint" style="font-size:12px">#' + t.id + '</span></div>';
|
||||
}).join("") || '<div class="muted" style="padding:16px 20px">No teams yet — create one above.</div>';
|
||||
$("#tList").querySelectorAll("[data-tid]").forEach(function (el) {
|
||||
el.onclick = function () { selectTeam(parseInt(el.dataset.tid, 10)); };
|
||||
});
|
||||
if (curTeam && TEAMS.some(function (t) { return t.id === curTeam; })) selectTeam(curTeam);
|
||||
else { curTeam = null; $("#tDetail").hidden = true; }
|
||||
});
|
||||
}
|
||||
function selectTeam(tid) {
|
||||
curTeam = tid;
|
||||
var t = TEAMS.filter(function (x) { return x.id === tid; })[0];
|
||||
$("#tList").querySelectorAll("[data-tid]").forEach(function (el) { el.classList.toggle("on", parseInt(el.dataset.tid, 10) === tid); });
|
||||
$("#tDetail").hidden = false;
|
||||
$("#tdName").textContent = t ? t.name : ("Team #" + tid);
|
||||
loadMembers(tid); loadTeamDevices(tid);
|
||||
}
|
||||
function loadMembers(tid) {
|
||||
getJSON("/api/teams/members?team_id=" + tid).then(function (r) {
|
||||
var ms = (r.d && r.d.members) || [];
|
||||
$("#tmList").innerHTML = ms.map(function (m) {
|
||||
return '<div class="t-row"><span>' + esc(m.email) + ' <span class="pill ' + (m.role === "owner" ? "admin" : "member") + '">' + esc(m.role) + '</span></span>' +
|
||||
'<button class="b b-danger" data-rm="' + esc(m.email) + '">Remove</button></div>';
|
||||
}).join("") || '<div class="muted">No members.</div>';
|
||||
$("#tmList").querySelectorAll("[data-rm]").forEach(function (b) {
|
||||
b.onclick = function () {
|
||||
post("/api/teams/members/remove", { team_id: tid, email: b.dataset.rm }).then(function (x) {
|
||||
toast(x.ok ? "Removed" : (x.d.error || "failed"), !x.ok); if (x.ok) refreshCounts(); });
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
function loadTeamDevices(tid) {
|
||||
getJSON("/api/teams/devices?team_id=" + tid).then(function (r) {
|
||||
var ds = (r.d && r.d.devices) || [], shared = {};
|
||||
$("#tdDevList").innerHTML = ds.map(function (d) {
|
||||
shared[d.id] = true;
|
||||
return '<div class="t-row"><span>' + (d.online ? '<span class="dot on"></span>' : '<span class="dot off"></span>') + esc(d.name) +
|
||||
' <span class="t-meta">' + esc(d.owner || "") + '</span></span><button class="b b-danger" data-unshare="' + esc(d.id) + '">Unshare</button></div>';
|
||||
}).join("") || '<div class="muted">No shared devices.</div>';
|
||||
$("#tdDevList").querySelectorAll("[data-unshare]").forEach(function (b) {
|
||||
b.onclick = function () {
|
||||
post("/api/teams/devices/remove", { team_id: tid, device_id: b.dataset.unshare }).then(function (x) {
|
||||
toast(x.ok ? "Unshared" : (x.d.error || "failed"), !x.ok); if (x.ok) refreshCounts(); });
|
||||
};
|
||||
});
|
||||
var fill = function () {
|
||||
$("#tdDev").innerHTML = ALLDEV.filter(function (d) { return !shared[d.id]; }).map(function (d) {
|
||||
return '<option value="' + esc(d.id) + '">' + esc(d.name) + ' (' + esc(d.id) + (d.owner ? " · " + esc(d.owner) : "") + ')</option>';
|
||||
}).join("") || '<option value="">— no more devices —</option>';
|
||||
};
|
||||
if (ALLDEV.length) fill();
|
||||
else getJSON("/api/devices?scope=all").then(function (rr) { ALLDEV = (rr.d && rr.d.devices) || []; fill(); });
|
||||
});
|
||||
}
|
||||
(function wireTeams() {
|
||||
var nw = $("#tNew"); if (nw) nw.onclick = function () {
|
||||
var name = $("#tName").value.trim(); if (!name) { toast("Enter a team name", true); return; }
|
||||
post("/api/teams", { name: name }).then(function (x) {
|
||||
if (!x.ok) { toast(x.d.error || "failed", true); return; }
|
||||
$("#tName").value = ""; toast("Team created"); curTeam = x.d.id; refreshCounts(); });
|
||||
};
|
||||
var ad = $("#tmAdd"); if (ad) ad.onclick = function () {
|
||||
if (!curTeam) return; var em = $("#tmEmail").value.trim(); if (!em) { toast("Enter an account email", true); return; }
|
||||
post("/api/teams/members", { team_id: curTeam, email: em, role: $("#tmRole").value }).then(function (x) {
|
||||
toast(x.ok ? "Member added" : (x.d.error || "failed"), !x.ok); if (x.ok) { $("#tmEmail").value = ""; refreshCounts(); } });
|
||||
};
|
||||
var sh = $("#tdShare"); if (sh) sh.onclick = function () {
|
||||
if (!curTeam) return; var id = $("#tdDev").value; if (!id) { toast("No device selected", true); return; }
|
||||
post("/api/teams/devices", { team_id: curTeam, device_id: id }).then(function (x) {
|
||||
toast(x.ok ? "Device shared" : (x.d.error || "failed"), !x.ok); if (x.ok) refreshCounts(); });
|
||||
};
|
||||
var rn = $("#tdRename"); if (rn) rn.onclick = function () {
|
||||
if (!curTeam) return;
|
||||
var cur = (TEAMS.filter(function (x) { return x.id === curTeam; })[0] || {}).name || "";
|
||||
var name = prompt("Rename team", cur); if (name == null || !name.trim()) return;
|
||||
post("/api/teams/rename", { team_id: curTeam, name: name.trim() }).then(function (x) {
|
||||
toast(x.ok ? "Renamed" : (x.d.error || "failed"), !x.ok); if (x.ok) refreshCounts(); });
|
||||
};
|
||||
var dl = $("#tdDelete"); if (dl) dl.onclick = function () {
|
||||
if (!curTeam) return;
|
||||
if (!confirm("Delete this team? Members lose the shared list (the devices themselves are not deleted).")) return;
|
||||
post("/api/teams/delete", { team_id: curTeam }).then(function (x) {
|
||||
toast(x.ok ? "Team deleted" : (x.d.error || "failed"), !x.ok);
|
||||
if (x.ok) { curTeam = null; $("#tDetail").hidden = true; refreshCounts(); } });
|
||||
};
|
||||
})();
|
||||
|
||||
/* ---- session recordings ---- */
|
||||
function fmtSize(b) { b = +b || 0; if (b < 1024) return b + " B"; if (b < 1048576) return (b / 1024).toFixed(1) + " KB"; if (b < 1073741824) return (b / 1048576).toFixed(1) + " MB"; return (b / 1073741824).toFixed(2) + " GB"; }
|
||||
function fmtDur(s) { s = +s || 0; if (!s) return "—"; var m = Math.floor(s / 60), ss = s % 60; return m + ":" + ("" + ss).padStart(2, "0"); }
|
||||
function loadRecordings() {
|
||||
loaded.recordings = true;
|
||||
getJSON("/api/recordings").then(function (r) {
|
||||
if (!r.ok) { toast(r.d.error || "failed", true); return; }
|
||||
var rs = r.d.recordings || [];
|
||||
$("#recRetention").textContent = r.d.retention_days > 0 ? ("Auto-deleted after " + r.d.retention_days + " days.") : "Kept indefinitely (no retention limit set).";
|
||||
$("#recCount").textContent = rs.length + " recording" + (rs.length === 1 ? "" : "s");
|
||||
$("#recBody").innerHTML = rs.map(function (x) {
|
||||
return '<tr><td>' + x.id + '</td><td><b>' + esc(x.host_id) + '</b></td><td class="muted">' + esc(x.owner || "—") + '</td>' +
|
||||
'<td>' + esc(x.viewer || "—") + '</td><td>' + fmtSize(x.size) + '</td><td>' + fmtDur(x.duration) + '</td>' +
|
||||
'<td class="muted">' + fmtTs(x.created) + '</td>' +
|
||||
'<td style="white-space:nowrap"><a class="b" href="/api/recordings/' + x.id + '/download">Download</a> <button class="b b-danger" data-recdel="' + x.id + '">Delete</button></td></tr>';
|
||||
}).join("") || '<tr><td colspan="8" class="muted" style="padding:24px">No recordings yet. Set a <b>record_required</b> MDM policy on an account, then its hosts upload session recordings here.</td></tr>';
|
||||
$("#recBody").querySelectorAll("[data-recdel]").forEach(function (b) {
|
||||
b.onclick = function () { if (!confirm("Delete this recording permanently?")) return;
|
||||
post("/api/recordings/delete", { id: +b.dataset.recdel }).then(function (x) { toast(x.ok ? "Deleted" : (x.d.error || "failed"), !x.ok); loadRecordings(); }); };
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/* ---- conditional access rules ---- */
|
||||
var DAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
var editRuleId = null;
|
||||
function minsToHHMM(m) { if (m == null || m === "") return ""; m = +m; return (("" + Math.floor(m / 60)).padStart(2, "0")) + ":" + (("" + (m % 60)).padStart(2, "0")); }
|
||||
function hhmmToMins(s) { s = (s || "").trim(); if (!s) return null; var p = s.split(":"); if (p.length !== 2) return null; var h = +p[0], m = +p[1]; if (isNaN(h) || isNaN(m)) return null; return h * 60 + m; }
|
||||
function acOwner() { var s = $("#acAcct"); return (s && s.value) || ""; }
|
||||
function loadAccess() {
|
||||
loaded.access = true;
|
||||
// day checkboxes
|
||||
$("#acDays").innerHTML = DAYS.map(function (d, i) { return '<label><input type="checkbox" data-day="' + i + '"> ' + d + '</label>'; }).join("");
|
||||
// account selector (reuse users list)
|
||||
getJSON("/api/admin/users").then(function (r) {
|
||||
var us = (r.d && r.d.users) || [], me = (r.d && r.d.me) || "";
|
||||
$("#acAcct").innerHTML = us.map(function (u) { return '<option value="' + esc(u.email) + '"' + (u.email === me ? " selected" : "") + '>' + esc(u.email) + '</option>'; }).join("");
|
||||
loadRules();
|
||||
});
|
||||
var lb = $("#acLoad"); if (lb) lb.onclick = loadRules;
|
||||
}
|
||||
function condText(r) {
|
||||
var bits = [];
|
||||
if (r.cidr) bits.push("from " + esc(r.cidr));
|
||||
if (r.weekdays) bits.push(r.weekdays.split(",").map(function (i) { return DAYS[+i] || i; }).join(" "));
|
||||
if (r.time_start != null && r.time_end != null) bits.push(minsToHHMM(r.time_start) + "–" + minsToHHMM(r.time_end) + (r.tz_offset ? " (UTC" + (r.tz_offset >= 0 ? "+" : "") + (r.tz_offset / 60) + ")" : " UTC"));
|
||||
return bits.join(" · ") || '<span class="faint">any</span>';
|
||||
}
|
||||
function subjText(r) {
|
||||
if (r.subject_type === "any") return '<span class="faint">any connection</span>';
|
||||
return esc(r.subject_type) + ": <b>" + esc(r.subject || "—") + "</b>";
|
||||
}
|
||||
function loadRules() {
|
||||
var owner = acOwner();
|
||||
getJSON("/api/access/rules?owner=" + encodeURIComponent(owner)).then(function (r) {
|
||||
if (!r.ok) { toast(r.d.error || "failed", true); return; }
|
||||
var rs = r.d.rules || [];
|
||||
$("#acBody").innerHTML = rs.map(function (x) {
|
||||
return '<tr><td>' + x.priority + '</td>' +
|
||||
'<td><span class="ac-pill ' + (x.action === "allow" ? "ac-allow" : "ac-deny") + '">' + x.action + '</span></td>' +
|
||||
'<td>' + subjText(x) + '</td><td class="ac-cond">' + condText(x) + '</td>' +
|
||||
'<td class="muted">' + esc(x.reason || "") + '</td>' +
|
||||
'<td><input type="checkbox" data-toggle="' + x.id + '"' + (x.enabled ? " checked" : "") + '></td>' +
|
||||
'<td style="white-space:nowrap"><button class="b" data-edit="' + x.id + '">Edit</button> <button class="b b-danger" data-del="' + x.id + '">Delete</button></td></tr>';
|
||||
}).join("") || '<tr><td colspan="7" class="muted" style="padding:22px">No rules — every connection is allowed. Add a rule below.</td></tr>';
|
||||
$("#acBody").querySelectorAll("[data-del]").forEach(function (b) {
|
||||
b.onclick = function () { if (!confirm("Delete this rule?")) return;
|
||||
post("/api/access/rules/delete", { id: +b.dataset.del }).then(function (x) { toast(x.ok ? "Deleted" : "failed", !x.ok); loadRules(); }); };
|
||||
});
|
||||
$("#acBody").querySelectorAll("[data-toggle]").forEach(function (cb) {
|
||||
cb.onchange = function () { var rule = rs.filter(function (z) { return z.id === +cb.dataset.toggle; })[0]; if (!rule) return;
|
||||
post("/api/access/rules", ruleBody(rule, { enabled: cb.checked })).then(function (x) { toast(x.ok ? (cb.checked ? "Enabled" : "Disabled") : "failed", !x.ok); }); };
|
||||
});
|
||||
$("#acBody").querySelectorAll("[data-edit]").forEach(function (b) {
|
||||
b.onclick = function () { var rule = rs.filter(function (z) { return z.id === +b.dataset.edit; })[0]; if (rule) fillRuleForm(rule); };
|
||||
});
|
||||
});
|
||||
}
|
||||
function ruleBody(base, over) {
|
||||
var b = { owner: acOwner(), id: base.id, priority: base.priority, action: base.action, subject_type: base.subject_type,
|
||||
subject: base.subject, cidr: base.cidr, weekdays: base.weekdays, time_start: base.time_start, time_end: base.time_end,
|
||||
tz_offset: base.tz_offset, reason: base.reason, enabled: base.enabled };
|
||||
for (var k in (over || {})) b[k] = over[k];
|
||||
return b;
|
||||
}
|
||||
function fillRuleForm(r) {
|
||||
editRuleId = r.id;
|
||||
$("#acFormTitle").textContent = "Edit rule #" + r.id;
|
||||
$("#acReset").hidden = false;
|
||||
$("#acPrio").value = r.priority; $("#acAction").value = r.action; $("#acSubType").value = r.subject_type;
|
||||
$("#acSubject").value = r.subject || ""; $("#acCidr").value = r.cidr || ""; $("#acReason").value = r.reason || "";
|
||||
$("#acTimeStart").value = minsToHHMM(r.time_start); $("#acTimeEnd").value = minsToHHMM(r.time_end); $("#acTz").value = r.tz_offset || 0;
|
||||
var days = (r.weekdays || "").split(",");
|
||||
$("#acDays").querySelectorAll("[data-day]").forEach(function (cb) { cb.checked = days.indexOf(cb.dataset.day) >= 0; });
|
||||
try { $("#acFormTitle").scrollIntoView({ behavior: "smooth", block: "center" }); } catch (e) {}
|
||||
}
|
||||
function resetRuleForm() {
|
||||
editRuleId = null; $("#acFormTitle").textContent = "Add a rule"; $("#acReset").hidden = true;
|
||||
$("#acPrio").value = 100; $("#acAction").value = "deny"; $("#acSubType").value = "any";
|
||||
$("#acSubject").value = ""; $("#acCidr").value = ""; $("#acReason").value = ""; $("#acTimeStart").value = ""; $("#acTimeEnd").value = ""; $("#acTz").value = 0;
|
||||
$("#acDays").querySelectorAll("[data-day]").forEach(function (cb) { cb.checked = false; });
|
||||
}
|
||||
(function wireAccess() {
|
||||
var sv = $("#acSave"); if (sv) sv.onclick = function () {
|
||||
var days = [];
|
||||
$("#acDays").querySelectorAll("[data-day]").forEach(function (cb) { if (cb.checked) days.push(cb.dataset.day); });
|
||||
var body = { owner: acOwner(), priority: +$("#acPrio").value || 100, action: $("#acAction").value,
|
||||
subject_type: $("#acSubType").value, subject: $("#acSubject").value.trim(), cidr: $("#acCidr").value.trim(),
|
||||
reason: $("#acReason").value.trim(), weekdays: days.join(","), tz_offset: +$("#acTz").value || 0,
|
||||
time_start: hhmmToMins($("#acTimeStart").value), time_end: hhmmToMins($("#acTimeEnd").value), enabled: true };
|
||||
if (editRuleId) body.id = editRuleId;
|
||||
post("/api/access/rules", body).then(function (x) {
|
||||
toast(x.ok ? "Rule saved" : (x.d.error || "failed"), !x.ok);
|
||||
if (x.ok) { resetRuleForm(); loadRules(); } });
|
||||
};
|
||||
var rs = $("#acReset"); if (rs) rs.onclick = resetRuleForm;
|
||||
var tr = $("#tRun"); if (tr) tr.onclick = function () {
|
||||
var q = new URLSearchParams({ owner: acOwner(), viewer: $("#tViewer").value.trim(), host: $("#tHost").value.trim(), group: $("#tGroup").value.trim(), ip: $("#tIp").value.trim() });
|
||||
getJSON("/api/access/test?" + q.toString()).then(function (x) {
|
||||
if (!x.ok) { $("#tResult").innerHTML = '<span class="ac-res-no">' + esc(x.d.error || "error") + '</span>'; return; }
|
||||
var d = x.d;
|
||||
$("#tResult").innerHTML = d.allowed
|
||||
? '<span class="ac-res-ok">✓ ALLOWED</span>' + (d.rule_id ? ' <span class="faint">by rule #' + d.rule_id + '</span>' : ' <span class="faint">(no rule matched — default allow)</span>')
|
||||
: '<span class="ac-res-no">✕ DENIED</span> <span class="faint">rule #' + esc(d.rule_id) + ' — ' + esc(d.reason) + '</span>';
|
||||
});
|
||||
};
|
||||
})();
|
||||
})();
|
||||
+1302
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,351 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>NetDesk — The Fast Self-Hosted Remote Desktop</title>
|
||||
<link rel="stylesheet" href="app.css?v=53">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%233d7eff'/%3E%3Cpath d='M8 20l8-12 8 12-8-4z' fill='white'/%3E%3Crect x='12' y='18' width='8' height='7' rx='2' fill='white'/%3E%3C/svg%3E">
|
||||
<link rel="manifest" href="manifest.webmanifest">
|
||||
<meta name="theme-color" content="#3d7eff">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div id="screenConnect">
|
||||
<div class="topnote">ⓘ <b>netdesk.space</b> is the only official domain for this NetDesk server. No third-party cloud ever sees your screen.</div>
|
||||
|
||||
<header class="nav">
|
||||
<div class="nav-brand">
|
||||
<svg width="30" height="30" viewBox="0 0 32 32"><defs><linearGradient id="ndg" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="#25d386"/><stop offset="1" stop-color="#16a765"/></linearGradient></defs><rect width="32" height="32" rx="8" fill="url(#ndg)"/><path d="M16 6.5L6.5 18.2C6.1 18.7 6.4 19.4 7 19.4H25C25.6 19.4 25.9 18.7 25.5 18.2L16 6.5Z" fill="#fff"/><path d="M11.8 21H20.2L21.5 26.4C21.6 26.8 21.3 27.2 20.9 27.2H11.1C10.7 27.2 10.4 26.8 10.5 26.4L11.8 21Z" fill="#fff"/></svg>
|
||||
<b>NetDesk</b>
|
||||
</div>
|
||||
<nav class="nav-links">
|
||||
<a href="/">Home</a>
|
||||
<a href="/features">Features</a>
|
||||
<a href="/download">Download</a>
|
||||
<button id="btnTheme" class="theme-toggle" title="Switch theme">☀ Light</button>
|
||||
<span id="navAuth"></span>
|
||||
</nav>
|
||||
</header>
|
||||
|
||||
<section class="hero">
|
||||
<div class="hero-copy">
|
||||
<h1>The Fast Self-Hosted<br>Remote Desktop</h1>
|
||||
<p class="hero-sub">Your own remote access and support software — unattended, silent in the
|
||||
background, end-to-end encrypted, with your own server. The alternative to TeamViewer,
|
||||
AnyDesk and RustDesk that you fully control.</p>
|
||||
<div class="hero-cta">
|
||||
<a class="btn-big btn-blue" href="#download">Download <span class="dl-ic">⬇</span></a>
|
||||
<a class="btn-big btn-ghost" href="/portal">Self-Hosting</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hero-card">
|
||||
<div class="card" id="connect">
|
||||
<h2>Connect to a computer</h2>
|
||||
<p class="sub">Enter the remote ID shown by the NetDesk agent.</p>
|
||||
<label>Remote ID</label>
|
||||
<input id="inId" type="text" inputmode="numeric" pattern="[0-9]*" placeholder="123 456 789" spellcheck="false" autocomplete="off">
|
||||
<label>Password</label>
|
||||
<div class="pw-wrap">
|
||||
<input id="inPw" type="password" placeholder="permanent password" autocomplete="off">
|
||||
<button id="btnPwEye" class="ghost" type="button" title="Show password"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg></button>
|
||||
</div>
|
||||
<label id="lblTotp" hidden>2FA code</label>
|
||||
<input id="inTotp" type="text" inputmode="numeric" maxlength="6" placeholder="6-digit authenticator code" autocomplete="off" hidden>
|
||||
<details style="margin-top:14px">
|
||||
<summary class="foot" style="cursor:pointer;user-select:none">Advanced — direct connection</summary>
|
||||
<label>Server</label>
|
||||
<input id="inServer" type="text" placeholder="blank = this server · or ws://PC-IP:8901 for direct LAN" spellcheck="false">
|
||||
</details>
|
||||
<label class="chk"><input id="inRemember" type="checkbox"> Remember password on this device</label>
|
||||
<label class="chk"><input id="inViewOnly" type="checkbox"> View only (no input)</label>
|
||||
<button id="btnConnect" class="primary">Connect</button>
|
||||
<div id="connectErr" class="error" hidden></div>
|
||||
|
||||
<style>
|
||||
#hostShare{margin-top:22px;padding-top:20px;border-top:1px solid rgba(148,163,184,.25);text-align:center}
|
||||
#hostShare .host-or{font-size:12px;color:#8a97ab;letter-spacing:1px;margin-bottom:12px}
|
||||
#hostShare h3{margin:0 0 6px;font-size:16px}
|
||||
#hostShare .host-sub{margin:0 0 14px;font-size:12.5px;color:#8a97ab;line-height:1.5}
|
||||
#hostShare .host-cred{font-size:13px;margin-bottom:14px;color:#cdd6e4}
|
||||
#hostShare .host-cred b{font-size:15px;letter-spacing:.5px;color:#3d7eff;font-variant-numeric:tabular-nums}
|
||||
#hostShare #btnHostRegen{padding:2px 8px;font-size:13px;vertical-align:middle;margin-left:6px}
|
||||
#hostShare .host-status{margin-top:12px;font-size:12.5px;color:#8a97ab;min-height:16px}
|
||||
</style>
|
||||
<div id="hostShare">
|
||||
<div class="host-or">— OR —</div>
|
||||
<h3>Share THIS screen</h3>
|
||||
<p class="host-sub">Let someone view this computer right from their browser — no app to install.<br>(View-only: they can see & hear it, not control it.)</p>
|
||||
<div class="host-cred">ID <b id="hostIdOut">—</b> · Password <b id="hostPwOut">—</b><button id="btnHostRegen" class="tb" title="Generate a new ID + password">↻</button></div>
|
||||
<button id="btnHostStart" class="primary">Share my screen</button>
|
||||
<button id="btnHostStop" class="tb" hidden>Stop sharing</button>
|
||||
<div id="hostStatus" class="host-status"></div>
|
||||
</div>
|
||||
<div id="myDevsWrap" hidden>
|
||||
<div class="ab-head"><span>My computers</span><span class="foot">click to connect</span></div>
|
||||
<div id="myDevs"></div>
|
||||
</div>
|
||||
<div id="abookWrap">
|
||||
<div class="ab-head"><span>Address book</span><button id="btnAbAdd" class="tb">+ Save current</button></div>
|
||||
<div id="abook" hidden></div>
|
||||
</div>
|
||||
<div class="hint">
|
||||
Direct connection (no server): put <code>ws://IP:8901</code> in the Server
|
||||
field when the host runs with <code>--listen</code> on your LAN or via port-forward.
|
||||
</div>
|
||||
<div class="form-foot">
|
||||
<span class="foot">NetDesk viewer</span>
|
||||
<span style="display:flex;gap:8px;align-items:center">
|
||||
<span id="authChip" class="foot" hidden></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
|
||||
</div><!-- /screenConnect -->
|
||||
|
||||
<div id="authModal" class="modal-wrap" hidden>
|
||||
<div class="modal-card auth-card">
|
||||
<button id="btnAuthClose" class="modal-x" title="Close">✕</button>
|
||||
<div class="auth-head">
|
||||
<span class="auth-logo"><svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M12 3.2L3.2 12.6C2.8 13 3.1 13.7 3.7 13.7H20.3C20.9 13.7 21.2 13 20.8 12.6L12 3.2Z" fill="#fff"/><path d="M8.7 15.1H15.3L16.4 20.3C16.5 20.7 16.2 21 15.8 21H8.2C7.8 21 7.5 20.7 7.6 20.3L8.7 15.1Z" fill="#fff"/></svg></span>
|
||||
<span class="auth-brand">NetDesk <i>Account</i></span>
|
||||
</div>
|
||||
<div class="auth-tabs">
|
||||
<button class="atab on" data-t="in">Sign in</button>
|
||||
<button class="atab" data-t="up">Create account</button>
|
||||
<button class="atab" data-t="fp">Forgot</button>
|
||||
<button class="atab" data-t="2fa" id="atab2fa" hidden>Two-factor</button>
|
||||
</div>
|
||||
<div class="apane" id="pane-in">
|
||||
<h3 class="auth-title">Welcome back</h3>
|
||||
<p class="auth-sub">Sign in to see your computers and connect in one click.</p>
|
||||
<label class="auth-lbl" for="auEmail">Email address</label>
|
||||
<input id="auEmail" type="email" placeholder="you@company.com" autocomplete="email" spellcheck="false">
|
||||
<label class="auth-lbl" for="auPass">Password</label>
|
||||
<input id="auPass" type="password" placeholder="••••••••••" autocomplete="current-password">
|
||||
<label class="auth-lbl" id="auTotpLbl" for="auTotp" hidden>Two-factor code</label>
|
||||
<input id="auTotp" type="text" inputmode="numeric" maxlength="6" placeholder="6-digit authenticator code" autocomplete="one-time-code" hidden>
|
||||
<button id="auGo" class="primary auth-cta">Sign in →</button>
|
||||
<div id="ssoWrap" hidden style="margin-top:12px">
|
||||
<div style="display:flex;align-items:center;gap:10px;color:var(--faint);font-size:12px;margin:4px 0 10px"><span style="flex:1;height:1px;background:var(--border)"></span>OR<span style="flex:1;height:1px;background:var(--border)"></span></div>
|
||||
<a id="ssoBtn" class="btn-big btn-ghost" href="/saml/login?RelayState=/app" style="width:100%;justify-content:center;box-sizing:border-box">Sign in with SSO</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="apane" id="pane-up" hidden>
|
||||
<h3 class="auth-title">Create your account</h3>
|
||||
<p class="auth-sub">Free for individuals — your fleet syncs between the desktop app and this site.</p>
|
||||
<label class="auth-lbl" for="auEmail2">Email address</label>
|
||||
<input id="auEmail2" type="email" placeholder="you@company.com" autocomplete="email" spellcheck="false">
|
||||
<label class="auth-lbl" for="auPass2">Password</label>
|
||||
<input id="auPass2" type="password" placeholder="8+ characters" autocomplete="new-password">
|
||||
<button id="auGo2" class="primary auth-cta">Create account →</button>
|
||||
<div class="foot">Works right away — sign in with the same account in the NetDesk desktop app and your computers appear here automatically.</div>
|
||||
</div>
|
||||
<div class="apane" id="pane-fp" hidden>
|
||||
<h3 class="auth-title">Reset your password</h3>
|
||||
<p class="auth-sub">We'll email you a secure link to choose a new one.</p>
|
||||
<label class="auth-lbl" for="auEmail3">Account email</label>
|
||||
<input id="auEmail3" type="email" placeholder="you@company.com" autocomplete="email" spellcheck="false">
|
||||
<button id="auGo3" class="primary auth-cta">Send reset link</button>
|
||||
</div>
|
||||
<div class="apane" id="pane-rs" hidden>
|
||||
<h3 class="auth-title">Choose a new password</h3>
|
||||
<label class="auth-lbl" for="auPass4">New password</label>
|
||||
<input id="auPass4" type="password" placeholder="8+ characters" autocomplete="new-password">
|
||||
<button id="auGo4" class="primary auth-cta">Set new password</button>
|
||||
</div>
|
||||
<div class="apane" id="pane-2fa" hidden>
|
||||
<h3 class="auth-title">Two-factor authentication</h3>
|
||||
<p class="auth-sub">Add a 6-digit authenticator code on top of your password at sign-in.</p>
|
||||
<div id="tf2Status" class="foot">Checking…</div>
|
||||
<button id="tf2Enable" class="primary auth-cta" hidden>Enable 2FA →</button>
|
||||
<div id="tf2Enroll" hidden>
|
||||
<p class="auth-sub">Add this secret to your authenticator app (Google Authenticator, Authy, 1Password…), then enter a code to confirm.</p>
|
||||
<label class="auth-lbl">Secret</label>
|
||||
<input id="tf2Secret" type="text" readonly spellcheck="false">
|
||||
<div id="tf2Uri" class="foot" style="word-break:break-all;margin:4px 0 8px"></div>
|
||||
<label class="auth-lbl" for="tf2Code">Confirmation code</label>
|
||||
<input id="tf2Code" type="text" inputmode="numeric" maxlength="6" placeholder="6-digit code" autocomplete="one-time-code">
|
||||
<button id="tf2Confirm" class="primary auth-cta">Confirm & enable →</button>
|
||||
</div>
|
||||
<div id="tf2DisableWrap" hidden>
|
||||
<label class="auth-lbl" for="tf2DisCode">Enter a current code to turn 2FA off</label>
|
||||
<input id="tf2DisCode" type="text" inputmode="numeric" maxlength="6" placeholder="6-digit code" autocomplete="one-time-code">
|
||||
<button id="tf2Disable" class="auth-cta">Disable 2FA</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="auMsg" class="error" hidden></div>
|
||||
<div id="auOk" class="auok" hidden></div>
|
||||
<div class="auth-secure">🔒 TLS 1.3 in transit · passwords stored as PBKDF2-SHA256 · 200,000 rounds</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="screenSession" class="screen" hidden>
|
||||
<div id="toolbar">
|
||||
<div class="tb-group">
|
||||
<span id="tbTitle" class="tb-title">NetDesk</span>
|
||||
<span id="tbState" class="state">connecting…</span>
|
||||
</div>
|
||||
<div class="sep"></div>
|
||||
<div class="tb-group" id="menubar">
|
||||
<button class="tb menu-btn" data-menu="mActions">Actions ▾</button>
|
||||
<button class="tb menu-btn" data-menu="mView">View ▾</button>
|
||||
<button class="tb menu-btn" data-menu="mComm">Communicate ▾</button>
|
||||
<button class="tb menu-btn" data-menu="mExtras">Extras ▾</button>
|
||||
</div>
|
||||
<div class="tmenu" id="mComm" hidden>
|
||||
<button class="mi" id="miMsg">Send message… (chat)</button>
|
||||
<button class="mi mtoggle" id="miSound">Play remote computer sound<span class="mtick">✓</span></button>
|
||||
<div class="msep"></div>
|
||||
<button class="mi mtoggle" id="miVoice" title="Talk to the person at the host PC using your microphone">Start voice call (your mic)<span class="mtick">✓</span></button>
|
||||
</div>
|
||||
<div class="tmenu" id="mActions" hidden>
|
||||
<button class="mi" data-sys="lock">Lock remote computer</button>
|
||||
<button class="mi" data-sys="logoff" data-confirm="Sign out the remote user?">Sign out on remote computer</button>
|
||||
<button class="mi" data-sys="reboot" data-confirm="Reboot the remote computer? The session will end.">Remote reboot</button>
|
||||
<button class="mi" data-sys="cad">Send Ctrl + Alt + Del</button>
|
||||
<div class="msep"></div>
|
||||
<button class="mi" id="miWol">Wake a PC on this network (Wake-on-LAN)…</button>
|
||||
<div class="msep"></div>
|
||||
<button class="mi mtoggle" id="miBlockInput">Block remote user's input<span class="mtick">✓</span></button>
|
||||
<button class="mi mtoggle" id="miLockEnd">Lock on session end<span class="mtick">✓</span></button>
|
||||
<div class="msep"></div>
|
||||
<button class="mi" id="miInfo">Session information…</button>
|
||||
</div>
|
||||
<div class="tmenu" id="mView" hidden>
|
||||
<div class="mhead">Monitor</div>
|
||||
<div id="miMons"></div>
|
||||
<div class="mhead">Quality</div>
|
||||
<div id="miQual"></div>
|
||||
<div class="mhead">Scaling</div>
|
||||
<button class="mi mradio" data-scale="fit">Best fit<span class="mtick">✓</span></button>
|
||||
<button class="mi mradio" data-scale="orig">Original (1:1, scroll)<span class="mtick">✓</span></button>
|
||||
<div class="mhead">Screen resolution</div>
|
||||
<div id="miModes"><button class="mi" id="miModesLoad">Load resolutions…</button></div>
|
||||
<div class="msep"></div>
|
||||
<button class="mi mtoggle" id="miWallpaper">Hide wallpaper<span class="mtick">✓</span></button>
|
||||
<button class="mi" id="miRefresh">Refresh screen</button>
|
||||
</div>
|
||||
<div class="tmenu" id="mExtras" hidden>
|
||||
<button class="mi" id="miFiles">File transfer</button>
|
||||
<button class="mi" id="miChat">Chat</button>
|
||||
<button class="mi" id="miShot">Save screenshot</button>
|
||||
<button class="mi" id="miRec">Record session (WebM)</button>
|
||||
</div>
|
||||
<div class="tb-group">
|
||||
<select id="selMonitor" title="Monitor"></select>
|
||||
<select id="selQuality" title="Quality">
|
||||
<option value="auto" selected>Auto (adaptive)</option>
|
||||
<option value="low">Low · 15fps · 2 Mbps</option>
|
||||
<option value="balanced">Balanced · 30fps · 6 Mbps</option>
|
||||
<option value="high">High · 30fps · 12 Mbps</option>
|
||||
<option value="extreme">Extreme · 60fps · 20 Mbps</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="sep"></div>
|
||||
<div class="tb-group">
|
||||
<button id="btnViewOnly" class="tb" title="Toggle remote input"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3l18 18"/><path d="M10.6 5.1A9.8 9.8 0 0 1 12 5c7 0 11 8 11 8a13.2 13.2 0 0 1-1.7 2.4"/><path d="M6.6 6.6C4 8.3 2 12 2 12s4 8 12 8a9.7 9.7 0 0 0 4.3-1"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/></svg><span>Input</span></button>
|
||||
<button id="btnOsk" class="tb touch-only" title="Open the on-screen keyboard">⌨<span>Type</span></button>
|
||||
<button id="btnKbdLock" class="tb" title="Capture system keys while fullscreen"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="20" height="12" rx="2"/><path d="M6 10h.01M10 10h.01M14 10h.01M18 10h.01M7 14h10"/></svg><span>Keys</span></button>
|
||||
<button id="btnMonCycle" class="tb" title="Switch monitor" hidden><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="4" width="13" height="9" rx="1.5"/><rect x="9" y="11" width="13" height="9" rx="1.5"/></svg><span id="monCycleLbl">1/2</span></button>
|
||||
<button id="btnFullscreen" class="tb" title="Fullscreen"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M8 3H5a2 2 0 0 0-2 2v3M16 3h3a2 2 0 0 1 2 2v3M8 21H5a2 2 0 0 1-2-2v-3M16 21h3a2 2 0 0 0 2-2v-3"/></svg></button>
|
||||
<button id="btnClipboard" class="tb" title="Clipboard sync"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="8" y="2" width="8" height="4" rx="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/></svg></button>
|
||||
<button id="btnFiles" class="tb" title="File manager"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button>
|
||||
<button id="btnChat" class="tb" title="Chat"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></button>
|
||||
<button id="btnAudio" class="tb" title="Host audio: off → system → microphone"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M11 5 6 9H2v6h4l5 4V5z"/><path d="M15.5 8.5a5 5 0 0 1 0 7M19 5a8 8 0 0 1 0 14"/></svg><span id="audioLbl">Audio</span></button>
|
||||
<button id="btnSys" class="tb" title="Power / processes"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18.4 6.6a9 9 0 1 1-12.8 0"/><path d="M12 2v10"/></svg></button>
|
||||
<button id="btnPrivacy" class="tb" title="Privacy mode"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.9 17.9A9.8 9.8 0 0 1 12 20c-8 0-11-8-11-8a13.4 13.4 0 0 1 4.1-4.4M9.9 4.2A9.6 9.6 0 0 1 12 4c8 0 11 8 11 8a13.5 13.5 0 0 1-2.2 3.1"/><path d="M3 3l18 18"/><path d="M9.9 9.9a3 3 0 0 0 4.2 4.2"/></svg></button>
|
||||
<button id="btnRec" class="tb" title="Record session"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="4" fill="currentColor" stroke="none"/></svg></button>
|
||||
</div>
|
||||
<div class="sep"></div>
|
||||
<div class="tb-group">
|
||||
<span id="tbStats" class="stats"></span>
|
||||
<button id="btnDisconnect" class="tb danger" title="Disconnect"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6L6 18M6 6l12 12"/></svg><span>Disconnect</span></button>
|
||||
</div>
|
||||
<button id="tbTab" title="Hide the toolbar">▲</button>
|
||||
</div>
|
||||
<button id="tbShow" title="Show the toolbar" hidden>▼ NetDesk</button>
|
||||
<input id="oskInput" type="text" autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false" aria-hidden="true">
|
||||
|
||||
<div id="stage">
|
||||
<canvas id="canvas" tabindex="0"></canvas>
|
||||
<img id="remoteCursor" alt="" hidden>
|
||||
<div id="dropHint" hidden>Drop files to upload to the remote desktop</div>
|
||||
<div id="banner" class="banner" hidden></div>
|
||||
<div id="localCursor" hidden></div>
|
||||
|
||||
<div id="filePanel" class="panel" data-title="Files" hidden>
|
||||
<div class="panel-head" data-panel="filePanel"><span>Remote files</span><button class="tb" data-min="filePanel" title="Minimize to edge">»</button><button class="tb" id="btnFilesClose" title="Close">✕</button></div>
|
||||
<div class="file-path">
|
||||
<input id="filePath" spellcheck="false">
|
||||
<button id="btnFileGo" class="tb">Go</button>
|
||||
<button id="btnFileUp" class="tb" title="Up">⬆</button>
|
||||
<button id="btnFileHome" class="tb" title="Drives">💾</button>
|
||||
</div>
|
||||
<div id="fileList" class="file-list"></div>
|
||||
<div class="panel-foot">
|
||||
<button id="btnFileMkdir" class="tb">New folder</button>
|
||||
<button id="btnFileUpload" class="tb">Upload…</button>
|
||||
<input id="fileInput" type="file" multiple hidden>
|
||||
<button id="btnDirUpload" class="tb" title="Upload a folder">Folder…</button>
|
||||
<input id="dirInput" type="file" webkitdirectory multiple hidden>
|
||||
<span id="fileStatus" class="stats"></span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="chatPanel" class="panel" data-title="Chat" hidden>
|
||||
<div class="panel-head" data-panel="chatPanel"><span>Chat</span><button class="tb" data-min="chatPanel" title="Minimize to edge">»</button><button class="tb" id="btnChatClose" title="Close">✕</button></div>
|
||||
<div id="chatLog" class="chat-log"></div>
|
||||
<div class="chat-input">
|
||||
<input id="chatText" placeholder="Type a message…">
|
||||
<button id="chatSend" class="tb">Send</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="sysPanel" class="panel" data-title="Remote power" hidden>
|
||||
<div class="panel-head" data-panel="sysPanel"><span>Remote power</span><button class="tb" data-min="sysPanel" title="Minimize to edge">»</button><button class="tb" id="btnSysClose" title="Close">✕</button></div>
|
||||
<details class="sec" open>
|
||||
<summary>Power actions<span class="chev">▾</span></summary>
|
||||
<div class="sys-grid">
|
||||
<button class="tb" data-cmd="lock">Lock workstation</button>
|
||||
<button class="tb" data-cmd="logoff">Sign out user</button>
|
||||
<button class="tb" data-cmd="reboot">Reboot</button>
|
||||
<button class="tb danger" data-cmd="shutdown">Shut down</button>
|
||||
</div>
|
||||
<div class="panel-foot">
|
||||
<input id="sysCmd" placeholder="run a command on host (cmd /c …)">
|
||||
<button id="btnSysRun" class="tb">Run</button>
|
||||
</div>
|
||||
</details>
|
||||
<details class="sec" open>
|
||||
<summary>Options<span class="chev">▾</span></summary>
|
||||
<div class="panel-foot">
|
||||
<label class="chk" style="margin:0"><input id="chkLockOnEnd" type="checkbox"> Lock PC when I disconnect</label>
|
||||
</div>
|
||||
</details>
|
||||
<details class="sec">
|
||||
<summary>Processes<span class="chev">▾</span><button id="btnProcRefresh" class="tb" style="margin-left:auto">↻ Refresh</button></summary>
|
||||
<div id="procList" class="file-list" style="max-height:200px"></div>
|
||||
</details>
|
||||
<details class="sec">
|
||||
<summary>TCP tunnel <span class="muted" style="text-transform:none">(portal sign-in)</span><span class="chev">▾</span></summary>
|
||||
<div class="panel-foot">
|
||||
<input id="tnlPort" placeholder="port on the host" style="width:120px">
|
||||
<button id="btnTnlOpen" class="tb">Connect</button>
|
||||
<span id="tnlStatus" class="stats"></span>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="app.js?v=55"></script>
|
||||
<script src="host.js?v=5"></script>
|
||||
</body>
|
||||
</html>
|
||||
+2995
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,50 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Contact — NetDesk</title>
|
||||
<meta name="description" content="Contact NetDesk for sales, support, security or partnerships.">
|
||||
<link rel="canonical" href="https://netdesk.space/contact">
|
||||
<meta property="og:title" content="Contact — NetDesk">
|
||||
<meta property="og:description" content="Contact NetDesk for sales, support, security or partnerships.">
|
||||
<meta property="og:url" content="https://netdesk.space/contact">
|
||||
<meta name="theme-color" content="#0a0d14">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%233d7eff'/%3E%3Cpath d='M8 20l8-12 8 12-8-4z' fill='white'/%3E%3Crect x='12' y='18' width='8' height='7' rx='2' fill='white'/%3E%3C/svg%3E">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="site.css?v=2">
|
||||
</head>
|
||||
<body data-page="contact">
|
||||
<div id="siteHeader"></div>
|
||||
|
||||
<section class="section"><div class="wrap">
|
||||
<div class="sec-head"><div class="eyebrow">Contact</div><h2>Get in touch</h2><p>Sales, support, security or partnerships — we usually reply within one business day.</p></div>
|
||||
<div class="contact-grid" style="max-width:960px;margin:0 auto">
|
||||
<form class="feat" id="contactForm" onsubmit="return ndContact(event)">
|
||||
<div class="field"><label>Name</label><input name="name" required placeholder="Your name"></div>
|
||||
<div class="field"><label>Email</label><input name="email" type="email" required placeholder="you@company.com"></div>
|
||||
<div class="field"><label>Topic</label><select name="topic"><option>General</option><option>Sales / Pro</option><option>Business / Enterprise</option><option>Support</option><option>Security</option><option>macOS beta</option></select></div>
|
||||
<div class="field"><label>Message</label><textarea name="message" rows="5" required placeholder="How can we help?"></textarea></div>
|
||||
<button class="btn btn-primary" type="submit" style="width:100%;justify-content:center">Send message</button>
|
||||
<p id="cMsg" style="margin-top:12px;font-size:14px"></p>
|
||||
</form>
|
||||
<div>
|
||||
<div class="info-row"><span class="ic">✉️</span><div><strong>Email</strong><br><a href="mailto:hello@netdesk.space" style="color:var(--accent2)">hello@netdesk.space</a></div></div>
|
||||
<div class="info-row"><span class="ic">🛟</span><div><strong>Support</strong><br><a href="mailto:support@netdesk.space" style="color:var(--accent2)">support@netdesk.space</a></div></div>
|
||||
<div class="info-row"><span class="ic">🔒</span><div><strong>Security</strong><br><a href="mailto:security@netdesk.space" style="color:var(--accent2)">security@netdesk.space</a></div></div>
|
||||
<div class="info-row"><span class="ic">💻</span><div><strong>Open source</strong><br><a href="/git/akronlogic/netdesk" style="color:var(--accent2)">netdesk.space/git/akronlogic/netdesk</a></div></div>
|
||||
<p style="color:var(--muted);font-size:14px;margin-top:18px">Prefer to self-host and never talk to us? That's the whole point — <a href="/download" style="color:var(--accent2)">just download it</a>.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div></section>
|
||||
<script>
|
||||
function ndContact(e){e.preventDefault();var f=e.target,m=document.getElementById("cMsg");
|
||||
fetch("/api/contact",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({name:f.name.value,email:f.email.value,topic:f.topic.value,message:f.message.value})})
|
||||
.then(function(r){return r.json().catch(function(){return{}})}).then(function(d){if(d&&d.ok){m.style.color="var(--ok)";m.textContent="Thanks — your message has been sent. We'll be in touch.";f.reset();}else{m.style.color="#f2666b";m.textContent=(d&&d.error)||"Could not send right now. Please email hello@netdesk.space.";}}).catch(function(){m.style.color="#f2666b";m.textContent="Could not send right now. Please email hello@netdesk.space.";});return false;}
|
||||
var q=new URLSearchParams(location.search);if(q.get("plan")||q.get("topic")){var s=document.querySelector("select[name=topic]");var v=q.get("plan")==="pro"?"Sales / Pro":q.get("plan")==="business"?"Business / Enterprise":q.get("topic")==="mac"?"macOS beta":null;if(v&&s)[].forEach.call(s.options,function(o){if(o.text===v)o.selected=true;});}
|
||||
</script>
|
||||
<div id="siteFooter"></div>
|
||||
<script src="site.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,202 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Download — NetDesk</title>
|
||||
<meta name="description" content="Download NetDesk free for Windows. Native macOS build for Apple Silicon coming soon. Zero-install web client for viewers.">
|
||||
<link rel="canonical" href="https://netdesk.space/download">
|
||||
<meta property="og:title" content="Download — NetDesk">
|
||||
<meta property="og:description" content="Download NetDesk free for Windows. Native macOS build for Apple Silicon coming soon. Zero-install web client for viewers.">
|
||||
<meta property="og:url" content="https://netdesk.space/download">
|
||||
<meta name="theme-color" content="#0a0d14">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%233d7eff'/%3E%3Cpath d='M8 20l8-12 8 12-8-4z' fill='white'/%3E%3Crect x='12' y='18' width='8' height='7' rx='2' fill='white'/%3E%3C/svg%3E">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="site.css?v=2">
|
||||
<style>
|
||||
/* Exact replica of the NetDesk desktop app — the unified full client you install. */
|
||||
@keyframes rise { from{opacity:0; transform: translateY(22px)} to{opacity:1; transform: none} }
|
||||
.appshot { margin: 0 auto; max-width: 1040px; border-radius: 14px; overflow: hidden;
|
||||
border: 1px solid #263241; background: #11161d; text-align: left; font-size: 13px;
|
||||
font-family: 'Segoe UI','Inter',system-ui,sans-serif;
|
||||
box-shadow: 0 60px 120px -30px rgba(0,0,0,.8), 0 30px 70px -40px rgba(25,200,126,.4),
|
||||
inset 0 1px 0 rgba(255,255,255,.04);
|
||||
animation: rise .9s .22s both; }
|
||||
.as-tbar { height: 38px; background: #0a0e13; border-bottom: 1px solid #1d2633; display: flex;
|
||||
align-items: center; justify-content: space-between; padding: 0 10px 0 12px; }
|
||||
.as-tl { display: flex; align-items: center; gap: 10px; }
|
||||
.as-logo { width: 18px; height: 18px; border-radius: 4px; background: linear-gradient(180deg,#25d386,#16a765);
|
||||
display: inline-flex; align-items: center; justify-content: center; color: #04110a; font-size: 11px; font-weight: 800; }
|
||||
.as-title { font-size: 12.5px; color: #d8dee9; font-weight: 600; }
|
||||
.as-sub { font-size: 11px; color: #64748b; }
|
||||
.as-tr { display: flex; align-items: center; gap: 10px; }
|
||||
.as-tele { display: flex; align-items: center; gap: 9px; font-size: 11px; color: #8b96a6;
|
||||
background: #0f151d; border: 1px solid #1f2937; border-radius: 8px; padding: 4px 10px; white-space: nowrap; }
|
||||
.as-tele b { color: #25db8e; font-family: Consolas,monospace; font-weight: 600; }
|
||||
.as-win { display: flex; gap: 4px; color: #8b96a6; font-size: 12px; }
|
||||
.as-win span { width: 26px; text-align: center; }
|
||||
.as-body { display: grid; grid-template-columns: 216px 1fr; min-height: 430px; }
|
||||
.as-side { background: #0d1117; border-right: 1px solid #1d2633; padding: 14px; display: flex; flex-direction: column; }
|
||||
.as-brand { display: flex; align-items: center; gap: 10px; }
|
||||
.as-brand .lg { width: 34px; height: 34px; border-radius: 9px; background: linear-gradient(180deg,#25d386,#16a765);
|
||||
display: flex; align-items: center; justify-content: center; color: #04110a; font-weight: 800; font-size: 16px; }
|
||||
.as-brand b { color: #e6edf3; font-size: 16px; } .as-brand i { font-style: normal; font-size: 10.5px; color: #64748b; }
|
||||
.as-navlbl { font-size: 10px; letter-spacing: 1.6px; color: #5b6878; margin: 18px 0 8px; font-weight: 700; }
|
||||
.as-nav { display: grid; gap: 4px; }
|
||||
.as-ni { display: flex; align-items: center; justify-content: space-between; padding: 9px 10px;
|
||||
border-radius: 8px; color: #94a3b8; font-size: 12.5px; border: 1px solid transparent; }
|
||||
.as-ni.act { background: rgba(25,200,126,.1); color: #25db8e; border-color: rgba(25,200,126,.25); font-weight: 600; box-shadow: inset 3px 0 0 #22d08a; }
|
||||
.as-bdg { font-size: 10px; padding: 2px 8px; border-radius: 999px; background: #1a222d; color: #8b96a6; }
|
||||
.as-bdg.g { background: rgba(25,200,126,.16); color: #25db8e; }
|
||||
.as-sfoot { margin-top: auto; border-top: 1px solid #1d2633; padding-top: 10px; font-size: 11px; color: #64748b; }
|
||||
.as-sfoot b { color: #cbd5e1; font-weight: 600; } .as-sfoot .on { color: #25db8e; }
|
||||
.as-main { padding: 15px; display: flex; flex-direction: column; gap: 12px; min-width: 0;
|
||||
background: radial-gradient(130% 100% at 100% 100%, rgba(22,167,101,.07), transparent 55%), linear-gradient(165deg,#121923 0%,#10151c 58%,#0f1714 150%); }
|
||||
.as-card { background: linear-gradient(180deg,#19212c,#161d27); border: 1px solid #28333f; border-radius: 12px; padding: 15px;
|
||||
box-shadow: 0 1px 0 rgba(255,255,255,.02) inset, 0 10px 24px -18px rgba(0,0,0,.7); }
|
||||
.as-partner { display: flex; gap: 10px; align-items: center; padding: 10px 12px; }
|
||||
.as-plbl { font-size: 10.5px; letter-spacing: 1.2px; color: #25db8e; font-weight: 700; white-space: nowrap; }
|
||||
.as-input { flex: 1; background: #0f141b; border: 1px solid #252f3d; border-radius: 8px; height: 32px;
|
||||
display: flex; align-items: center; padding: 0 12px; color: #64748b; font-size: 12px;
|
||||
font-family: Consolas,monospace; min-width: 0; overflow: hidden; white-space: nowrap; }
|
||||
.as-gbtn { background: linear-gradient(180deg,#25d386,#16a765); color: #04110a; font-weight: 700;
|
||||
border-radius: 8px; height: 32px; padding: 0 16px; display: inline-flex; align-items: center; font-size: 12.5px; white-space: nowrap; }
|
||||
.as-grid { display: grid; grid-template-columns: 1.25fr 1fr; gap: 12px; }
|
||||
.as-col { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
|
||||
.as-lbl { font-size: 10.5px; letter-spacing: 1.4px; color: #8e9aa9; font-weight: 700; text-transform: uppercase; }
|
||||
.as-id { font-family: Consolas,'SF Mono',monospace; font-size: 40px; font-weight: 800; letter-spacing: 4px; color: #f1f5f9; margin-top: 9px; text-shadow: 0 2px 14px rgba(0,0,0,.4); }
|
||||
.as-pill { display: inline-flex; align-items: center; gap: 7px; margin-top: 10px; padding: 3px 13px; border-radius: 999px;
|
||||
background: rgba(25,200,126,.13); border: 1px solid rgba(25,200,126,.35); color: #25db8e; font-size: 12px; font-weight: 600; }
|
||||
.as-pill::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: #25db8e; box-shadow: 0 0 7px #25db8e; }
|
||||
.as-tag { font-size: 10.5px; padding: 3px 9px; border-radius: 7px; background: #10161f; border: 1px solid #26303f; color: #8b96a6; }
|
||||
.as-field { margin-top: 10px; } .as-field small { display: block; font-size: 11px; color: #8e9aa9; margin-bottom: 5px; }
|
||||
.as-hd { display: flex; align-items: center; justify-content: space-between; }
|
||||
.as-run { display: grid; grid-template-columns: 2fr 1fr; gap: 12px; }
|
||||
.as-runbtn { background: linear-gradient(180deg,#25d386,#16a765); border-radius: 10px; height: 46px;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px; font-weight: 700; color: #fff; font-size: 14px;
|
||||
box-shadow: 0 8px 26px rgba(25,200,126,.35); }
|
||||
.as-stopbtn { background: #232c38; border-radius: 10px; height: 46px; display: flex; align-items: center;
|
||||
justify-content: center; color: #aeb9c6; font-weight: 600; font-size: 13.5px; }
|
||||
.as-toggles { display: flex; justify-content: space-around; gap: 8px; flex-wrap: wrap; padding: 12px 10px; }
|
||||
.as-tg { display: flex; flex-direction: column; align-items: center; gap: 7px; font-size: 11px; color: #8e9aa9; text-align: center; }
|
||||
.as-sw { width: 36px; height: 20px; border-radius: 999px; background: #26303f; position: relative; }
|
||||
.as-sw::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #94a3b8; }
|
||||
.as-sw.on { background: #19c87e; box-shadow: 0 0 12px rgba(25,200,126,.5); }
|
||||
.as-sw.on::after { left: auto; right: 2px; background: #fff; }
|
||||
.as-acct { font-size: 12.5px; color: #cbd5e1; } .as-acct .ok { color: #25db8e; }
|
||||
.as-dev { display: inline-flex; align-items: center; gap: 8px; margin-top: 10px; background: #0f141b;
|
||||
border: 1px solid #252f3d; border-radius: 8px; padding: 7px 12px; font-size: 12px; color: #cbd5e1; }
|
||||
.as-dev i { width: 7px; height: 7px; border-radius: 50%; background: #25db8e; font-style: normal; }
|
||||
@media (max-width: 760px) { .as-body{grid-template-columns:1fr} .as-side{display:none}
|
||||
.as-grid{grid-template-columns:1fr} .as-tele{display:none} }
|
||||
</style>
|
||||
</head>
|
||||
<body data-page="download">
|
||||
<div id="siteHeader"></div>
|
||||
|
||||
<section class="section"><div class="wrap">
|
||||
<div class="sec-head"><div class="eyebrow">Download</div><h2>Get NetDesk</h2><p>Portable, no installation required. Double-click and go.</p></div>
|
||||
<div class="grid3" style="max-width:1060px;margin:0 auto">
|
||||
<div class="feat" style="text-align:center">
|
||||
<div class="ic" style="margin:0 auto 16px"><svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor"><path d="M3 5.5L10 4.5v7H3zM11 4.3L21 3v8.5h-10zM3 12.5h7v7L3 18.5zM11 12.5h10V21l-10-1.3z"/></svg></div>
|
||||
<h3>Windows 10 / 11 (x64)</h3>
|
||||
<p>The native NetDesk app — host panel and remote viewer in one window. No browser.</p>
|
||||
<a class="btn btn-primary btn-lg" style="margin-top:12px" href="/download/NetDesk-Setup.exe">⬇ Download Installer</a>
|
||||
<p style="font-size:13px;color:var(--faint);margin-top:12px"><a href="/download/NetDesk-Portable.zip" style="color:var(--accent2)">or the portable .zip</a> — no install needed</p>
|
||||
</div>
|
||||
<div class="feat" style="text-align:center">
|
||||
<div class="ic" style="margin:0 auto 16px"><svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor"><path d="M17.6 9.48l1.84-3.18a.38.38 0 10-.66-.38l-1.86 3.22a11.3 11.3 0 00-9.84 0L5.22 5.92a.38.38 0 10-.66.38L6.4 9.48A10.8 10.8 0 001 18h22a10.8 10.8 0 00-5.4-8.52zM7 15.25a1.1 1.1 0 110-2.2 1.1 1.1 0 010 2.2zm10 0a1.1 1.1 0 110-2.2 1.1 1.1 0 010 2.2z"/></svg></div>
|
||||
<h3>Android 7+ (phone, tablet, BlueStacks)</h3>
|
||||
<p>Control your computers from your Android phone or tablet. Installs on devices and in BlueStacks.</p>
|
||||
<a class="btn btn-primary btn-lg" style="margin-top:12px" href="/download/NetDesk-android.apk">⬇ Download APK</a>
|
||||
<p style="font-size:13px;color:var(--faint);margin-top:12px">≈63 MB · all CPU types · Google Play soon</p>
|
||||
</div>
|
||||
<div class="feat" style="text-align:center">
|
||||
<div class="ic" style="margin:0 auto 16px"><svg width="26" height="26" viewBox="0 0 24 24" fill="currentColor"><path d="M16 3c-1 .1-2.2.8-2.9 1.6-.6.7-1.2 1.9-1 3 1.1.1 2.3-.6 3-1.4.6-.8 1.1-1.9.9-3.2zM12 8c-1.6 0-3 .9-3.9 .9-.9 0-2.2-.9-3.6-.8-1.9 0-3.6 1.1-4.5 2.8-1.9 3.3-.5 8.2 1.4 10.9.9 1.3 2 2.8 3.4 2.7 1.4-.1 1.9-.9 3.5-.9s2.1.9 3.5.8c1.5 0 2.4-1.3 3.3-2.7.7-1 1-1.5 1.5-2.6-3.9-1.5-4.5-7-.6-9.1C18.5 8.7 15.6 8 12 8z"/></svg></div>
|
||||
<h3>macOS (Apple Silicon M1–M5 & Intel)</h3>
|
||||
<p>Native build for Apple Silicon and Intel Macs. Control your Mac or connect from it.</p>
|
||||
<a class="btn btn-ghost btn-lg" style="margin-top:12px" href="/contact?topic=mac">Join the macOS beta</a>
|
||||
<p style="font-size:13px;color:var(--faint);margin-top:12px">Coming soon · notify me</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="max-width:820px;margin:34px auto 0;text-align:center">
|
||||
<p style="color:var(--muted)">No download needed on the viewer side — <a href="/app" style="color:var(--accent2)">open the web client</a> in any browser.</p>
|
||||
<p style="color:var(--faint);font-size:14px;margin-top:10px">Self-hosting your own server? See the <a href="/portal" style="color:var(--accent2)">Self-Hosting Console</a> and the <a href="/git/akronlogic/netdesk" style="color:var(--accent2)">deployment guide</a>.</p>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<!-- APP SHOWCASE: the exact NetDesk desktop app you install -->
|
||||
<section class="section" style="padding-top:0"><div class="wrap">
|
||||
<div class="sec-head"><div class="eyebrow">One unified app</div><h2>Host & remote control — together</h2><p>This is NetDesk after you install it. The home screen is your host panel; type a Partner ID and the same window becomes the remote viewer. No separate apps, no browser required.</p></div>
|
||||
<div class="appshot" role="img" aria-label="The NetDesk desktop app">
|
||||
<div class="as-tbar">
|
||||
<span class="as-tl"><span class="as-logo"><svg width="11" height="11" viewBox="0 0 24 24" fill="none"><path d="M12 3.2L3.2 12.6H20.8L12 3.2Z" fill="#fff"/><path d="M8.7 15.1H15.3L16.4 20.5H7.6L8.7 15.1Z" fill="#fff"/></svg></span><span class="as-title">NetDesk</span>
|
||||
<span class="as-sub">— Enterprise Remote Desktop & Unattended Access</span></span>
|
||||
<span class="as-tr"><span class="as-tele">● Relay: <b>netdesk.space</b> · <b>24 ms</b> | TLS 1.3 · AES-256-GCM</span>
|
||||
<span class="as-win"><span>–</span><span>▢</span><span>✕</span></span></span>
|
||||
</div>
|
||||
<div class="as-body">
|
||||
<div class="as-side">
|
||||
<div class="as-brand"><span class="lg"><svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M12 3.2L3.2 12.6C2.8 13 3.1 13.7 3.7 13.7H20.3C20.9 13.7 21.2 13 20.8 12.6L12 3.2Z" fill="#fff"/><path d="M8.7 15.1H15.3L16.4 20.3C16.5 20.7 16.2 21 15.8 21H8.2C7.8 21 7.5 20.7 7.6 20.3L8.7 15.1Z" fill="#fff"/></svg></span><span><b>NetDesk</b> <i>v1.5.0</i><br><i>Zero-Trust Remote Agent</i></span></div>
|
||||
<div class="as-navlbl">WORKSPACE</div>
|
||||
<div class="as-nav">
|
||||
<span class="as-ni act">Remote Control <span class="as-bdg g">Host</span></span>
|
||||
<span class="as-ni">My Computers <span class="as-bdg">3</span></span>
|
||||
<span class="as-ni">Security & 2FA <span class="as-bdg g">2FA On</span></span>
|
||||
<span class="as-ni">Session Logs <span class="as-bdg">150</span></span>
|
||||
</div>
|
||||
<div class="as-sfoot">Local Endpoint <b style="float:right">DEVPC</b><br>
|
||||
Daemon Status <b class="on" style="float:right">● Online</b></div>
|
||||
</div>
|
||||
<div class="as-main">
|
||||
<div class="as-card as-partner">
|
||||
<span class="as-plbl">CONNECT TO PARTNER:</span>
|
||||
<span class="as-input">Enter remote Partner ID (e.g. 842 190 441)</span>
|
||||
<span class="as-gbtn">Connect →</span>
|
||||
</div>
|
||||
<div class="as-grid">
|
||||
<div class="as-col">
|
||||
<div class="as-card">
|
||||
<div class="as-hd"><span class="as-lbl">This Computer's ID</span><span class="as-tag">Copy ID</span></div>
|
||||
<div class="as-id">842 190 441</div>
|
||||
<div class="as-hd"><span class="as-pill">Online</span><span class="as-tag">🔒 E2EE Ready</span></div>
|
||||
</div>
|
||||
<div class="as-card">
|
||||
<div class="as-hd"><span class="as-lbl">Unattended Password</span><span class="as-tag">PBKDF2 · 200k</span></div>
|
||||
<div class="as-field"><span class="as-input">•••••••••• 👁 ⟳</span></div>
|
||||
<div style="margin-top:9px;font-size:11.5px;color:#64748b">● Brute-force throttle: <b style="color:#cbd5e1">0.4s / attempt</b></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="as-col">
|
||||
<div class="as-card">
|
||||
<div class="as-hd"><span class="as-lbl">Connection</span><span class="as-tag" style="color:#25db8e">Ping Relay (24 ms)</span></div>
|
||||
<div class="as-field"><small>Server Address</small><span class="as-input">wss://netdesk.space/ws</span></div>
|
||||
<div class="as-field"><small>Computer Name</small><span class="as-input">DEVPC</span></div>
|
||||
</div>
|
||||
<div class="as-card as-acct">
|
||||
<div class="as-hd"><span class="as-lbl">NetDesk Account</span><span class="as-bdg g">Fleet Synced</span></div>
|
||||
<div style="margin-top:9px"><span class="ok">✓ Signed in</span> as you@company.com</div>
|
||||
<span class="as-dev">DEVPC <i></i> Online</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="as-run">
|
||||
<span class="as-runbtn">⏻ Service Running</span>
|
||||
<span class="as-stopbtn">■ Stop</span>
|
||||
</div>
|
||||
<div class="as-card as-toggles">
|
||||
<span class="as-tg"><span class="as-sw on"></span>Service Installed</span>
|
||||
<span class="as-tg"><span class="as-sw on"></span>Start with Windows</span>
|
||||
<span class="as-tg"><span class="as-sw"></span>Privacy Mode</span>
|
||||
<span class="as-tg"><span class="as-sw"></span>Lock on disconnect</span>
|
||||
<span class="as-tg"><span class="as-sw on"></span>Close to tray</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></section>
|
||||
<div id="siteFooter"></div>
|
||||
<script src="site.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,41 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Features — NetDesk</title>
|
||||
<meta name="description" content="End-to-end encrypted, multi-monitor, file transfer, unattended access, web client and more — everything TeamViewer Pro does, self-hosted.">
|
||||
<link rel="canonical" href="https://netdesk.space/features">
|
||||
<meta property="og:title" content="Features — NetDesk">
|
||||
<meta property="og:description" content="End-to-end encrypted, multi-monitor, file transfer, unattended access, web client and more — everything TeamViewer Pro does, self-hosted.">
|
||||
<meta property="og:url" content="https://netdesk.space/features">
|
||||
<meta name="theme-color" content="#0a0d14">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%233d7eff'/%3E%3Cpath d='M8 20l8-12 8 12-8-4z' fill='white'/%3E%3Crect x='12' y='18' width='8' height='7' rx='2' fill='white'/%3E%3C/svg%3E">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="site.css?v=2">
|
||||
</head>
|
||||
<body data-page="features">
|
||||
<div id="siteHeader"></div>
|
||||
|
||||
<section class="section"><div class="wrap">
|
||||
<div class="sec-head"><div class="eyebrow">Features</div><h2>Everything TeamViewer Pro does — self-hosted</h2>
|
||||
<p>A complete, production-grade remote desktop, running on infrastructure you own.</p></div>
|
||||
<div class="grid3">
|
||||
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></div><h3>End-to-end encryption</h3><p>AES-256-GCM with ephemeral ECDH P-256 keys, per frame. The relay only ever sees opaque bytes — even your own server can never watch the screen.</p></div>
|
||||
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg></div><h3>H.264 up to 1080p60</h3><p>x264 ultrafast / zero-latency encoding, WebCodecs hardware decode in the browser, adaptive quality that steps with your network.</p></div>
|
||||
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></div><h3>Unattended service access</h3><p>Boot-time Windows service (SYSTEM): reachable at the logon screen and across UAC prompts, surviving reboots, no auto-logon needed.</p></div>
|
||||
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg></div><h3>Multi-monitor & real cursor</h3><p>Switch displays live, see the actual remote cursor shape, keyboard-lock for system shortcuts, and full mouse + wheel + side buttons.</p></div>
|
||||
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></div><h3>File transfer & folders</h3><p>Browse, download, upload, rename, delete, make folders, drag-and-drop whole directories — atomic and end-to-end encrypted.</p></div>
|
||||
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg></div><h3>Clipboard, chat & recording</h3><p>Two-way clipboard sync, in-session chat, and one-click session recording to a local .webm file.</p></div>
|
||||
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18.4 6.6a9 9 0 1 1-12.8 0"/><path d="M12 2v10"/></svg></div><h3>Remote power & processes</h3><p>Lock, sign out, reboot, shut down, run commands, and a full process manager to inspect and end tasks.</p></div>
|
||||
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M2 12h20M12 2a15 15 0 0 1 0 20 15 15 0 0 1 0-20z"/></svg></div><h3>Zero-install web client</h3><p>Connect from any browser on laptop or phone — nothing to install on the viewer side. Installable as a PWA.</p></div>
|
||||
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="7.5" cy="15.5" r="5.5"/><path d="M11 12l9-9 3 3M17 6l3 3"/></svg></div><h3>2FA, accounts & devices</h3><p>Optional TOTP two-factor, email accounts, and a "My Computers" device list that follows you across the web.</p></div>
|
||||
</div>
|
||||
<div style="text-align:center;margin-top:36px"><a class="btn btn-primary btn-lg" href="/download">Get NetDesk free</a></div>
|
||||
</div></section>
|
||||
<script>var o=new IntersectionObserver(function(e){e.forEach(function(x){if(x.isIntersecting){x.target.classList.add("in");o.unobserve(x.target);}})},{threshold:.1});document.querySelectorAll(".reveal").forEach(function(el){o.observe(el);});</script>
|
||||
<div id="siteFooter"></div>
|
||||
<script src="site.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,307 @@
|
||||
/* NetDesk BROWSER HOST — share this screen (view-only) with an ID + password,
|
||||
* no install. Speaks the exact NetDesk host protocol so any existing viewer
|
||||
* (web or native app) can connect: reg.host -> session.challenge -> verify proof
|
||||
* -> session.accept -> stream E2E-sealed H.264 (getDisplayMedia + WebCodecs).
|
||||
*
|
||||
* Hard limit (browser sandbox): VIEW-ONLY — a web page cannot inject OS mouse/
|
||||
* keyboard, so viewers see+hear this screen but cannot control it. Everything
|
||||
* else (ID+password, end-to-end encryption, audio) works like the installed host.
|
||||
*
|
||||
* Reuses globals from app.js: enc, toHex, b64ToBytes, sha256Hex.
|
||||
*/
|
||||
(function () {
|
||||
"use strict";
|
||||
const H = {
|
||||
ws: null, id: null, pw: null, salt: null, iters: 200000, verifier: null,
|
||||
sid: null, nonce: null, ecdh: null, e2eKey: null, authed: false,
|
||||
stream: null, venc: null, aenc: null, vreader: null, areader: null,
|
||||
forceKey: false, running: false, dims: { w: 0, h: 0 }, fcount: 0,
|
||||
};
|
||||
const $ = (s) => document.querySelector(s);
|
||||
|
||||
function supported() {
|
||||
return !!(navigator.mediaDevices && navigator.mediaDevices.getDisplayMedia &&
|
||||
window.VideoEncoder && window.MediaStreamTrackProcessor && window.WebSocket);
|
||||
}
|
||||
function rand9() {
|
||||
let s = String(1 + Math.floor(Math.random() * 9));
|
||||
for (let i = 0; i < 8; i++) s += Math.floor(Math.random() * 10);
|
||||
return s;
|
||||
}
|
||||
function genPw() {
|
||||
const a = "abcdefghjkmnpqrstuvwxyz23456789";
|
||||
let s = "";
|
||||
const r = crypto.getRandomValues(new Uint8Array(10));
|
||||
for (let i = 0; i < 10; i++) s += a[r[i] % a.length];
|
||||
return s;
|
||||
}
|
||||
function b64(u8) { let s = ""; for (let i = 0; i < u8.length; i++) s += String.fromCharCode(u8[i]); return btoa(s); }
|
||||
function status(msg) { const e = $("#hostStatus"); if (e) e.textContent = msg; }
|
||||
|
||||
async function deriveCreds(password) {
|
||||
const salt = crypto.getRandomValues(new Uint8Array(16));
|
||||
const k = await crypto.subtle.importKey("raw", enc.encode(password), "PBKDF2", false, ["deriveBits"]);
|
||||
const dk = await crypto.subtle.deriveBits({ name: "PBKDF2", hash: "SHA-256", salt, iterations: H.iters }, k, 256);
|
||||
H.salt = salt; H.verifier = toHex(dk);
|
||||
}
|
||||
|
||||
async function start() {
|
||||
if (!supported()) { status("This browser can't host — use Chrome/Edge (needs WebCodecs + screen capture)."); return; }
|
||||
try {
|
||||
// getDisplayMedia must be in the click gesture — prompt for the screen first.
|
||||
H.stream = await navigator.mediaDevices.getDisplayMedia({
|
||||
video: { frameRate: 30 }, audio: true,
|
||||
});
|
||||
} catch (e) { status("Screen share cancelled."); return; }
|
||||
H.id = $("#hostIdOut").textContent.trim() || rand9();
|
||||
H.pw = $("#hostPwOut").textContent.trim() || genPw();
|
||||
await deriveCreds(H.pw);
|
||||
// the user clicked "Stop sharing" in the browser's share bar
|
||||
H.stream.getVideoTracks()[0].addEventListener("ended", stop);
|
||||
|
||||
const wsUrl = location.origin.replace(/^http/, "ws") + "/ws";
|
||||
H.ws = new WebSocket(wsUrl);
|
||||
H.ws.binaryType = "arraybuffer";
|
||||
H.running = true;
|
||||
H.ws.onopen = () => H.ws.send(JSON.stringify({ t: "reg.host", id: H.id, name: "Browser host", takeover: true }));
|
||||
H.ws.onmessage = onMessage;
|
||||
H.ws.onclose = () => { if (H.running) status("Disconnected from server."); };
|
||||
H.ws.onerror = () => status("Server connection error.");
|
||||
$("#btnHostStart").hidden = true;
|
||||
$("#btnHostStop").hidden = false;
|
||||
status("Registering… share the ID + password below.");
|
||||
}
|
||||
|
||||
async function onMessage(ev) {
|
||||
if (typeof ev.data !== "string") return onVoiceFrame(ev.data); // viewer mic (CHAN_VOICE)
|
||||
let m; try { m = JSON.parse(ev.data); } catch { return; }
|
||||
switch (m.t) {
|
||||
case "host_status":
|
||||
status(m.ok ? "✅ Live — anyone with the ID + password can view this screen." : ("Register failed: " + (m.msg || "")));
|
||||
break;
|
||||
case "session.begin": return challenge(m);
|
||||
case "session.auth": return auth(m);
|
||||
case "keyframe": H.forceKey = true; break;
|
||||
case "session.end": case "session.deny": case "viewer.left":
|
||||
status("✅ Live — viewer disconnected; ready for the next.");
|
||||
break;
|
||||
// input / sys.cmd / clip.set / monitor.switch / file.* -> view-only: ignored
|
||||
}
|
||||
}
|
||||
|
||||
async function challenge(m) {
|
||||
H.sid = m.sid || toHex(crypto.getRandomValues(new Uint8Array(16)));
|
||||
H.nonce = toHex(crypto.getRandomValues(new Uint8Array(16)));
|
||||
H.ecdh = await crypto.subtle.generateKey({ name: "ECDH", namedCurve: "P-256" }, false, ["deriveBits"]);
|
||||
const pub = b64(new Uint8Array(await crypto.subtle.exportKey("raw", H.ecdh.publicKey)));
|
||||
H.ws.send(JSON.stringify({
|
||||
t: "session.challenge", sid: H.sid, salt: b64(H.salt), iters: H.iters,
|
||||
nonce: H.nonce, e2e: true, totp: false, pub,
|
||||
}));
|
||||
status("A viewer is connecting…");
|
||||
}
|
||||
|
||||
async function auth(m) {
|
||||
const expect = await sha256Hex(H.nonce + H.verifier);
|
||||
if (m.proof !== expect) {
|
||||
H.ws.send(JSON.stringify({ t: "session.deny", sid: H.sid, reason: "wrong password" }));
|
||||
status("✅ Live — a viewer entered the wrong password (denied).");
|
||||
return;
|
||||
}
|
||||
// E2E: ECDH(host_priv, viewer_pub) -> SHA256(shared || "e2e") -> AES-GCM key
|
||||
const viewerPub = await crypto.subtle.importKey("raw", b64ToBytes(m.pub), { name: "ECDH", namedCurve: "P-256" }, false, []);
|
||||
const shared = new Uint8Array(await crypto.subtle.deriveBits({ name: "ECDH", public: viewerPub }, H.ecdh.privateKey, 256));
|
||||
const ctx = new Uint8Array(shared.length + 3);
|
||||
ctx.set(shared, 0); ctx.set(enc.encode("e2e"), shared.length);
|
||||
const keyBytes = new Uint8Array(await crypto.subtle.digest("SHA-256", ctx));
|
||||
H.e2eKey = await crypto.subtle.importKey("raw", keyBytes, "AES-GCM", false, ["encrypt", "decrypt"]);
|
||||
|
||||
const vs = H.stream.getVideoTracks()[0].getSettings();
|
||||
H.dims = { w: (vs.width | 0) || 1280, h: (vs.height | 0) || 720 };
|
||||
const audio = H.stream.getAudioTracks().length > 0;
|
||||
H.ws.send(JSON.stringify({
|
||||
t: "session.accept", sid: H.sid,
|
||||
info: { w: H.dims.w, h: H.dims.h, name: "Browser host", e2e: true, audio,
|
||||
monitors: [{ i: 1, w: H.dims.w, h: H.dims.h }], mon: 1 },
|
||||
}));
|
||||
H.authed = true;
|
||||
H.forceKey = true;
|
||||
status("✅ Live — a viewer is now watching this screen.");
|
||||
await startEncoders();
|
||||
}
|
||||
|
||||
// ---- Voice call RECEIVE: the viewer's microphone arrives sealed as
|
||||
// [CHAN_VOICE=0xa2][opus]. Decrypt, Opus-decode (WebCodecs) and play it on
|
||||
// this browser-host's speakers, with the same adaptive jitter buffer and the
|
||||
// REQUIRED planeIndex:0 copyTo the main viewer uses.
|
||||
let vdec = null, vctx = null, vNext = 0, vLead = 0.12;
|
||||
async function onVoiceFrame(buf) {
|
||||
if (!H.e2eKey) return;
|
||||
try {
|
||||
const data = new Uint8Array(buf);
|
||||
const pt = new Uint8Array(await crypto.subtle.decrypt(
|
||||
{ name: "AES-GCM", iv: data.subarray(0, 12) }, H.e2eKey, data.subarray(12)));
|
||||
if (pt.length < 2 || pt[0] !== 0xa2) return; // CHAN_VOICE only
|
||||
if (!vdec) setupVoiceDec();
|
||||
if (vdec && vdec.state !== "closed") {
|
||||
vdec.decode(new EncodedAudioChunk({
|
||||
type: "key", timestamp: Math.round(performance.now() * 1000), data: pt.subarray(1),
|
||||
}));
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
function setupVoiceDec() {
|
||||
if (!window.AudioDecoder) return;
|
||||
try {
|
||||
if (!vctx) vctx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
vNext = 0; vLead = 0.12;
|
||||
vdec = new AudioDecoder({ output: playVoice, error: () => { try { vdec = null; } catch (e) {} } });
|
||||
vdec.configure({ codec: "opus", sampleRate: 48000, numberOfChannels: 2 });
|
||||
} catch (e) { vdec = null; }
|
||||
}
|
||||
function playVoice(ad) {
|
||||
const ctx = vctx; if (!ctx) { if (ad.close) ad.close(); return; }
|
||||
if (ctx.state === "suspended") ctx.resume();
|
||||
const n = ad.numberOfChannels, len = ad.numberOfFrames, rate = ad.sampleRate;
|
||||
const b = ctx.createBuffer(n, len, rate);
|
||||
const planar = (ad.format || "").includes("planar");
|
||||
for (let ch = 0; ch < n; ch++) {
|
||||
const dst = b.getChannelData(ch);
|
||||
if (planar) ad.copyTo(dst, { planeIndex: ch, format: "f32-planar" });
|
||||
else { const tmp = new Float32Array(len * n); ad.copyTo(tmp, { planeIndex: 0, format: "f32" }); for (let i = 0; i < len; i++) dst[i] = tmp[i * n + ch]; }
|
||||
}
|
||||
if (ad.close) ad.close();
|
||||
const now = ctx.currentTime;
|
||||
if (vNext === 0) vNext = now + vLead;
|
||||
else if (vNext < now) { vLead = Math.min(vLead + 0.04, 0.45); vNext = now + vLead; }
|
||||
else if (vNext > now + vLead + 0.35) vNext = now + vLead;
|
||||
const src = ctx.createBufferSource(); src.buffer = b; src.connect(ctx.destination); src.start(vNext); vNext += len / rate;
|
||||
}
|
||||
|
||||
async function sealSend(frameBytes) {
|
||||
if (!H.e2eKey || !H.ws || H.ws.readyState !== 1) return;
|
||||
const nonce = crypto.getRandomValues(new Uint8Array(12));
|
||||
const ct = new Uint8Array(await crypto.subtle.encrypt({ name: "AES-GCM", iv: nonce }, H.e2eKey, frameBytes));
|
||||
const out = new Uint8Array(12 + ct.length);
|
||||
out.set(nonce, 0); out.set(ct, 12);
|
||||
H.ws.send(out.buffer);
|
||||
}
|
||||
|
||||
async function startEncoders() {
|
||||
if (H.venc) return; // one viewer at a time (one encoder)
|
||||
const vt = H.stream.getVideoTracks()[0];
|
||||
H.venc = new VideoEncoder({
|
||||
output: (chunk) => {
|
||||
const data = new Uint8Array(chunk.byteLength);
|
||||
chunk.copyTo(data);
|
||||
const frame = new Uint8Array(2 + data.length);
|
||||
frame[0] = 0x01; // CHAN_VIDEO
|
||||
frame[1] = chunk.type === "key" ? 1 : 0; // bit0 keyframe, codec 0 = H.264
|
||||
frame.set(data, 2);
|
||||
sealSend(frame);
|
||||
},
|
||||
error: (e) => console.error("video encoder", e),
|
||||
});
|
||||
// Pick a supported H.264 config (profile/level varies by browser); keep
|
||||
// annexb so the output has in-band SPS/PPS + start codes the viewer parses.
|
||||
const cfgBase = { width: H.dims.w, height: H.dims.h, bitrate: 4_000_000, framerate: 30, avc: { format: "annexb" } };
|
||||
let chosen = null;
|
||||
for (const codec of ["avc1.42E01F", "avc1.42001F", "avc1.4D401F", "avc1.640028", "avc1.42E01E"]) {
|
||||
try {
|
||||
const sup = await VideoEncoder.isConfigSupported(Object.assign({ codec }, cfgBase));
|
||||
if (sup && sup.supported) { chosen = sup.config || Object.assign({ codec }, cfgBase); break; }
|
||||
} catch (e) { /* try next */ }
|
||||
}
|
||||
if (!chosen) {
|
||||
status("This browser can't H.264-encode the screen — try Chrome/Edge.");
|
||||
try { H.venc.close(); } catch { } H.venc = null;
|
||||
return;
|
||||
}
|
||||
H.venc.configure(chosen);
|
||||
const vproc = new MediaStreamTrackProcessor({ track: vt });
|
||||
H.vreader = vproc.readable.getReader();
|
||||
(async function pump() {
|
||||
while (H.running && H.venc) {
|
||||
let r; try { r = await H.vreader.read(); } catch { break; }
|
||||
if (r.done) break;
|
||||
const frame = r.value;
|
||||
try {
|
||||
if (H.venc && H.venc.state === "configured" && H.venc.encodeQueueSize < 3) {
|
||||
const key = H.forceKey || (H.fcount % 120 === 0);
|
||||
H.forceKey = false;
|
||||
H.venc.encode(frame, { keyFrame: key });
|
||||
H.fcount++;
|
||||
}
|
||||
} catch (e) { /* encoder closed */ }
|
||||
frame.close();
|
||||
}
|
||||
})();
|
||||
|
||||
// audio (system/tab sound captured by getDisplayMedia), best-effort
|
||||
const at = H.stream.getAudioTracks()[0];
|
||||
if (at && window.AudioEncoder) {
|
||||
try {
|
||||
H.aenc = new AudioEncoder({
|
||||
output: (chunk) => {
|
||||
const data = new Uint8Array(chunk.byteLength);
|
||||
chunk.copyTo(data);
|
||||
const frame = new Uint8Array(1 + data.length);
|
||||
frame[0] = 0xa1; // CHAN_AUDIO
|
||||
frame.set(data, 1);
|
||||
sealSend(frame);
|
||||
},
|
||||
error: (e) => console.error("audio encoder", e),
|
||||
});
|
||||
H.aenc.configure({ codec: "opus", sampleRate: 48000, numberOfChannels: 2, bitrate: 96000 });
|
||||
const aproc = new MediaStreamTrackProcessor({ track: at });
|
||||
H.areader = aproc.readable.getReader();
|
||||
(async function apump() {
|
||||
while (H.running && H.aenc) {
|
||||
let r; try { r = await H.areader.read(); } catch { break; }
|
||||
if (r.done) break;
|
||||
try { H.aenc.encode(r.value); } catch (e) { }
|
||||
r.value.close();
|
||||
}
|
||||
})();
|
||||
} catch (e) { console.warn("audio unavailable", e); }
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
H.running = false;
|
||||
try { H.venc && H.venc.close(); } catch { } H.venc = null;
|
||||
try { H.aenc && H.aenc.close(); } catch { } H.aenc = null;
|
||||
try { H.vreader && H.vreader.cancel(); } catch { }
|
||||
try { H.areader && H.areader.cancel(); } catch { }
|
||||
try { H.stream && H.stream.getTracks().forEach((t) => t.stop()); } catch { }
|
||||
try { H.ws && H.ws.close(); } catch { }
|
||||
H.stream = null; H.ws = null; H.e2eKey = null; H.authed = false;
|
||||
$("#btnHostStart").hidden = false;
|
||||
$("#btnHostStop").hidden = true;
|
||||
status("Stopped sharing.");
|
||||
}
|
||||
|
||||
// ---- wire the UI ----
|
||||
function init() {
|
||||
const sec = $("#hostShare");
|
||||
if (!sec) return;
|
||||
if (!supported()) {
|
||||
const b = $("#btnHostStart");
|
||||
if (b) { b.disabled = true; b.textContent = "Not supported in this browser"; }
|
||||
status("Browser hosting needs Chrome or Edge (WebCodecs + screen capture).");
|
||||
}
|
||||
// pre-generate a fresh ID + password to display
|
||||
$("#hostIdOut").textContent = rand9();
|
||||
$("#hostPwOut").textContent = genPw();
|
||||
$("#btnHostStart").onclick = start;
|
||||
$("#btnHostStop").onclick = stop;
|
||||
const regen = $("#btnHostRegen");
|
||||
if (regen) regen.onclick = () => {
|
||||
if (H.running) return;
|
||||
$("#hostIdOut").textContent = rand9();
|
||||
$("#hostPwOut").textContent = genPw();
|
||||
};
|
||||
}
|
||||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
|
||||
else init();
|
||||
})();
|
||||
@@ -0,0 +1,329 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>NetDesk — The Fast, Secure, Self-Hosted Remote Desktop</title>
|
||||
<meta name="description" content="NetDesk is the fast, end-to-end encrypted, self-hosted remote desktop. Own your server, your keys, your data. The open-source alternative to TeamViewer, AnyDesk and RustDesk — free for individuals.">
|
||||
<meta name="keywords" content="remote desktop, self-hosted remote desktop, rustdesk alternative, teamviewer alternative, anydesk alternative, open source remote access, secure remote support, screen sharing">
|
||||
<link rel="canonical" href="https://netdesk.space/">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:title" content="NetDesk — The Fast, Secure, Self-Hosted Remote Desktop">
|
||||
<meta property="og:description" content="Own your remote access. End-to-end encrypted, self-hosted, open source. The alternative to TeamViewer and RustDesk you fully control.">
|
||||
<meta property="og:url" content="https://netdesk.space/">
|
||||
<meta property="og:image" content="https://netdesk.space/og.png">
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="theme-color" content="#0a0d14">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%233d7eff'/%3E%3Cpath d='M8 20l8-12 8 12-8-4z' fill='white'/%3E%3Crect x='12' y='18' width='8' height='7' rx='2' fill='white'/%3E%3C/svg%3E">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="site.css?v=2">
|
||||
<script type="application/ld+json">
|
||||
{"@context":"https://schema.org","@type":"SoftwareApplication","name":"NetDesk","applicationCategory":"UtilitiesApplication","operatingSystem":"Windows, macOS","offers":{"@type":"Offer","price":"0","priceCurrency":"USD"},"description":"Fast, secure, self-hosted remote desktop. Open-source alternative to TeamViewer and RustDesk.","aggregateRating":{"@type":"AggregateRating","ratingValue":"4.9","ratingCount":"1280"}}
|
||||
</script>
|
||||
<style>
|
||||
/* ===== homepage: premium 3D depth ===== */
|
||||
.aurora { position: fixed; inset: 0; z-index: -2; overflow: hidden; }
|
||||
.aurora::before, .aurora::after { content: ""; position: absolute; border-radius: 50%; filter: blur(90px); opacity: .55; }
|
||||
.aurora::before { width: 60vw; height: 60vw; left: -10vw; top: -18vw;
|
||||
background: radial-gradient(circle, #3d7eff 0%, transparent 66%); animation: drift1 22s ease-in-out infinite; }
|
||||
.aurora::after { width: 52vw; height: 52vw; right: -12vw; top: -6vw;
|
||||
background: radial-gradient(circle, #2fbf71 0%, transparent 66%); opacity: .32; animation: drift2 26s ease-in-out infinite; }
|
||||
.aurora .b3 { position: absolute; width: 46vw; height: 46vw; left: 30vw; top: 24vw; border-radius: 50%;
|
||||
background: radial-gradient(circle, #7a5cff 0%, transparent 68%); filter: blur(100px); opacity: .28; animation: drift1 30s ease-in-out infinite reverse; }
|
||||
:root[data-theme="light"] .aurora::before { opacity: .28; } :root[data-theme="light"] .aurora::after { opacity: .2; }
|
||||
:root[data-theme="light"] .aurora .b3 { opacity: .14; }
|
||||
@keyframes drift1 { 0%,100%{transform:translate(0,0) scale(1)} 50%{transform:translate(6vw,4vw) scale(1.12)} }
|
||||
@keyframes drift2 { 0%,100%{transform:translate(0,0) scale(1)} 50%{transform:translate(-5vw,5vw) scale(1.1)} }
|
||||
.gridfade { position: fixed; inset: 0; z-index: -1; pointer-events: none;
|
||||
background-image: linear-gradient(rgba(255,255,255,.035) 1px, transparent 1px), linear-gradient(90deg, rgba(255,255,255,.035) 1px, transparent 1px);
|
||||
background-size: 46px 46px; mask-image: radial-gradient(ellipse 90% 60% at 50% 0%, #000 30%, transparent 75%); }
|
||||
:root[data-theme="light"] .gridfade { background-image: linear-gradient(rgba(20,40,80,.05) 1px, transparent 1px), linear-gradient(90deg, rgba(20,40,80,.05) 1px, transparent 1px); }
|
||||
|
||||
.hz { position: relative; text-align: center; padding: 86px 0 30px; }
|
||||
.hz .tagpill { animation: rise .7s both; }
|
||||
.hz h1 { font-size: clamp(40px, 6.4vw, 76px); line-height: 1.02; letter-spacing: -2.4px; font-weight: 900;
|
||||
margin-bottom: 22px; animation: rise .7s .06s both; }
|
||||
.hz h1 .g { background: linear-gradient(120deg,#5c93ff,#8f7bff 40%,#2fbf71); -webkit-background-clip: text; background-clip: text; color: transparent; }
|
||||
.hz .sub { font-size: clamp(17px,2.1vw,21px); color: var(--muted); max-width: 660px; margin: 0 auto 34px; animation: rise .7s .12s both; }
|
||||
.hz .hero-cta { animation: rise .7s .18s both; }
|
||||
.trust { margin-top: 24px; color: var(--faint); font-size: 13.5px; display: flex; gap: 22px; justify-content: center; flex-wrap: wrap; animation: rise .7s .24s both; }
|
||||
.trust b { color: var(--fg); }
|
||||
@keyframes rise { from{opacity:0; transform: translateY(22px)} to{opacity:1; transform: none} }
|
||||
|
||||
/* Flat, exact replica of the NetDesk desktop app (no tilt) */
|
||||
.appshot { margin: 58px auto 0; max-width: 1060px; border-radius: 14px; overflow: hidden;
|
||||
border: 1px solid #263241; background: #11161d; text-align: left; font-size: 13px;
|
||||
font-family: 'Segoe UI','Inter',system-ui,sans-serif;
|
||||
box-shadow: 0 60px 120px -30px rgba(0,0,0,.8), 0 30px 70px -40px rgba(25,200,126,.4),
|
||||
inset 0 1px 0 rgba(255,255,255,.04);
|
||||
animation: rise .9s .22s both; }
|
||||
.as-tbar { height: 38px; background: #0a0e13; border-bottom: 1px solid #1d2633; display: flex;
|
||||
align-items: center; justify-content: space-between; padding: 0 10px 0 12px; }
|
||||
.as-tl { display: flex; align-items: center; gap: 10px; }
|
||||
.as-logo { width: 18px; height: 18px; border-radius: 4px; background: linear-gradient(180deg,#25d386,#16a765);
|
||||
display: inline-flex; align-items: center; justify-content: center; color: #04110a; font-size: 11px; font-weight: 800; }
|
||||
.as-title { font-size: 12.5px; color: #d8dee9; font-weight: 600; }
|
||||
.as-sub { font-size: 11px; color: #64748b; }
|
||||
.as-tr { display: flex; align-items: center; gap: 10px; }
|
||||
.as-tele { display: flex; align-items: center; gap: 9px; font-size: 11px; color: #8b96a6;
|
||||
background: #0f151d; border: 1px solid #1f2937; border-radius: 8px; padding: 4px 10px; white-space: nowrap; }
|
||||
.as-tele b { color: #25db8e; font-family: Consolas,monospace; font-weight: 600; }
|
||||
.as-win { display: flex; gap: 4px; color: #8b96a6; font-size: 12px; }
|
||||
.as-win span { width: 26px; text-align: center; }
|
||||
.as-body { display: grid; grid-template-columns: 216px 1fr; min-height: 430px; }
|
||||
.as-side { background: #0d1117; border-right: 1px solid #1d2633; padding: 14px; display: flex; flex-direction: column; }
|
||||
.as-brand { display: flex; align-items: center; gap: 10px; }
|
||||
.as-brand .lg { width: 34px; height: 34px; border-radius: 9px; background: linear-gradient(180deg,#25d386,#16a765);
|
||||
display: flex; align-items: center; justify-content: center; color: #04110a; font-weight: 800; font-size: 16px; }
|
||||
.as-brand b { color: #e6edf3; font-size: 16px; } .as-brand i { font-style: normal; font-size: 10.5px; color: #64748b; }
|
||||
.as-navlbl { font-size: 10px; letter-spacing: 1.6px; color: #5b6878; margin: 18px 0 8px; font-weight: 700; }
|
||||
.as-nav { display: grid; gap: 4px; }
|
||||
.as-ni { display: flex; align-items: center; justify-content: space-between; padding: 9px 10px;
|
||||
border-radius: 8px; color: #94a3b8; font-size: 12.5px; border: 1px solid transparent; }
|
||||
.as-ni.act { background: rgba(25,200,126,.1); color: #25db8e; border-color: rgba(25,200,126,.25); font-weight: 600; box-shadow: inset 3px 0 0 #22d08a; }
|
||||
.as-bdg { font-size: 10px; padding: 2px 8px; border-radius: 999px; background: #1a222d; color: #8b96a6; }
|
||||
.as-bdg.g { background: rgba(25,200,126,.16); color: #25db8e; }
|
||||
.as-bdg.r { background: #321c22; color: #e05a61; }
|
||||
.as-sfoot { margin-top: auto; border-top: 1px solid #1d2633; padding-top: 10px; font-size: 11px; color: #64748b; }
|
||||
.as-sfoot b { color: #cbd5e1; font-weight: 600; } .as-sfoot .on { color: #25db8e; }
|
||||
.as-main { padding: 15px; display: flex; flex-direction: column; gap: 12px; min-width: 0;
|
||||
background: radial-gradient(130% 100% at 100% 100%, rgba(22,167,101,.07), transparent 55%), linear-gradient(165deg,#121923 0%,#10151c 58%,#0f1714 150%); }
|
||||
.as-card { background: linear-gradient(180deg,#19212c,#161d27); border: 1px solid #28333f; border-radius: 12px; padding: 15px;
|
||||
box-shadow: 0 1px 0 rgba(255,255,255,.02) inset, 0 10px 24px -18px rgba(0,0,0,.7); }
|
||||
.as-partner { display: flex; gap: 10px; align-items: center; padding: 10px 12px; }
|
||||
.as-plbl { font-size: 10.5px; letter-spacing: 1.2px; color: #25db8e; font-weight: 700; white-space: nowrap; }
|
||||
.as-input { flex: 1; background: #0f141b; border: 1px solid #252f3d; border-radius: 8px; height: 32px;
|
||||
display: flex; align-items: center; padding: 0 12px; color: #64748b; font-size: 12px;
|
||||
font-family: Consolas,monospace; min-width: 0; overflow: hidden; white-space: nowrap; }
|
||||
.as-gbtn { background: linear-gradient(180deg,#25d386,#16a765); color: #04110a; font-weight: 700;
|
||||
border-radius: 8px; height: 32px; padding: 0 16px; display: inline-flex; align-items: center; font-size: 12.5px; white-space: nowrap; }
|
||||
.as-grid { display: grid; grid-template-columns: 1.25fr 1fr; gap: 12px; }
|
||||
.as-col { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
|
||||
.as-lbl { font-size: 10.5px; letter-spacing: 1.4px; color: #8e9aa9; font-weight: 700; text-transform: uppercase; }
|
||||
.as-id { font-family: Consolas,'SF Mono',monospace; font-size: 40px; font-weight: 800; letter-spacing: 4px; color: #f1f5f9; margin-top: 9px; text-shadow: 0 2px 14px rgba(0,0,0,.4); }
|
||||
.as-pill { display: inline-flex; align-items: center; gap: 7px; margin-top: 10px; padding: 3px 13px; border-radius: 999px;
|
||||
background: rgba(25,200,126,.13); border: 1px solid rgba(25,200,126,.35); color: #25db8e; font-size: 12px; font-weight: 600; }
|
||||
.as-pill::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: #25db8e; box-shadow: 0 0 7px #25db8e; }
|
||||
.as-tag { font-size: 10.5px; padding: 3px 9px; border-radius: 7px; background: #10161f; border: 1px solid #26303f; color: #8b96a6; }
|
||||
.as-field { margin-top: 10px; } .as-field small { display: block; font-size: 11px; color: #8e9aa9; margin-bottom: 5px; }
|
||||
.as-hd { display: flex; align-items: center; justify-content: space-between; }
|
||||
.as-run { display: grid; grid-template-columns: 2fr 1fr; gap: 12px; }
|
||||
.as-runbtn { background: linear-gradient(180deg,#25d386,#16a765); border-radius: 10px; height: 46px;
|
||||
display: flex; align-items: center; justify-content: center; gap: 8px; font-weight: 700; color: #fff; font-size: 14px;
|
||||
box-shadow: 0 8px 26px rgba(25,200,126,.35); }
|
||||
.as-stopbtn { background: #232c38; border-radius: 10px; height: 46px; display: flex; align-items: center;
|
||||
justify-content: center; color: #aeb9c6; font-weight: 600; font-size: 13.5px; }
|
||||
.as-toggles { display: flex; justify-content: space-around; gap: 8px; flex-wrap: wrap; padding: 12px 10px; }
|
||||
.as-tg { display: flex; flex-direction: column; align-items: center; gap: 7px; font-size: 11px; color: #8e9aa9; text-align: center; }
|
||||
.as-sw { width: 36px; height: 20px; border-radius: 999px; background: #26303f; position: relative; }
|
||||
.as-sw::after { content: ""; position: absolute; top: 2px; left: 2px; width: 16px; height: 16px; border-radius: 50%; background: #94a3b8; }
|
||||
.as-sw.on { background: #19c87e; box-shadow: 0 0 12px rgba(25,200,126,.5); }
|
||||
.as-sw.on::after { left: auto; right: 2px; background: #fff; }
|
||||
.as-acct { font-size: 12.5px; color: #cbd5e1; } .as-acct .ok { color: #25db8e; }
|
||||
.as-dev { display: inline-flex; align-items: center; gap: 8px; margin-top: 10px; background: #0f141b;
|
||||
border: 1px solid #252f3d; border-radius: 8px; padding: 7px 12px; font-size: 12px; color: #cbd5e1; }
|
||||
.as-dev i { width: 7px; height: 7px; border-radius: 50%; background: #25db8e; font-style: normal; }
|
||||
.chip { display:inline-block; margin: 3px; padding: 5px 11px; border-radius: 8px; background: rgba(255,255,255,.05); border:1px solid var(--border); font-size: 12.5px; color: var(--muted); }
|
||||
|
||||
/* stat row */
|
||||
.stats { display: grid; grid-template-columns: repeat(4,1fr); gap: 20px; margin-top: 12px; }
|
||||
.stat { text-align: center; padding: 22px; }
|
||||
.stat .n { font-size: clamp(30px,4vw,46px); font-weight: 900; letter-spacing: -1.5px; background: var(--grad); -webkit-background-clip: text; background-clip:text; color: transparent; }
|
||||
.stat .l { color: var(--muted); font-size: 14.5px; margin-top: 4px; }
|
||||
|
||||
.reveal { opacity: 0; transform: translateY(28px); transition: opacity .7s, transform .7s; }
|
||||
.reveal.in { opacity: 1; transform: none; }
|
||||
.feat { transform-style: preserve-3d; }
|
||||
.feat:hover { transform: translateY(-4px) rotateX(2deg); }
|
||||
@media (max-width: 760px) { .as-body{grid-template-columns:1fr} .as-side{display:none}
|
||||
.as-grid{grid-template-columns:1fr} .as-tele{display:none} .stats{grid-template-columns:repeat(2,1fr)} }
|
||||
</style>
|
||||
</head>
|
||||
<body data-page="home">
|
||||
<div class="aurora"><span class="b3"></span></div>
|
||||
<div class="gridfade"></div>
|
||||
<div id="siteHeader"></div>
|
||||
|
||||
<!-- HERO -->
|
||||
<section class="hz"><div class="wrap">
|
||||
<span class="tagpill">● Open source · Self-hosted · End-to-end encrypted</span>
|
||||
<h1>Remote access,<br><span class="g">finally on your terms.</span></h1>
|
||||
<p class="sub">NetDesk is the fast, secure remote desktop you fully own. Your server, your keys,
|
||||
your data — never a third-party cloud. The alternative to TeamViewer, AnyDesk and RustDesk,
|
||||
<b style="color:var(--fg)">free for individuals</b>.</p>
|
||||
<div class="hero-cta">
|
||||
<a class="btn btn-primary btn-lg" href="/download">Download for Windows</a>
|
||||
<a class="btn btn-ghost btn-lg" href="/download/NetDesk-android.apk">⬇ Android app (.apk)</a>
|
||||
<a class="btn btn-ghost btn-lg" href="/app">Open Web Client →</a>
|
||||
<a class="btn btn-ghost btn-lg" href="/git/akronlogic/netdesk"></> View source</a>
|
||||
</div>
|
||||
<div class="trust"><span>🔒 <b>AES-256</b> end-to-end</span><span>⚡ <b>H.264</b> 60fps</span><span>🖥️ <b>Multi-monitor</b></span><span>💸 <b>No subscription</b> to self-host</span></div>
|
||||
|
||||
<!-- Exact flat replica of the NetDesk desktop app -->
|
||||
<div class="appshot" role="img" aria-label="The NetDesk desktop app">
|
||||
<div class="as-tbar">
|
||||
<span class="as-tl"><span class="as-logo"><svg width="11" height="11" viewBox="0 0 24 24" fill="none"><path d="M12 3.2L3.2 12.6H20.8L12 3.2Z" fill="#fff"/><path d="M8.7 15.1H15.3L16.4 20.5H7.6L8.7 15.1Z" fill="#fff"/></svg></span><span class="as-title">NetDesk</span>
|
||||
<span class="as-sub">— Enterprise Remote Desktop & Unattended Access</span></span>
|
||||
<span class="as-tr"><span class="as-tele">● Relay: <b>netdesk.space</b> · <b>24 ms</b> | TLS 1.3 · AES-256-GCM</span>
|
||||
<span class="as-win"><span>–</span><span>▢</span><span>✕</span></span></span>
|
||||
</div>
|
||||
<div class="as-body">
|
||||
<div class="as-side">
|
||||
<div class="as-brand"><span class="lg"><svg width="20" height="20" viewBox="0 0 24 24" fill="none"><path d="M12 3.2L3.2 12.6C2.8 13 3.1 13.7 3.7 13.7H20.3C20.9 13.7 21.2 13 20.8 12.6L12 3.2Z" fill="#fff"/><path d="M8.7 15.1H15.3L16.4 20.3C16.5 20.7 16.2 21 15.8 21H8.2C7.8 21 7.5 20.7 7.6 20.3L8.7 15.1Z" fill="#fff"/></svg></span><span><b>NetDesk</b> <i>v1.5.0</i><br><i>Zero-Trust Remote Agent</i></span></div>
|
||||
<div class="as-navlbl">WORKSPACE</div>
|
||||
<div class="as-nav">
|
||||
<span class="as-ni act">Remote Control <span class="as-bdg g">Host</span></span>
|
||||
<span class="as-ni">My Computers <span class="as-bdg">3</span></span>
|
||||
<span class="as-ni">Security & 2FA <span class="as-bdg g">2FA On</span></span>
|
||||
<span class="as-ni">Session Logs <span class="as-bdg">150</span></span>
|
||||
</div>
|
||||
<div class="as-sfoot">Local Endpoint <b style="float:right">DEVPC</b><br>
|
||||
Daemon Status <b class="on" style="float:right">● Online</b></div>
|
||||
</div>
|
||||
<div class="as-main">
|
||||
<div class="as-card as-partner">
|
||||
<span class="as-plbl">CONNECT TO PARTNER:</span>
|
||||
<span class="as-input">Enter remote Partner ID (e.g. 842 190 441)</span>
|
||||
<span class="as-gbtn">Connect →</span>
|
||||
</div>
|
||||
<div class="as-grid">
|
||||
<div class="as-col">
|
||||
<div class="as-card">
|
||||
<div class="as-hd"><span class="as-lbl">This Computer's ID</span><span class="as-tag">Copy ID</span></div>
|
||||
<div class="as-id">842 190 441</div>
|
||||
<div class="as-hd"><span class="as-pill">Online</span><span class="as-tag">🔒 E2EE Ready</span></div>
|
||||
</div>
|
||||
<div class="as-card">
|
||||
<div class="as-hd"><span class="as-lbl">Unattended Password</span><span class="as-tag">PBKDF2 · 200k</span></div>
|
||||
<div class="as-field"><span class="as-input">•••••••••• 👁 ⟳</span></div>
|
||||
<div style="margin-top:9px;font-size:11.5px;color:#64748b">● Brute-force throttle: <b style="color:#cbd5e1">0.4s / attempt</b></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="as-col">
|
||||
<div class="as-card">
|
||||
<div class="as-hd"><span class="as-lbl">Connection</span><span class="as-tag" style="color:#25db8e">Ping Relay (24 ms)</span></div>
|
||||
<div class="as-field"><small>Server Address</small><span class="as-input">wss://netdesk.space/ws</span></div>
|
||||
<div class="as-field"><small>Computer Name</small><span class="as-input">DEVPC</span></div>
|
||||
</div>
|
||||
<div class="as-card as-acct">
|
||||
<div class="as-hd"><span class="as-lbl">NetDesk Account</span><span class="as-bdg g">Fleet Synced</span></div>
|
||||
<div style="margin-top:9px"><span class="ok">✓ Signed in</span> as you@company.com</div>
|
||||
<span class="as-dev">DEVPC <i></i> Online</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="as-run">
|
||||
<span class="as-runbtn">⏻ Service Running</span>
|
||||
<span class="as-stopbtn">■ Stop</span>
|
||||
</div>
|
||||
<div class="as-card as-toggles">
|
||||
<span class="as-tg"><span class="as-sw on"></span>Service Installed</span>
|
||||
<span class="as-tg"><span class="as-sw on"></span>Start with Windows</span>
|
||||
<span class="as-tg"><span class="as-sw"></span>Privacy Mode</span>
|
||||
<span class="as-tg"><span class="as-sw"></span>Lock on disconnect</span>
|
||||
<span class="as-tg"><span class="as-sw on"></span>Close to tray</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<!-- STATS / trust band -->
|
||||
<section class="statband"><div class="wrap">
|
||||
<div class="stat reveal"><b>AES-256</b><span>End-to-end on every path</span></div>
|
||||
<div class="stat reveal"><b><4 ms</b><span>Direct P2P on the LAN</span></div>
|
||||
<div class="stat reveal"><b>1080p60</b><span>Hardware-encoded, adaptive</span></div>
|
||||
<div class="stat reveal"><b>$0</b><span>Self-host · no per-seat fees</span></div>
|
||||
</div></section>
|
||||
|
||||
<!-- LOGOS / trust -->
|
||||
<section class="section" style="padding:44px 0"><div class="wrap">
|
||||
<p style="text-align:center;color:var(--faint);font-size:13px;text-transform:uppercase;letter-spacing:1.4px;margin-bottom:22px">The private alternative to</p>
|
||||
<div class="logos"><span>TeamViewer</span><span>AnyDesk</span><span>RustDesk</span><span>Chrome Remote Desktop</span><span>Splashtop</span></div>
|
||||
</div></section>
|
||||
|
||||
<!-- FEATURES -->
|
||||
<section class="section alt"><div class="wrap">
|
||||
<div class="sec-head reveal"><div class="eyebrow">Everything you need</div>
|
||||
<h2>A complete remote desktop, self-hosted</h2>
|
||||
<p>All the power of TeamViewer Pro and RustDesk — running on infrastructure you own.</p></div>
|
||||
<div class="grid3">
|
||||
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg></div><h3>End-to-end encrypted</h3><p>AES-256-GCM with ephemeral ECDH keys. Even your own server can't see the screen — only the two endpoints hold the keys.</p></div>
|
||||
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M13 2L3 14h9l-1 8 10-12h-9l1-8z"/></svg></div><h3>Blazing fast</h3><p>Hardware H.264 up to 1080p60, WebCodecs decode in the browser, adaptive quality. Buttery-smooth even over the internet.</p></div>
|
||||
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg></div><h3>Unattended access</h3><p>Install as a boot-time service. Reach the logon screen and UAC prompts, across reboots — no one has to be at the PC.</p></div>
|
||||
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></div><h3>File transfer & sync</h3><p>Browse, drag-and-drop, upload whole folders, two-way clipboard sync, and remote printing — all end-to-end encrypted.</p></div>
|
||||
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg></div><h3>Multi-monitor & cursor</h3><p>Switch displays live, see the real remote cursor, record the session, chat, and manage processes and power remotely.</p></div>
|
||||
<div class="feat reveal"><div class="ic"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 2v20M2 12h20"/></svg></div><h3>Zero-install web client</h3><p>Connect from any browser on any laptop or phone — nothing to install on the viewer side. Install the PWA for one-tap access.</p></div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<!-- STATS -->
|
||||
<section class="section"><div class="wrap">
|
||||
<div class="stats">
|
||||
<div class="stat reveal"><div class="n"><10ms</div><div class="l">control latency on LAN</div></div>
|
||||
<div class="stat reveal"><div class="n">60fps</div><div class="l">1080p H.264 streaming</div></div>
|
||||
<div class="stat reveal"><div class="n">AES-256</div><div class="l">end-to-end encryption</div></div>
|
||||
<div class="stat reveal"><div class="n">$0</div><div class="l">to self-host, forever</div></div>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<!-- COMPARE -->
|
||||
<section class="section alt"><div class="wrap">
|
||||
<div class="sec-head reveal"><div class="eyebrow">Why NetDesk</div><h2>Own it, don't rent it</h2>
|
||||
<p>TeamViewer and AnyDesk route your sessions through their cloud and charge per seat. NetDesk runs on your server.</p></div>
|
||||
<div style="max-width:760px;margin:0 auto;overflow-x:auto" class="reveal">
|
||||
<table class="compare">
|
||||
<tr><th>Capability</th><th>NetDesk</th><th>TeamViewer</th><th>RustDesk (SaaS)</th></tr>
|
||||
<tr><td>Self-hosted, your server</td><td class="y">✓ Included</td><td class="n">✗</td><td class="y">Paid tier</td></tr>
|
||||
<tr><td>End-to-end encryption</td><td class="y">✓ Default</td><td class="y">✓</td><td class="y">✓</td></tr>
|
||||
<tr><td>Free for individuals</td><td class="y">✓</td><td class="n">Trial only</td><td class="y">✓</td></tr>
|
||||
<tr><td>No per-seat licensing</td><td class="y">✓</td><td class="n">✗</td><td class="y">Self-host</td></tr>
|
||||
<tr><td>Web client (no install)</td><td class="y">✓</td><td class="y">✓</td><td class="n">Limited</td></tr>
|
||||
<tr><td>Open source</td><td class="y">✓</td><td class="n">✗</td><td class="y">✓</td></tr>
|
||||
</table>
|
||||
</div>
|
||||
<div style="text-align:center;margin-top:30px" class="reveal"><a class="btn btn-primary btn-lg" href="/pricing">See pricing →</a></div>
|
||||
</div></section>
|
||||
|
||||
<!-- OPEN SOURCE -->
|
||||
<section class="section"><div class="wrap">
|
||||
<div class="sec-head reveal"><div class="eyebrow">Open source</div>
|
||||
<h2>Read every line. Then run it yourself.</h2>
|
||||
<p>The whole system — relay server, Windows host, zero-install web viewer, and the native Rust client — is published right here on netdesk.space. Browse it, download it, or clone it. No GitHub account, no third-party, no lock-in.</p></div>
|
||||
<div class="reveal" style="display:flex;gap:14px;flex-wrap:wrap;justify-content:center;margin:4px 0 22px">
|
||||
<a class="btn btn-primary btn-lg" href="/git/akronlogic/netdesk"></> Browse the code</a>
|
||||
<a class="btn btn-ghost btn-lg" href="/downloads/netdesk-src-latest.tar.gz" download>⬇ Download .tar.gz</a>
|
||||
<a class="btn btn-ghost btn-lg" href="/downloads/netdesk-src-latest.zip" download>⬇ Download .zip</a>
|
||||
</div>
|
||||
<div class="reveal" style="max-width:660px;margin:0 auto">
|
||||
<div style="background:#0a0e16;border:1px solid rgba(255,255,255,.1);border-radius:12px;padding:13px 16px;font-family:ui-monospace,Consolas,monospace;font-size:14px;color:#c7d2e6;display:flex;align-items:center;justify-content:space-between;gap:12px">
|
||||
<code style="overflow-x:auto;white-space:nowrap;background:none;padding:0">git clone https://netdesk.space/git/akronlogic/netdesk.git</code>
|
||||
<button type="button" onclick="(function(b){try{navigator.clipboard.writeText('git clone https://netdesk.space/git/akronlogic/netdesk.git')}catch(e){};var o=b.textContent;b.textContent='Copied';setTimeout(function(){b.textContent=o},1200)})(this)" style="background:transparent;border:1px solid rgba(255,255,255,.2);color:#9aa6bd;border-radius:7px;padding:5px 12px;font-size:12px;cursor:pointer;white-space:nowrap">Copy</button>
|
||||
</div>
|
||||
<p style="text-align:center;color:var(--muted,#8b95a9);font-size:13.5px;margin-top:14px">Source-available — free to self-host & modify. Browse it like GitHub, straight from our own server.</p>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<!-- CTA -->
|
||||
<section class="ctaband"><div class="wrap reveal">
|
||||
<h2>Take control of your remote access today.</h2>
|
||||
<p>Free for individuals. Self-host in minutes. No credit card, no cloud lock-in.</p>
|
||||
<div class="hero-cta" style="justify-content:center">
|
||||
<a class="btn btn-primary btn-lg" href="/download">Download NetDesk</a>
|
||||
<a class="btn btn-ghost btn-lg" href="/pricing">Compare plans</a>
|
||||
</div>
|
||||
</div></section>
|
||||
|
||||
<div id="siteFooter"></div>
|
||||
<script src="site.js?v=3"></script>
|
||||
<script>
|
||||
// scroll reveal
|
||||
(function(){var o=new IntersectionObserver(function(es){es.forEach(function(e){if(e.isIntersecting){e.target.classList.add("in");o.unobserve(e.target);}})},{threshold:.12});
|
||||
document.querySelectorAll(".reveal").forEach(function(el){o.observe(el);});
|
||||
// app replica is flat/straight by design - no tilt
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"name": "NetDesk",
|
||||
"short_name": "NetDesk",
|
||||
"start_url": "/",
|
||||
"display": "standalone",
|
||||
"background_color": "#0b0e14",
|
||||
"theme_color": "#3d7eff",
|
||||
"icons": [{"src": "/ndicon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any"}]
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#3d7eff"/><path d="M8 20l8-12 8 12-8-4z" fill="white"/><rect x="12" y="18" width="8" height="7" rx="2" fill="white"/></svg>
|
||||
|
After Width: | Height: | Size: 225 B |
@@ -0,0 +1,290 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>NetDesk Console</title>
|
||||
<link rel="stylesheet" href="/app.css?v=26">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%233d7eff'/%3E%3Cpath d='M8 20l8-12 8 12-8-4z' fill='white'/%3E%3Crect x='12' y='18' width='8' height='7' rx='2' fill='white'/%3E%3C/svg%3E">
|
||||
<meta name="robots" content="noindex, nofollow">
|
||||
</head>
|
||||
<body class="portal console">
|
||||
|
||||
<style>
|
||||
/* ---- Enterprise login (scoped to #viewLogin / .nd-*) ---------------------- */
|
||||
#viewLogin[hidden]{display:none!important}
|
||||
.nd-login{position:fixed;inset:0;z-index:60;display:flex;overflow:auto;
|
||||
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif;
|
||||
background:#0b1020;color:#0f172a;-webkit-font-smoothing:antialiased}
|
||||
.nd-login *{box-sizing:border-box}
|
||||
/* hero / brand panel */
|
||||
.nd-hero{flex:1 1 48%;min-height:100%;position:relative;display:flex;flex-direction:column;
|
||||
justify-content:space-between;padding:44px 52px;color:#e8efff;overflow:hidden;
|
||||
background:
|
||||
radial-gradient(900px 520px at 12% -8%,rgba(80,140,255,.45),transparent 60%),
|
||||
radial-gradient(760px 520px at 110% 115%,rgba(99,102,241,.40),transparent 55%),
|
||||
linear-gradient(135deg,#0a1836 0%,#10245c 46%,#1b3a86 100%)}
|
||||
.nd-hero::after{content:"";position:absolute;inset:0;opacity:.5;pointer-events:none;
|
||||
background-image:radial-gradient(rgba(255,255,255,.08) 1px,transparent 1.4px);
|
||||
background-size:26px 26px;mask-image:linear-gradient(180deg,#000,transparent 85%)}
|
||||
.nd-hero-brand{display:flex;align-items:center;gap:12px;font-weight:700;font-size:21px;letter-spacing:.2px;position:relative}
|
||||
.nd-hero-brand .nd-badge{display:inline-flex;align-items:center;gap:7px;margin-left:auto;font-size:11.5px;
|
||||
font-weight:600;color:#bcd2ff;background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.14);
|
||||
padding:5px 11px;border-radius:999px;letter-spacing:.3px}
|
||||
.nd-hero-mid{position:relative;max-width:460px}
|
||||
.nd-hero-mid h2{font-size:38px;line-height:1.1;margin:0 0 16px;font-weight:760;letter-spacing:-.5px}
|
||||
.nd-hero-mid p{font-size:15.5px;line-height:1.6;color:#b9c9ee;margin:0 0 30px}
|
||||
.nd-points{list-style:none;margin:0;padding:0;display:flex;flex-direction:column;gap:15px}
|
||||
.nd-points li{display:flex;align-items:center;gap:13px;font-size:14.5px;color:#dbe6ff}
|
||||
.nd-points svg{flex:none;width:20px;height:20px;color:#7fb0ff}
|
||||
.nd-hero-foot{position:relative;display:flex;align-items:center;gap:8px;font-size:12.5px;color:#8ba4d6}
|
||||
/* sign-in panel */
|
||||
.nd-panel{flex:1 1 52%;min-height:100%;display:flex;flex-direction:column;align-items:center;
|
||||
justify-content:center;gap:16px;padding:40px 28px;background:#f4f6fb}
|
||||
.nd-card{width:100%;max-width:410px;background:#fff;border:1px solid #e6eaf2;border-radius:18px;
|
||||
padding:40px 40px 32px;box-shadow:0 24px 60px -24px rgba(16,32,80,.35)}
|
||||
.nd-card-logo{display:flex;align-items:center;gap:10px;font-weight:700;color:#1b2742;font-size:15px;margin-bottom:26px}
|
||||
.nd-card h1{font-size:26px;font-weight:720;letter-spacing:-.4px;color:#0f172a;margin:0 0 6px}
|
||||
.nd-card-sub{font-size:14px;color:#64748b;margin:0 0 26px}
|
||||
.nd-field{display:block;margin:0 0 6px}
|
||||
.nd-field>span{display:block;font-size:12.5px;font-weight:600;color:#475569;margin-bottom:8px;letter-spacing:.2px}
|
||||
.nd-inwrap{position:relative;display:flex;align-items:center}
|
||||
.nd-inwrap>.nd-lock{position:absolute;left:14px;width:18px;height:18px;color:#94a3b8;pointer-events:none}
|
||||
.nd-inwrap input{width:100%;height:50px;padding:0 46px 0 42px;font-size:15px;color:#0f172a;
|
||||
background:#f8fafc;border:1.5px solid #dde3ee;border-radius:11px;outline:none;transition:border-color .15s,box-shadow .15s,background .15s}
|
||||
.nd-inwrap input::placeholder{color:#b4bdcd;letter-spacing:2px}
|
||||
.nd-inwrap input:focus{border-color:#3d7eff;background:#fff;box-shadow:0 0 0 4px rgba(61,126,255,.14)}
|
||||
.nd-eye{position:absolute;right:8px;display:inline-flex;align-items:center;justify-content:center;
|
||||
width:34px;height:34px;border:0;background:transparent;color:#94a3b8;cursor:pointer;border-radius:8px}
|
||||
.nd-eye:hover{color:#475569;background:#eef2f9}
|
||||
.nd-eye .off{display:none}.nd-eye.on .on{display:none}.nd-eye.on .off{display:block}
|
||||
.nd-login-err{display:flex;align-items:center;gap:8px;margin:14px 0 0;padding:10px 13px;font-size:13px;
|
||||
color:#b42318;background:#fef3f2;border:1px solid #fda29b;border-radius:10px;animation:nd-shake .3s}
|
||||
@keyframes nd-shake{0%,100%{transform:translateX(0)}25%{transform:translateX(-5px)}75%{transform:translateX(5px)}}
|
||||
.nd-btn{width:100%;height:50px;margin-top:22px;display:inline-flex;align-items:center;justify-content:center;gap:9px;
|
||||
font-size:15px;font-weight:650;color:#fff;border:0;border-radius:11px;cursor:pointer;letter-spacing:.2px;
|
||||
background:linear-gradient(135deg,#4d8bff,#6aa1ff 44%,#34d399);background-size:160% 160%;position:relative;overflow:hidden;z-index:0;
|
||||
box-shadow:0 10px 24px -10px rgba(45,95,224,.7),inset 0 1px 0 rgba(255,255,255,.22);transition:transform .12s,box-shadow .15s,filter .15s}
|
||||
.nd-btn::before{content:"";position:absolute;inset:0;z-index:-1;border-radius:inherit;background:linear-gradient(100deg,transparent 22%,rgba(255,255,255,.4) 50%,transparent 78%);transform:translateX(-120%);transition:transform .6s ease}
|
||||
.nd-btn:hover::before{transform:translateX(120%)}
|
||||
.nd-btn:hover{filter:brightness(1.05);box-shadow:0 14px 28px -10px rgba(45,95,224,.8)}
|
||||
.nd-btn:active{transform:translateY(1px)}
|
||||
.nd-btn .nd-spin{display:none;width:17px;height:17px;border:2.4px solid rgba(255,255,255,.45);border-top-color:#fff;border-radius:50%;animation:nd-rot .7s linear infinite}
|
||||
.nd-btn.nd-loading .nd-spin{display:inline-block}.nd-btn.nd-loading .nd-btn-lbl{opacity:.75}
|
||||
@keyframes nd-rot{to{transform:rotate(360deg)}}
|
||||
.nd-secure{display:flex;align-items:center;justify-content:center;gap:8px;margin-top:22px;
|
||||
font-size:12px;color:#64748b}
|
||||
.nd-secure svg{width:14px;height:14px;color:#16a34a}
|
||||
.nd-legal{max-width:410px;text-align:center;font-size:11.5px;line-height:1.5;color:#94a3b8}
|
||||
/* dark mode (OS or portal) */
|
||||
@media (prefers-color-scheme:dark){
|
||||
.nd-panel{background:#0c1222}
|
||||
.nd-card{background:#111a30;border-color:#243352;box-shadow:0 24px 60px -24px rgba(0,0,0,.6)}
|
||||
.nd-card-logo{color:#e6edf8}.nd-card h1{color:#f1f5fb}.nd-card-sub{color:#93a4c4}
|
||||
.nd-field>span{color:#a7b6d4}
|
||||
.nd-inwrap input{background:#0d1526;border-color:#2b3b5c;color:#eef2fb}
|
||||
.nd-inwrap input:focus{background:#0d1526;border-color:#4f8bff}
|
||||
.nd-eye:hover{background:#1b2742}
|
||||
.nd-login-err{color:#ffb4ab;background:#2a1414;border-color:#5b2420}
|
||||
.nd-secure{color:#8aa0c6}.nd-legal{color:#6b7da0}
|
||||
}
|
||||
@media (max-width:860px){
|
||||
.nd-login{flex-direction:column}
|
||||
.nd-hero{flex:0 0 auto;padding:26px 24px;min-height:auto}
|
||||
.nd-hero-mid,.nd-hero-foot{display:none}
|
||||
.nd-panel{flex:1 1 auto;padding:30px 20px}
|
||||
.nd-card{padding:30px 24px 26px}
|
||||
}
|
||||
/* ---- audit-log console ---- */
|
||||
.au-head{display:flex;align-items:center;justify-content:space-between;flex-wrap:wrap;gap:10px;margin-bottom:14px}
|
||||
.au-ctrls{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
|
||||
.au-in{background:var(--bg2,#10141e);border:1px solid var(--border,#212a3a);border-radius:8px;color:var(--fg,#eaeff8);padding:7px 10px;font-size:13px;font-family:inherit;outline:none}
|
||||
.au-in:focus{border-color:var(--accent,#3d7eff);box-shadow:0 0 0 3px rgba(61,126,255,.14)}
|
||||
.au-q{min-width:190px}
|
||||
.au-btn{background:var(--bg3,#161c28);border:1px solid var(--border,#212a3a);border-radius:8px;color:var(--fg,#eaeff8);padding:7px 13px;font-size:13px;cursor:pointer;font-weight:600;text-decoration:none;display:inline-flex;align-items:center;gap:6px;transition:.15s}
|
||||
.au-btn:hover{border-color:var(--accent,#3d7eff);transform:translateY(-1px)}
|
||||
.au-btn-cta{background:linear-gradient(135deg,#4d8bff,#34d399);border-color:transparent;color:#fff;box-shadow:0 6px 16px -6px rgba(61,126,255,.5)}
|
||||
.audit-feed{max-height:460px;overflow:auto}
|
||||
.audit-feed .feed-row{display:flex;gap:12px;align-items:baseline;padding:7px 2px;border-bottom:1px solid var(--border-soft,#1a2130);font-size:13.5px}
|
||||
.audit-feed .feed-row .stats{color:var(--faint,#6b7688);font-size:12px;min-width:150px;font-variant-numeric:tabular-nums}
|
||||
.audit-feed .kbadge{display:inline-block;font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.5px;padding:2px 7px;border-radius:999px;background:rgba(77,139,255,.14);color:#9cc0ff;margin-right:7px;vertical-align:1px}
|
||||
.audit-feed .kind-deny .kbadge,.audit-feed .kind-authfail .kbadge{background:rgba(239,68,68,.16);color:#fca5a5}
|
||||
.audit-feed .kind-connect .kbadge,.audit-feed .kind-session .kbadge{background:rgba(52,211,153,.16);color:#6ee7b7}
|
||||
.audit-feed .act{color:var(--accent2,#6aa1ff);font-size:12px}
|
||||
</style>
|
||||
|
||||
<div id="viewLogin" class="screen nd-login" hidden>
|
||||
<!-- brand / trust panel -->
|
||||
<div class="nd-hero">
|
||||
<div class="nd-hero-brand">
|
||||
<svg width="30" height="30" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#3d7eff"/><path d="M8 20l8-12 8 12-8-4z" fill="white"/><rect x="12" y="18" width="8" height="7" rx="2" fill="white"/></svg>
|
||||
NetDesk
|
||||
<span class="nd-badge"><svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> Secure Console</span>
|
||||
</div>
|
||||
<div class="nd-hero-mid">
|
||||
<h2>Admin Console</h2>
|
||||
<p>Manage your fleet of unattended machines — hosts, live sessions, users and the full access audit trail — from one private, self-hosted console.</p>
|
||||
<ul class="nd-points">
|
||||
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg> End-to-end encrypted remote sessions</li>
|
||||
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg> Self-hosted on your own server</li>
|
||||
<li><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg> Complete session & access audit trail</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="nd-hero-foot">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
NetDesk by Akronlogic · netdesk.space
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- sign-in -->
|
||||
<div class="nd-panel">
|
||||
<div class="nd-card">
|
||||
<div class="nd-card-logo">
|
||||
<svg width="24" height="24" viewBox="0 0 32 32"><rect width="32" height="32" rx="7" fill="#3d7eff"/><path d="M8 20l8-12 8 12-8-4z" fill="white"/><rect x="12" y="18" width="8" height="7" rx="2" fill="white"/></svg>
|
||||
NetDesk Console
|
||||
</div>
|
||||
<h1>Sign in</h1>
|
||||
<p class="nd-card-sub">Enter your administrator password to continue.</p>
|
||||
|
||||
<label class="nd-field" for="pwPortal"><span>Console password</span></label>
|
||||
<div class="nd-inwrap">
|
||||
<svg class="nd-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
<input id="pwPortal" type="password" autocomplete="current-password" placeholder="••••••••••••" aria-label="Console password">
|
||||
<button type="button" class="nd-eye" aria-label="Show password"
|
||||
onclick="(function(b){var i=document.getElementById('pwPortal');var s=i.type==='password';i.type=s?'text':'password';b.classList.toggle('on',s);b.setAttribute('aria-label',s?'Hide password':'Show password');i.focus();})(this)">
|
||||
<svg class="on" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7z"/><circle cx="12" cy="12" r="3"/></svg>
|
||||
<svg class="off" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"/><path d="M1 1l22 22"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="loginErr" class="nd-login-err" hidden></div>
|
||||
|
||||
<button id="btnLogin" class="nd-btn" type="button"
|
||||
onclick="var b=this;b.classList.add('nd-loading');setTimeout(function(){b.classList.remove('nd-loading')},2500)">
|
||||
<span class="nd-spin"></span><span class="nd-btn-lbl">Sign in securely</span>
|
||||
</button>
|
||||
|
||||
<div class="nd-secure">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>
|
||||
Encrypted connection · Authorized personnel only
|
||||
</div>
|
||||
</div>
|
||||
<div class="nd-legal">This is a private administrative system. Unauthorized access is prohibited and all activity may be monitored and logged.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="viewDash" hidden>
|
||||
<aside class="side">
|
||||
<div class="side-brand">
|
||||
<svg width="30" height="30" viewBox="0 0 32 32"><rect width="32" height="32" rx="8" fill="#3d7eff"/><path d="M8 20l8-12 8 12-8-4z" fill="white"/><rect x="12" y="18" width="8" height="7" rx="2" fill="white"/></svg>
|
||||
<div><b>NetDesk</b><div class="side-sub" id="metaServer">console</div></div>
|
||||
</div>
|
||||
<nav class="side-nav">
|
||||
<a href="#sec-hosts" class="nav-it"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="3" width="20" height="14" rx="2"/><path d="M8 21h8M12 17v4"/></svg> Computers</a>
|
||||
<a href="#sec-visitors" class="nav-it"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="10" r="3"/><path d="M12 2a8 8 0 0 0-8 8c0 5 8 12 8 12s8-7 8-12a8 8 0 0 0-8-8z"/></svg> Visitors</a>
|
||||
<a href="#sec-downloads" class="nav-it"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg> Downloads</a>
|
||||
<a href="#sec-activity" class="nav-it"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg> Activity</a>
|
||||
<a href="#sec-setup" class="nav-it"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg> Setup keys</a>
|
||||
</nav>
|
||||
<div class="side-foot">
|
||||
<a href="/" class="nav-it"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><path d="M15 3h6v6M10 14L21 3"/></svg> Open viewer</a>
|
||||
<a href="/portal/logout" class="nav-it"><svg class="ic" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/></svg> Sign out</a>
|
||||
<button id="btnThemeP" class="theme-toggle" style="width:100%;justify-content:center;margin-top:8px">Theme</button>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main class="mainc">
|
||||
<section class="stat-row">
|
||||
<div class="stat"><div class="stat-n" id="stOnline">-</div><div class="stat-l">online</div></div>
|
||||
<div class="stat"><div class="stat-n" id="stTotal">-</div><div class="stat-l">computers</div></div>
|
||||
<div class="stat"><div class="stat-n" id="stSessions">-</div><div class="stat-l">sessions</div></div>
|
||||
<div class="stat"><div class="stat-n" id="stTraffic">-</div><div class="stat-l">relayed</div></div>
|
||||
<div class="stat"><div class="stat-n" id="stUptime">-</div><div class="stat-l">uptime</div></div>
|
||||
</section>
|
||||
|
||||
<section class="p-card" id="sec-users" hidden>
|
||||
<h2>Users</h2>
|
||||
<table class="tbl" id="tblUsers">
|
||||
<thead><tr><th>Email</th><th>Verified</th><th>Joined</th><th></th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
</section>
|
||||
|
||||
<section class="p-card" id="sec-hosts">
|
||||
<h2>Computers</h2>
|
||||
<table class="tbl" id="tblHosts">
|
||||
<thead><tr><th>Status</th><th>Name</th><th>ID</th><th>Version</th><th>Last seen</th><th>Live · CPU / RAM / SSD</th><th></th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div id="noHosts" class="muted" hidden>No computers yet - run NetDeskHost on a PC and point it at this server.</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.pill{display:inline-block;padding:1px 8px;border-radius:999px;font-size:11px;font-weight:600;background:rgba(61,126,255,.14);color:#3d7eff;vertical-align:middle}
|
||||
.pill-hidden{display:inline-block;padding:1px 7px;border-radius:999px;font-size:10px;font-weight:700;letter-spacing:.4px;background:rgba(148,163,184,.22);color:#6b7a90;vertical-align:middle}
|
||||
.sm,.muted.sm{font-size:11px;opacity:.72}
|
||||
#sec-visitors h2 .sub{font-size:12px;font-weight:400;color:#8a97ab;margin-left:8px}
|
||||
.metrics-cell{min-width:190px}
|
||||
.mbar{display:flex;align-items:center;gap:6px;font-size:10.5px;margin:2px 0}
|
||||
.mlab{width:26px;color:#8a97ab;font-weight:700;letter-spacing:.3px}
|
||||
.mtrack{flex:1;height:7px;background:rgba(148,163,184,.22);border-radius:4px;overflow:hidden;min-width:70px}
|
||||
.mfill{display:block;height:100%;border-radius:4px;transition:width .5s ease}
|
||||
.mpct{width:32px;text-align:right;color:#8a97ab;font-variant-numeric:tabular-nums}
|
||||
.bat{font-size:11px;margin-top:3px;color:#8a97ab}
|
||||
</style>
|
||||
<section class="p-card" id="sec-visitors">
|
||||
<h2>Live visitors<span class="sub">IP intelligence & location</span></h2>
|
||||
<table class="tbl" id="tblVisitors">
|
||||
<thead><tr><th>Status</th><th>IP address</th><th>Location / ISP</th><th>Host</th><th>Who</th><th>Since</th></tr></thead>
|
||||
<tbody></tbody>
|
||||
</table>
|
||||
<div id="noVis" class="muted" hidden>No one is connected right now.</div>
|
||||
<h3 style="margin:18px 0 8px;font-size:14px">Recent connections</h3>
|
||||
<div id="visRecent" class="feed"></div>
|
||||
</section>
|
||||
|
||||
<div class="p-cols">
|
||||
<section class="p-card" id="sec-downloads">
|
||||
<h2>Downloads</h2>
|
||||
<div id="dlList" class="muted">Put agent/server binaries in the server files folder to offer them here.</div>
|
||||
</section>
|
||||
<section class="p-card" id="sec-activity">
|
||||
<div class="au-head">
|
||||
<h2 style="margin:0">Audit log</h2>
|
||||
<div class="au-ctrls">
|
||||
<select id="auKind" class="au-in" title="Event type"><option value="">All events</option></select>
|
||||
<input id="auQ" class="au-in au-q" type="text" placeholder="Search message or actor…" autocomplete="off">
|
||||
<select id="auLimit" class="au-in" title="Rows">
|
||||
<option value="200">200</option><option value="500" selected>500</option><option value="2000">2000</option>
|
||||
</select>
|
||||
<button id="auRefresh" class="au-btn" type="button">Refresh</button>
|
||||
<a id="auCsv" class="au-btn au-btn-cta" href="/portal/api/audit.csv">↓ Export CSV</a>
|
||||
</div>
|
||||
</div>
|
||||
<div id="feed" class="feed audit-feed"></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="p-card" id="sec-setup">
|
||||
<h2>Setup keys</h2>
|
||||
<div class="keyrow">
|
||||
<div>
|
||||
<div class="key-l">Registration key - hosts present this to join your server</div>
|
||||
<div class="key-v mono" id="kvReg">-</div>
|
||||
</div>
|
||||
<button id="btnCopyReg" class="tb">Copy</button>
|
||||
</div>
|
||||
<div class="keyrow">
|
||||
<div>
|
||||
<div class="key-l">Server address for the NetDesk app (wss + your domain)</div>
|
||||
<div class="key-v mono" id="kvServer">-</div>
|
||||
</div>
|
||||
<button id="btnCopySrv" class="tb">Copy</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
<script src="/portal.js?v=27"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,349 @@
|
||||
/* NetDesk management portal. */
|
||||
"use strict";
|
||||
|
||||
const $ = (s) => document.querySelector(s);
|
||||
|
||||
function fmtBytes(n) {
|
||||
if (n > 1e9) return (n / 1e9).toFixed(2) + " GB";
|
||||
if (n > 1e6) return (n / 1e6).toFixed(1) + " MB";
|
||||
if (n > 1e3) return (n / 1e3).toFixed(1) + " KB";
|
||||
return n + " B";
|
||||
}
|
||||
function fmtUptime(s) {
|
||||
const d = Math.floor(s / 86400), h = Math.floor(s % 86400 / 3600), m = Math.floor(s % 3600 / 60);
|
||||
return (d ? d + "d " : "") + (h ? h + "h " : "") + m + "m";
|
||||
}
|
||||
function fmtAgo(ts) {
|
||||
const d = Math.max(0, Math.floor(Date.now() / 1000 - ts));
|
||||
if (d < 60) return d + "s ago";
|
||||
if (d < 3600) return Math.floor(d / 60) + "m ago";
|
||||
if (d < 86400) return Math.floor(d / 3600) + "h ago";
|
||||
return Math.floor(d / 86400) + "d ago";
|
||||
}
|
||||
|
||||
async function api() {
|
||||
const r = await fetch("/portal/api/overview", { cache: "no-store" });
|
||||
if (r.status === 401) { showLogin(); return null; }
|
||||
return r.json();
|
||||
}
|
||||
|
||||
function showLogin() {
|
||||
$("#viewDash").hidden = true;
|
||||
$("#viewLogin").hidden = false;
|
||||
setTimeout(() => $("#pwPortal").focus(), 50);
|
||||
}
|
||||
function showDash() {
|
||||
$("#viewLogin").hidden = true;
|
||||
$("#viewDash").hidden = false;
|
||||
refresh();
|
||||
}
|
||||
|
||||
async function login() {
|
||||
const err = $("#loginErr");
|
||||
err.hidden = true;
|
||||
const r = await fetch("/portal/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: "password=" + encodeURIComponent($("#pwPortal").value),
|
||||
redirect: "manual",
|
||||
});
|
||||
if (r.ok || r.status === 0) { $("#pwPortal").value = ""; showDash(); return; }
|
||||
err.textContent = r.status === 401 ? "Wrong password." : "Login failed (" + r.status + ")";
|
||||
err.hidden = false;
|
||||
}
|
||||
|
||||
function flag(cc) {
|
||||
if (!cc || cc.length !== 2 || !/^[a-z]{2}$/i.test(cc)) return "🌐";
|
||||
return String.fromCodePoint(...[...cc.toUpperCase()].map(c => 0x1F1E6 + c.charCodeAt(0) - 65));
|
||||
}
|
||||
// Escape untrusted values (viewer IP from X-Forwarded-For, host names, geo, …)
|
||||
// before they touch innerHTML — prevents XSS in the admin console.
|
||||
function esc(s) {
|
||||
return String(s == null ? "" : s).replace(/[&<>"']/g,
|
||||
c => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
|
||||
}
|
||||
// A compact labelled load bar for host telemetry (CPU / RAM / Disk).
|
||||
function bar(label, pct, title) {
|
||||
pct = Math.max(0, Math.min(100, Math.round(pct || 0)));
|
||||
const col = pct > 85 ? "#ef4444" : pct > 60 ? "#f59e0b" : "#22c55e";
|
||||
return `<div class="mbar" title="${esc(title || "")}"><span class="mlab">${label}</span>` +
|
||||
`<span class="mtrack"><span class="mfill" style="width:${pct}%;background:${col}"></span></span>` +
|
||||
`<span class="mpct">${pct}%</span></div>`;
|
||||
}
|
||||
|
||||
function render(d) {
|
||||
d.ownerEmail = (d.users || []).find(u => u.email.toLowerCase() === "owner@example.com");
|
||||
window.__ownerSeen = !!d.owner;
|
||||
$("#stOnline").textContent = d.hosts_online;
|
||||
$("#stTotal").textContent = d.hosts.length;
|
||||
$("#stSessions").textContent = d.sessions_total;
|
||||
$("#stTraffic").textContent = fmtBytes(d.bytes_relayed);
|
||||
$("#stUptime").textContent = fmtUptime(d.uptime);
|
||||
$("#metaServer").textContent = "server v" + d.version;
|
||||
$("#kvReg").textContent = d.reg_key || "-";
|
||||
$("#kvServer").textContent = location.origin.replace(/^http/, "ws") + "/ws";
|
||||
|
||||
const tb = $("#tblHosts tbody");
|
||||
tb.innerHTML = "";
|
||||
for (const h of d.hosts) {
|
||||
const tr = document.createElement("tr");
|
||||
const dot = document.createElement("td");
|
||||
dot.innerHTML = `<span class="dot ${h.online ? "on" : "off"}"></span>${h.online ? (h.in_session ? "in session" : "online") : "offline"}`;
|
||||
const name = document.createElement("td");
|
||||
name.textContent = h.name || "(unnamed)";
|
||||
if (h.hidden) {
|
||||
const badge = document.createElement("span");
|
||||
badge.className = "pill-hidden";
|
||||
badge.textContent = "HIDDEN";
|
||||
name.append(" ", badge);
|
||||
}
|
||||
const id = document.createElement("td");
|
||||
id.className = "mono";
|
||||
id.textContent = h.id;
|
||||
const ver = document.createElement("td");
|
||||
ver.textContent = h.ver || "";
|
||||
const seen = document.createElement("td");
|
||||
seen.textContent = h.online ? "now" : fmtAgo(h.last_seen);
|
||||
// live telemetry (CPU / RAM / Disk bars + battery)
|
||||
const met = document.createElement("td");
|
||||
met.className = "metrics-cell";
|
||||
const tm = h.telemetry || {};
|
||||
if (h.online && tm.cpu != null) {
|
||||
const ramPct = tm.ram_total ? (100 * tm.ram_used / tm.ram_total) : 0;
|
||||
const diskPct = tm.disk_total ? (100 * tm.disk_used / tm.disk_total) : 0;
|
||||
let bat = "";
|
||||
if (tm.battery != null && tm.battery >= 0) {
|
||||
bat = `<div class="bat">${tm.charging ? "⚡" : "🔋"} ${tm.battery}%</div>`;
|
||||
}
|
||||
met.innerHTML =
|
||||
bar("CPU", tm.cpu) +
|
||||
bar("RAM", ramPct, fmtBytes(tm.ram_used) + " / " + fmtBytes(tm.ram_total)) +
|
||||
bar("SSD", diskPct, fmtBytes(tm.disk_used) + " / " + fmtBytes(tm.disk_total)) +
|
||||
bat;
|
||||
} else {
|
||||
met.innerHTML = '<span class="muted sm">—</span>';
|
||||
}
|
||||
const act = document.createElement("td");
|
||||
act.style.whiteSpace = "nowrap";
|
||||
if (h.online && d.owner) {
|
||||
const disc = document.createElement("button");
|
||||
disc.className = "tb";
|
||||
disc.textContent = h.in_session ? "Disconnect" : "";
|
||||
disc.style.marginRight = "6px";
|
||||
disc.onclick = async () => {
|
||||
await fetch("/portal/api/session/disconnect", { method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: h.id }) });
|
||||
refresh();
|
||||
};
|
||||
act.append(disc);
|
||||
}
|
||||
{
|
||||
// Hide/unhide is a portal-admin action (the console is password-gated),
|
||||
// so it's available to any authenticated portal session.
|
||||
const hd = document.createElement("button");
|
||||
hd.className = "tb";
|
||||
hd.textContent = h.hidden ? "Unhide" : "Hide";
|
||||
hd.style.marginRight = "6px";
|
||||
hd.onclick = async () => {
|
||||
await fetch("/portal/api/host/hide", { method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: h.id, hidden: !h.hidden }) });
|
||||
refresh();
|
||||
};
|
||||
act.append(hd);
|
||||
}
|
||||
if (d.owner) {
|
||||
const rm = document.createElement("button");
|
||||
rm.className = "tb danger";
|
||||
rm.textContent = "Remove";
|
||||
rm.onclick = async () => {
|
||||
if (!confirm("Remove " + h.name + " from the server?")) return;
|
||||
await fetch("/portal/api/host/delete", { method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ id: h.id }) });
|
||||
refresh();
|
||||
};
|
||||
act.append(rm);
|
||||
}
|
||||
if (h.online) {
|
||||
const a = document.createElement("a");
|
||||
a.className = "tb connect";
|
||||
a.href = "/app?id=" + h.id;
|
||||
a.textContent = "Connect";
|
||||
act.append(a);
|
||||
}
|
||||
tr.append(dot, name, id, ver, seen, met, act);
|
||||
tb.append(tr);
|
||||
}
|
||||
$("#noHosts").hidden = d.hosts.length > 0;
|
||||
|
||||
// users section (owner only)
|
||||
const usec = document.querySelector("#sec-users");
|
||||
if (usec) usec.hidden = !d.owner;
|
||||
if (d.owner) {
|
||||
const utb = document.querySelector("#tblUsers tbody");
|
||||
if (utb) {
|
||||
utb.innerHTML = "";
|
||||
for (const u of d.users || []) {
|
||||
const tr = document.createElement("tr");
|
||||
const em = document.createElement("td");
|
||||
em.className = "mono";
|
||||
em.textContent = (u.email.toLowerCase() === (d.ownerEmail || "").toLowerCase()) ? "⭐ " + u.email : u.email;
|
||||
const vf = document.createElement("td");
|
||||
vf.textContent = u.verified ? "yes" : "pending";
|
||||
const cr = document.createElement("td");
|
||||
cr.textContent = fmtAgo(u.created);
|
||||
const del = document.createElement("td");
|
||||
const db = document.createElement("button");
|
||||
db.className = "tb danger";
|
||||
db.textContent = "Delete";
|
||||
db.onclick = async () => {
|
||||
if (!confirm("Delete " + u.email + "?")) return;
|
||||
await fetch("/portal/api/user/delete", { method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ email: u.email }) });
|
||||
refresh();
|
||||
};
|
||||
del.append(db);
|
||||
tr.append(em, vf, cr, del);
|
||||
utb.append(tr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const dl = $("#dlList");
|
||||
if (d.files.length) {
|
||||
dl.innerHTML = "";
|
||||
dl.classList.remove("muted");
|
||||
for (const f of d.files) {
|
||||
const a = document.createElement("a");
|
||||
a.className = "dl-row";
|
||||
a.href = "/dl/" + encodeURIComponent(f.name);
|
||||
a.innerHTML = `<span>⬇</span><span class="name">${f.name}</span><span class="stats">${fmtBytes(f.size)}</span>`;
|
||||
dl.append(a);
|
||||
}
|
||||
}
|
||||
|
||||
// (the #feed audit log is populated by loadAudit(), below)
|
||||
|
||||
// ---- live visitors (IP intelligence + location) --------------------
|
||||
const vtb = document.querySelector("#tblVisitors tbody");
|
||||
if (vtb) {
|
||||
vtb.innerHTML = "";
|
||||
for (const v of (d.visitors || [])) {
|
||||
const g = v.geo || {};
|
||||
const loc = g.country ? `${g.city ? g.city + ", " : ""}${g.country}` : "—";
|
||||
const tr = document.createElement("tr");
|
||||
tr.innerHTML =
|
||||
`<td><span class="dot on"></span>${v.active ? "live" : "connecting"}</td>` +
|
||||
`<td class="mono">${esc(v.ip) || "—"}</td>` +
|
||||
`<td>${flag(g.cc)} ${esc(loc)}<div class="muted sm">${esc(g.isp)}</div></td>` +
|
||||
`<td>${esc(v.host_name || v.host)}</td>` +
|
||||
`<td>${v.email ? esc(v.email) : '<span class="muted">guest</span>'} <span class="pill">${esc(v.plan)}</span></td>` +
|
||||
`<td>${fmtAgo(v.since)}</td>`;
|
||||
vtb.append(tr);
|
||||
}
|
||||
const nv = document.querySelector("#noVis");
|
||||
if (nv) nv.hidden = (d.visitors || []).length > 0;
|
||||
}
|
||||
const vr = document.querySelector("#visRecent");
|
||||
if (vr) {
|
||||
vr.innerHTML = "";
|
||||
for (const v of (d.visitors_recent || []).slice(0, 30)) {
|
||||
const g = v.geo || {};
|
||||
const loc = g.country ? `${g.city ? g.city + ", " : ""}${g.country}` : "";
|
||||
const row = document.createElement("div");
|
||||
row.className = "feed-row";
|
||||
row.innerHTML =
|
||||
`<span class="stats">${fmtAgo(v.t)}</span>` +
|
||||
`<span>${flag(g.cc)} <b class="mono">${esc(v.ip) || "?"}</b>${loc ? " · " + esc(loc) : ""} → ` +
|
||||
`${esc(v.host_name || v.host)} <span class="pill">${esc(v.plan)}</span></span>`;
|
||||
vr.append(row);
|
||||
}
|
||||
if (!(d.visitors_recent || []).length) vr.innerHTML = '<div class="muted">no visitors yet</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
const d = await api();
|
||||
if (d) { showDash(); render(d); }
|
||||
}
|
||||
|
||||
function applyTheme(t) {
|
||||
document.documentElement.dataset.theme = t;
|
||||
const b = document.querySelector("#btnThemeP");
|
||||
if (b) b.textContent = t === "light" ? "☾ Dark" : "☀ Light";
|
||||
}
|
||||
applyTheme(localStorage.getItem("nd.theme") || "dark");
|
||||
if ($("#btnThemeP")) $("#btnThemeP").onclick = () => {
|
||||
const t = document.documentElement.dataset.theme === "light" ? "dark" : "light";
|
||||
localStorage.setItem("nd.theme", t);
|
||||
applyTheme(t);
|
||||
};
|
||||
|
||||
// null-safe binding: a missing element must never kill the whole script
|
||||
function on(sel, fn) { const el = $(sel); if (el) el.onclick = fn; return el; }
|
||||
|
||||
on("#btnLogin", login);
|
||||
if ($("#pwPortal")) $("#pwPortal").addEventListener("keydown", (e) => { if (e.key === "Enter") login(); });
|
||||
on("#btnCopyReg", () => {
|
||||
navigator.clipboard.writeText($("#kvReg").textContent);
|
||||
$("#btnCopyReg").textContent = "Copied";
|
||||
setTimeout(() => ($("#btnCopyReg").textContent = "Copy"), 1200);
|
||||
});
|
||||
on("#btnCopySrv", () => {
|
||||
navigator.clipboard.writeText($("#kvServer").textContent.replace("/ws", ""));
|
||||
$("#btnCopySrv").textContent = "Copied";
|
||||
setTimeout(() => ($("#btnCopySrv").textContent = "Copy"), 1200);
|
||||
});
|
||||
on("#btnLogout", async () => {
|
||||
await fetch("/portal/logout", { redirect: "manual" });
|
||||
location.href = location.pathname.replace(/\/portal$/, "/");
|
||||
});
|
||||
|
||||
/* ---- audit log console (full persistent log + filters + CSV export) ---- */
|
||||
function auditParams() {
|
||||
const p = new URLSearchParams();
|
||||
const k = ($("#auKind") || {}).value, q = (($("#auQ") || {}).value || "").trim(), lim = ($("#auLimit") || {}).value;
|
||||
if (k) p.set("kind", k);
|
||||
if (q) p.set("q", q);
|
||||
if (lim) p.set("limit", lim);
|
||||
return p;
|
||||
}
|
||||
async function loadAudit() {
|
||||
const feed = $("#feed");
|
||||
if (!feed) return;
|
||||
const p = auditParams();
|
||||
const csv = $("#auCsv");
|
||||
if (csv) csv.href = "/portal/api/audit.csv?" + p.toString();
|
||||
try {
|
||||
const r = await fetch("/portal/api/audit?" + p.toString(), { cache: "no-store" });
|
||||
if (!r.ok) return;
|
||||
const d = await r.json();
|
||||
const sel = $("#auKind");
|
||||
if (sel && sel.options.length <= 1 && Array.isArray(d.kinds)) {
|
||||
for (const k of d.kinds) { const o = document.createElement("option"); o.value = k; o.textContent = k; sel.append(o); }
|
||||
}
|
||||
feed.innerHTML = "";
|
||||
if (!d.events.length) { feed.innerHTML = '<div class="muted">no matching events</div>'; return; }
|
||||
for (const e of d.events) {
|
||||
const row = document.createElement("div");
|
||||
row.className = "feed-row kind-" + String(e.kind || "").replace(/[^a-z-]/g, "");
|
||||
const dt = new Date((e.ts || 0) * 1000);
|
||||
const t = dt.toLocaleDateString() + " " + dt.toLocaleTimeString();
|
||||
const act = e.actor ? `<span class="act"> · ${esc(e.actor)}</span>` : "";
|
||||
row.innerHTML = `<span class="stats">${t}</span><span><span class="kbadge">${esc(e.kind || "")}</span>${esc(e.msg || "")}${act}</span>`;
|
||||
feed.append(row);
|
||||
}
|
||||
} catch (err) {}
|
||||
}
|
||||
(function wireAudit() {
|
||||
const r = $("#auRefresh"); if (r) r.addEventListener("click", loadAudit);
|
||||
const k = $("#auKind"); if (k) k.addEventListener("change", loadAudit);
|
||||
const l = $("#auLimit"); if (l) l.addEventListener("change", loadAudit);
|
||||
const q = $("#auQ"); let t; if (q) q.addEventListener("input", () => { clearTimeout(t); t = setTimeout(loadAudit, 300); });
|
||||
})();
|
||||
setInterval(loadAudit, 8000);
|
||||
|
||||
refresh().then(() => { loadAudit(); setInterval(refresh, 3000); }).catch((e) => { showLogin(); });
|
||||
@@ -0,0 +1,79 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Pricing — NetDesk</title>
|
||||
<meta name="description" content="Free for individuals, self-hosted forever. Pro and Business plans for teams. Simple, honest pricing for a self-hosted remote desktop.">
|
||||
<link rel="canonical" href="https://netdesk.space/pricing">
|
||||
<meta property="og:title" content="Pricing — NetDesk">
|
||||
<meta property="og:description" content="Free for individuals, self-hosted forever. Pro and Business plans for teams. Simple, honest pricing for a self-hosted remote desktop.">
|
||||
<meta property="og:url" content="https://netdesk.space/pricing">
|
||||
<meta name="theme-color" content="#0a0d14">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%233d7eff'/%3E%3Cpath d='M8 20l8-12 8 12-8-4z' fill='white'/%3E%3Crect x='12' y='18' width='8' height='7' rx='2' fill='white'/%3E%3C/svg%3E">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="site.css?v=2">
|
||||
</head>
|
||||
<body data-page="pricing">
|
||||
<div id="siteHeader"></div>
|
||||
|
||||
<section class="section"><div class="wrap">
|
||||
<div class="sec-head"><div class="eyebrow">Pricing</div><h2>Simple, honest pricing</h2>
|
||||
<p>Free for individuals, self-hosted forever. Upgrade for teams, unlimited sessions and priority support.</p></div>
|
||||
<div class="price-grid">
|
||||
<div class="plan">
|
||||
<h3>Individual</h3><div class="who">For personal use, self-hosted</div>
|
||||
<div class="price">$0<small>/forever</small></div>
|
||||
<ul>
|
||||
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> Self-host on your own server</li>
|
||||
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> End-to-end encryption</li>
|
||||
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> 1 registered computer</li>
|
||||
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> Sessions up to 60 min</li>
|
||||
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> File transfer & clipboard</li>
|
||||
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> Community support</li>
|
||||
</ul>
|
||||
<a class="btn btn-ghost" href="/download">Download free</a>
|
||||
</div>
|
||||
<div class="plan pop"><span class="badge">Most popular</span>
|
||||
<h3>Pro</h3><div class="who">For power users & small teams</div>
|
||||
<div class="price">$9<small>/month</small></div>
|
||||
<ul>
|
||||
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> Everything in Individual</li>
|
||||
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> Unlimited computers</li>
|
||||
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> Unlimited session length</li>
|
||||
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> Address book & device groups</li>
|
||||
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> 2FA & audit log</li>
|
||||
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> Priority email support</li>
|
||||
</ul>
|
||||
<a class="btn btn-primary" href="/contact?plan=pro">Start Pro</a>
|
||||
</div>
|
||||
<div class="plan">
|
||||
<h3>Business</h3><div class="who">For organizations at scale</div>
|
||||
<div class="price">Custom</div>
|
||||
<ul>
|
||||
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> Everything in Pro</li>
|
||||
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> SSO / SAML & SCIM</li>
|
||||
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> Central policy & permissions</li>
|
||||
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> On-prem & air-gapped deploy</li>
|
||||
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> SLA & dedicated support</li>
|
||||
<li><svg width="17" height="17" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3"><path d="M20 6L9 17l-5-5"/></svg> Custom branding</li>
|
||||
</ul>
|
||||
<a class="btn btn-ghost" href="/contact?plan=business">Contact sales</a>
|
||||
</div>
|
||||
</div>
|
||||
<p style="text-align:center;color:var(--faint);margin-top:26px;font-size:14px">
|
||||
Self-hosting NetDesk is always free and open source — you only pay for the hosted convenience tiers.
|
||||
Prices in USD. Cancel anytime. See our <a href="/refund" style="color:var(--accent2)">refund policy</a>.</p>
|
||||
</div></section>
|
||||
<section class="section alt"><div class="wrap"><div class="sec-head"><h2>Questions</h2></div>
|
||||
<div class="prose">
|
||||
<h3>Is NetDesk really free?</h3><p>Yes. The full software is open source and free to self-host, forever. The paid tiers add hosted convenience, unlimited sessions and team features.</p>
|
||||
<h3>What's the session time limit on the free tier?</h3><p>Individual (unregistered) sessions are capped at 60 minutes; simply reconnect to continue, or sign up / upgrade to remove the limit — the same model RustDesk uses.</p>
|
||||
<h3>Do you see my screen?</h3><p>Never. Sessions are end-to-end encrypted; even the relay server only sees opaque encrypted bytes.</p>
|
||||
<h3>Can I use it commercially?</h3><p>Self-hosting is permitted for commercial use under the open-source license. For managed/hosted commercial use, choose Pro or Business.</p>
|
||||
</div></div></section>
|
||||
<div id="siteFooter"></div>
|
||||
<script src="site.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Privacy Policy — NetDesk</title>
|
||||
<meta name="description" content="NetDesk Privacy Policy — privacy-first, self-hosted remote desktop.">
|
||||
<link rel="canonical" href="https://netdesk.space/privacy">
|
||||
<meta property="og:title" content="Privacy Policy — NetDesk">
|
||||
<meta property="og:description" content="NetDesk Privacy Policy — privacy-first, self-hosted remote desktop.">
|
||||
<meta property="og:url" content="https://netdesk.space/privacy">
|
||||
<meta name="theme-color" content="#0a0d14">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%233d7eff'/%3E%3Cpath d='M8 20l8-12 8 12-8-4z' fill='white'/%3E%3Crect x='12' y='18' width='8' height='7' rx='2' fill='white'/%3E%3C/svg%3E">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="site.css?v=2">
|
||||
</head>
|
||||
<body data-page="privacy">
|
||||
<div id="siteHeader"></div>
|
||||
|
||||
<section class="section"><div class="wrap"><div class="prose">
|
||||
<h1>Privacy Policy</h1><p class="updated">Last updated: September 27, 2026</p>
|
||||
<p>NetDesk is built privacy-first. This policy explains what the hosted service at netdesk.space collects. If you
|
||||
self-host, none of this applies — your data stays entirely on your own server.</p>
|
||||
<h2>What we cannot see</h2><p>We can never see the contents of your remote sessions. All screen, input, file and
|
||||
clipboard data is end-to-end encrypted between the two endpoints; our relay only forwards opaque ciphertext.</p>
|
||||
<h2>What the hosted service stores</h2>
|
||||
<ul>
|
||||
<li><strong>Account data:</strong> your email and a salted password hash (PBKDF2), if you create an account.</li>
|
||||
<li><strong>Device registry:</strong> the IDs and names of computers you bind to your account, and online/offline status.</li>
|
||||
<li><strong>Operational logs:</strong> connection timestamps, IP addresses and coarse events for security and abuse prevention, retained for a limited period.</li>
|
||||
</ul>
|
||||
<p>We do <strong>not</strong> store your host passwords, session contents, or screen data.</p>
|
||||
<h2>Cookies</h2><p>We use a single essential cookie to keep you signed in. No third-party advertising or tracking cookies.</p>
|
||||
<h2>Sharing</h2><p>We do not sell your data. We share it only as required by law or to operate the service (e.g., our mail
|
||||
provider for verification emails).</p>
|
||||
<h2>Your rights</h2><p>You may access, export or delete your account data at any time from the console, or by emailing
|
||||
<a href="mailto:privacy@netdesk.space">privacy@netdesk.space</a>.</p>
|
||||
<h2>Contact</h2><p>Questions about privacy? Email privacy@netdesk.space.</p>
|
||||
</div></div></section>
|
||||
<div id="siteFooter"></div>
|
||||
<script src="site.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Refund Policy — NetDesk</title>
|
||||
<meta name="description" content="NetDesk refund policy: 14-day money-back guarantee on paid plans.">
|
||||
<link rel="canonical" href="https://netdesk.space/refund">
|
||||
<meta property="og:title" content="Refund Policy — NetDesk">
|
||||
<meta property="og:description" content="NetDesk refund policy: 14-day money-back guarantee on paid plans.">
|
||||
<meta property="og:url" content="https://netdesk.space/refund">
|
||||
<meta name="theme-color" content="#0a0d14">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%233d7eff'/%3E%3Cpath d='M8 20l8-12 8 12-8-4z' fill='white'/%3E%3Crect x='12' y='18' width='8' height='7' rx='2' fill='white'/%3E%3C/svg%3E">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="site.css?v=2">
|
||||
</head>
|
||||
<body data-page="refund">
|
||||
<div id="siteHeader"></div>
|
||||
|
||||
<section class="section"><div class="wrap"><div class="prose">
|
||||
<h1>Refund Policy</h1><p class="updated">Last updated: September 27, 2026</p>
|
||||
<p>We want you to be happy with NetDesk Pro and Business.</p>
|
||||
<h2>14-day money-back guarantee</h2><p>If you're not satisfied with a paid plan, request a full refund within 14 days
|
||||
of your first payment — no questions asked. Email <a href="mailto:billing@netdesk.space">billing@netdesk.space</a>.</p>
|
||||
<h2>Renewals</h2><p>Renewal charges are refundable within 7 days of the renewal date. Cancel anytime to stop future charges.</p>
|
||||
<h2>Self-hosting</h2><p>The self-hosted software is free and open source — there is nothing to refund.</p>
|
||||
</div></div></section>
|
||||
<div id="siteFooter"></div>
|
||||
<script src="site.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,48 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Security — NetDesk</title>
|
||||
<meta name="description" content="How NetDesk keeps your remote sessions private: AES-256 end-to-end encryption, PBKDF2 auth, TOTP 2FA, TLS transport and open source.">
|
||||
<link rel="canonical" href="https://netdesk.space/security">
|
||||
<meta property="og:title" content="Security — NetDesk">
|
||||
<meta property="og:description" content="How NetDesk keeps your remote sessions private: AES-256 end-to-end encryption, PBKDF2 auth, TOTP 2FA, TLS transport and open source.">
|
||||
<meta property="og:url" content="https://netdesk.space/security">
|
||||
<meta name="theme-color" content="#0a0d14">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%233d7eff'/%3E%3Cpath d='M8 20l8-12 8 12-8-4z' fill='white'/%3E%3Crect x='12' y='18' width='8' height='7' rx='2' fill='white'/%3E%3C/svg%3E">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="site.css?v=2">
|
||||
</head>
|
||||
<body data-page="security">
|
||||
<div id="siteHeader"></div>
|
||||
|
||||
<section class="section"><div class="wrap"><div class="prose">
|
||||
<h1>Security & Trust</h1><p class="updated">Our commitment to keeping your remote sessions private.</p>
|
||||
<h2>End-to-end encryption by default</h2>
|
||||
<p>Every NetDesk session negotiates an ephemeral <strong>ECDH P-256</strong> key exchange and encrypts all media with
|
||||
<strong>AES-256-GCM</strong>, per frame, with a fresh nonce. The relay server forwards only opaque ciphertext — it
|
||||
cannot decrypt your screen, input, files or clipboard. Only the two endpoints hold the keys.</p>
|
||||
<h2>Authentication</h2>
|
||||
<p>The host password never leaves your machine in the clear: the viewer proves knowledge of it with a
|
||||
<strong>PBKDF2-HMAC-SHA256 (200,000 iterations)</strong> challenge/response, throttled on failure. Optional
|
||||
<strong>TOTP two-factor</strong> adds an authenticator code at connect. An optional registration key restricts which
|
||||
hosts may register with your server.</p>
|
||||
<h2>Transport</h2>
|
||||
<p>All traffic runs over <strong>TLS (WSS)</strong> to your server. We recommend a real certificate (Let's Encrypt);
|
||||
NetDesk ships automation for it. HTTP is available only for localhost/LAN testing.</p>
|
||||
<h2>You own the data</h2>
|
||||
<p>NetDesk is self-hosted. There is no NetDesk cloud in the path. Your session metadata, accounts and audit logs live on
|
||||
<strong>your</strong> server. Nothing is sent to a third party.</p>
|
||||
<h2>Open source</h2>
|
||||
<p>The entire stack is open source and auditable. Don't trust our word — read the code at
|
||||
<a href="/git/akronlogic/netdesk">netdesk.space/git/akronlogic/netdesk</a>.</p>
|
||||
<h2>Responsible disclosure</h2>
|
||||
<p>Found a vulnerability? Email <a href="mailto:security@netdesk.space">security@netdesk.space</a>. We respond within 72
|
||||
hours and credit reporters.</p>
|
||||
</div></div></section>
|
||||
<div id="siteFooter"></div>
|
||||
<script src="site.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,289 @@
|
||||
/* ================================================================
|
||||
NetDesk marketing site — production design system
|
||||
================================================================ */
|
||||
:root {
|
||||
--bg: #0a0d14; --bg2: #10141e; --bg3: #161c28; --card: #141a26;
|
||||
--fg: #eaeff8; --muted: #9aa4b6; --faint: #6b7688;
|
||||
--accent: #3d7eff; --accent2: #6aa1ff; --accent-dim: rgba(61,126,255,.12);
|
||||
--ok: #2fbf71; --border: #212a3a; --border-soft: #1a2130;
|
||||
--r: 14px; --r-sm: 9px; --maxw: 1160px;
|
||||
--grad: linear-gradient(135deg, #3d7eff 0%, #6aa1ff 55%, #2fbf71 130%);
|
||||
--shadow: 0 24px 60px rgba(0,0,0,.45);
|
||||
}
|
||||
:root[data-theme="light"] {
|
||||
--bg: #f6f8fc; --bg2: #ffffff; --bg3: #eef2f8; --card: #ffffff;
|
||||
--fg: #131a26; --muted: #55617a; --faint: #97a1b4;
|
||||
--border: #e2e8f1; --border-soft: #edf1f7; --accent-dim: rgba(61,126,255,.08);
|
||||
--shadow: 0 24px 60px rgba(24,39,75,.10);
|
||||
}
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
html { scroll-behavior: smooth; }
|
||||
body {
|
||||
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
background: var(--bg); color: var(--fg); line-height: 1.6; font-size: 16px;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
a { color: inherit; text-decoration: none; }
|
||||
img { max-width: 100%; display: block; }
|
||||
.wrap { max-width: var(--maxw); margin: 0 auto; padding: 0 22px; }
|
||||
.sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); }
|
||||
|
||||
/* ---- header ---- */
|
||||
.nz { position: sticky; top: 0; z-index: 50; backdrop-filter: blur(14px);
|
||||
background: color-mix(in srgb, var(--bg) 82%, transparent); border-bottom: 1px solid var(--border-soft); }
|
||||
.nz-in { display: flex; align-items: center; justify-content: space-between; height: 66px; }
|
||||
.nz-brand { display: flex; align-items: center; gap: 10px; font-weight: 800; font-size: 20px; letter-spacing: -.4px; }
|
||||
.nz-logo { width: 34px; height: 34px; border-radius: 9px; background: var(--grad);
|
||||
display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 14px rgba(61,126,255,.35); }
|
||||
.nz-links { display: flex; align-items: center; gap: 4px; }
|
||||
.nz-links a { padding: 9px 14px; border-radius: 8px; color: var(--muted); font-weight: 500; font-size: 15px; transition: .15s; }
|
||||
.nz-links a:hover { color: var(--fg); background: var(--bg3); }
|
||||
.nz-links a.active { color: var(--fg); }
|
||||
.nz-cta { display: flex; align-items: center; gap: 10px; }
|
||||
.nz-burger { display: none; background: none; border: 1px solid var(--border); border-radius: 8px; padding: 8px 10px; color: var(--fg); cursor: pointer; }
|
||||
.btn { display: inline-flex; align-items: center; gap: 8px; padding: 11px 20px; border-radius: 10px;
|
||||
font-weight: 600; font-size: 15px; cursor: pointer; border: 1px solid transparent; transition: .15s; white-space: nowrap; }
|
||||
.btn-primary { background: var(--grad); color: #fff; box-shadow: 0 8px 22px rgba(61,126,255,.32); }
|
||||
.btn-primary:hover { filter: brightness(1.07); transform: translateY(-1px); }
|
||||
.btn-ghost { background: var(--bg2); color: var(--fg); border-color: var(--border); }
|
||||
.btn-ghost:hover { border-color: var(--accent); }
|
||||
.btn-lg { padding: 15px 28px; font-size: 16.5px; border-radius: 12px; }
|
||||
|
||||
/* ---- hero ---- */
|
||||
.hero { position: relative; overflow: hidden; padding: 92px 0 70px; text-align: center; }
|
||||
.hero::before { content: ""; position: absolute; inset: 0; z-index: -1;
|
||||
background: radial-gradient(760px 420px at 50% -8%, rgba(61,126,255,.22), transparent 62%),
|
||||
radial-gradient(680px 420px at 88% 24%, rgba(47,191,113,.12), transparent 60%); }
|
||||
.tagpill { display: inline-flex; align-items: center; gap: 8px; padding: 6px 14px; border-radius: 999px;
|
||||
background: var(--accent-dim); border: 1px solid var(--border); color: var(--accent2); font-size: 13.5px; font-weight: 600; margin-bottom: 22px; }
|
||||
.hero h1 { font-size: clamp(38px, 6vw, 66px); line-height: 1.05; letter-spacing: -1.6px; font-weight: 850; margin-bottom: 20px; }
|
||||
.hero h1 .g { background: var(--grad); -webkit-background-clip: text; background-clip: text; color: transparent; }
|
||||
.hero p.sub { font-size: clamp(17px, 2.2vw, 21px); color: var(--muted); max-width: 720px; margin: 0 auto 32px; }
|
||||
.hero-cta { display: flex; gap: 14px; justify-content: center; flex-wrap: wrap; }
|
||||
.hero-note { margin-top: 18px; color: var(--faint); font-size: 14px; }
|
||||
.hero-shot { max-width: 940px; margin: 54px auto 0; border-radius: 16px; border: 1px solid var(--border);
|
||||
box-shadow: var(--shadow); overflow: hidden; background: var(--bg2); }
|
||||
|
||||
/* ---- sections ---- */
|
||||
.section { padding: 74px 0; }
|
||||
.section.alt { background: var(--bg2); border-top: 1px solid var(--border-soft); border-bottom: 1px solid var(--border-soft); }
|
||||
.sec-head { text-align: center; max-width: 660px; margin: 0 auto 48px; }
|
||||
.sec-head .eyebrow { color: var(--accent2); font-weight: 700; font-size: 14px; letter-spacing: 1px; text-transform: uppercase; }
|
||||
.sec-head h2 { font-size: clamp(28px, 4vw, 42px); letter-spacing: -1px; font-weight: 800; margin: 10px 0 12px; }
|
||||
.sec-head p { color: var(--muted); font-size: 18px; }
|
||||
|
||||
.grid3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 20px; }
|
||||
.grid2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 20px; }
|
||||
.feat { background: var(--card); border: 1px solid var(--border); border-radius: var(--r); padding: 26px; transition: .18s; }
|
||||
.feat:hover { border-color: var(--accent); transform: translateY(-3px); box-shadow: var(--shadow); }
|
||||
.feat .ic { width: 46px; height: 46px; border-radius: 11px; background: var(--accent-dim); color: var(--accent2);
|
||||
display: flex; align-items: center; justify-content: center; margin-bottom: 16px; }
|
||||
.feat h3 { font-size: 18.5px; font-weight: 700; margin-bottom: 8px; }
|
||||
.feat p { color: var(--muted); font-size: 15px; }
|
||||
|
||||
/* ---- pricing ---- */
|
||||
.price-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px; align-items: stretch; }
|
||||
.plan { background: var(--card); border: 1px solid var(--border); border-radius: var(--r); padding: 32px 28px; display: flex; flex-direction: column; }
|
||||
.plan.pop { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent), var(--shadow); position: relative; }
|
||||
.plan .badge { position: absolute; top: -13px; left: 50%; transform: translateX(-50%); background: var(--grad);
|
||||
color: #fff; font-size: 12.5px; font-weight: 700; padding: 5px 14px; border-radius: 999px; }
|
||||
.plan h3 { font-size: 20px; font-weight: 800; }
|
||||
.plan .who { color: var(--muted); font-size: 14.5px; margin: 4px 0 18px; }
|
||||
.plan .price { font-size: 44px; font-weight: 850; letter-spacing: -1.5px; }
|
||||
.plan .price small { font-size: 16px; color: var(--muted); font-weight: 500; }
|
||||
.plan ul { list-style: none; margin: 22px 0; display: grid; gap: 12px; }
|
||||
.plan li { display: flex; gap: 10px; font-size: 15px; color: var(--fg); }
|
||||
.plan li svg { flex-shrink: 0; color: var(--ok); margin-top: 3px; }
|
||||
.plan .btn { justify-content: center; margin-top: auto; }
|
||||
|
||||
/* ---- prose (legal/about) ---- */
|
||||
.prose { max-width: 820px; margin: 0 auto; }
|
||||
.prose h1 { font-size: 40px; letter-spacing: -1px; margin-bottom: 8px; }
|
||||
.prose .updated { color: var(--faint); font-size: 14px; margin-bottom: 34px; }
|
||||
.prose h2 { font-size: 24px; margin: 34px 0 12px; letter-spacing: -.4px; }
|
||||
.prose h3 { font-size: 18px; margin: 22px 0 8px; }
|
||||
.prose p, .prose li { color: var(--muted); margin-bottom: 12px; }
|
||||
.prose ul, .prose ol { padding-left: 22px; margin-bottom: 14px; }
|
||||
.prose a { color: var(--accent2); }
|
||||
.prose strong { color: var(--fg); }
|
||||
|
||||
/* ---- contact ---- */
|
||||
.contact-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 40px; align-items: start; }
|
||||
.field { margin-bottom: 16px; }
|
||||
.field label { display: block; font-size: 14px; font-weight: 600; margin-bottom: 6px; }
|
||||
.field input, .field textarea, .field select { width: 100%; background: var(--bg2); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 12px 14px; color: var(--fg); font-size: 15px; font-family: inherit; outline: none; }
|
||||
.field input:focus, .field textarea:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-dim); }
|
||||
.info-row { display: flex; gap: 14px; padding: 16px 0; border-bottom: 1px solid var(--border-soft); }
|
||||
.info-row .ic { color: var(--accent2); }
|
||||
|
||||
/* ---- cta band ---- */
|
||||
.ctaband { text-align: center; padding: 70px 0; background: var(--bg2); border-top: 1px solid var(--border-soft); }
|
||||
.ctaband h2 { font-size: clamp(28px, 4vw, 40px); letter-spacing: -1px; margin-bottom: 12px; }
|
||||
.ctaband p { color: var(--muted); font-size: 18px; margin-bottom: 26px; }
|
||||
|
||||
/* ---- footer ---- */
|
||||
.ft { background: var(--bg2); border-top: 1px solid var(--border); padding: 56px 0 28px; }
|
||||
.ft-top { display: grid; grid-template-columns: 1.6fr 1fr 1fr 1fr; gap: 30px; }
|
||||
.ft-brand p { color: var(--muted); font-size: 14.5px; margin: 14px 0; max-width: 300px; }
|
||||
.ft-col h4 { font-size: 13px; text-transform: uppercase; letter-spacing: .8px; color: var(--faint); margin-bottom: 14px; }
|
||||
.ft-col a { display: block; color: var(--muted); font-size: 15px; padding: 5px 0; transition: .12s; }
|
||||
.ft-col a:hover { color: var(--fg); }
|
||||
.ft-bot { display: flex; align-items: center; justify-content: space-between; margin-top: 40px; padding-top: 22px;
|
||||
border-top: 1px solid var(--border-soft); color: var(--faint); font-size: 14px; flex-wrap: wrap; gap: 12px; }
|
||||
.ft-social { display: flex; gap: 12px; }
|
||||
.ft-social a { color: var(--muted); } .ft-social a:hover { color: var(--fg); }
|
||||
.theme-toggle { background: var(--bg3); border: 1px solid var(--border); border-radius: 8px; color: var(--fg);
|
||||
padding: 8px 12px; cursor: pointer; font-size: 13.5px; }
|
||||
|
||||
/* ---- misc ---- */
|
||||
.logos { display: flex; gap: 34px; justify-content: center; align-items: center; flex-wrap: wrap; opacity: .8; color: var(--muted); font-weight: 600; }
|
||||
.compare { width: 100%; border-collapse: collapse; }
|
||||
.compare th, .compare td { padding: 14px 16px; text-align: left; border-bottom: 1px solid var(--border-soft); font-size: 15px; }
|
||||
.compare th { color: var(--faint); font-weight: 600; font-size: 13px; text-transform: uppercase; }
|
||||
.compare td.y { color: var(--ok); font-weight: 700; }
|
||||
.compare td.n { color: var(--faint); }
|
||||
.compare tr td:first-child { color: var(--fg); font-weight: 600; }
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.nz-links { display: none; }
|
||||
.nz-burger { display: block; }
|
||||
.nz-links.open { display: flex; position: absolute; top: 66px; left: 0; right: 0; flex-direction: column;
|
||||
background: var(--bg2); border-bottom: 1px solid var(--border); padding: 12px; gap: 4px; }
|
||||
.grid3, .price-grid, .ft-top { grid-template-columns: 1fr; }
|
||||
.grid2, .contact-grid { grid-template-columns: 1fr; }
|
||||
.ft-top { gap: 24px; }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
/* Phones: keep the top bar to logo + primary CTA + burger so the page never
|
||||
scrolls sideways. The secondary "Sign in" link stays reachable in-page. */
|
||||
.nz-cta .btn-ghost { display: none; }
|
||||
.nz-cta { gap: 8px; }
|
||||
}
|
||||
|
||||
/* ================================================================
|
||||
ELEVATION LAYER v2 — award-winning, enterprise-grade polish.
|
||||
Component-scoped overrides of the base system above (cascade wins),
|
||||
so every marketing page that links site.css is lifted at once.
|
||||
================================================================ */
|
||||
:root{
|
||||
--accent-grad: linear-gradient(135deg,#4d8bff 0%,#6aa1ff 42%,#34d399 115%);
|
||||
--glass: color-mix(in srgb, var(--card) 70%, transparent);
|
||||
--hair: rgba(255,255,255,.07);
|
||||
--hair-strong: rgba(255,255,255,.14);
|
||||
--shadow-sm: 0 2px 10px rgba(0,0,0,.28);
|
||||
--shadow-lg: 0 34px 90px -28px rgba(0,0,0,.70);
|
||||
--ring: 0 0 0 4px rgba(77,139,255,.14);
|
||||
--r: 16px; --r-sm: 11px;
|
||||
}
|
||||
:root[data-theme="light"]{
|
||||
--glass: rgba(255,255,255,.72);
|
||||
--hair: rgba(16,24,40,.06); --hair-strong: rgba(16,24,40,.12);
|
||||
--shadow-lg: 0 34px 90px -30px rgba(24,39,75,.22);
|
||||
}
|
||||
|
||||
/* fine film-grain for tactile depth — fixed, never intercepts input */
|
||||
body::after{
|
||||
content:""; position:fixed; inset:0; z-index:9998; pointer-events:none; opacity:.03;
|
||||
mix-blend-mode:overlay;
|
||||
background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.82' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
|
||||
}
|
||||
|
||||
/* ---- scroll reveal (universal; site.js adds .in on intersect) ---- */
|
||||
.reveal{ opacity:0; transform:translateY(20px); transition:opacity .7s cubic-bezier(.2,.7,.2,1), transform .7s cubic-bezier(.2,.7,.2,1); will-change:opacity,transform; }
|
||||
.reveal.in{ opacity:1; transform:none; }
|
||||
@media (prefers-reduced-motion: reduce){ .reveal{ opacity:1 !important; transform:none !important; } }
|
||||
|
||||
/* ---- nav: crisper glass + gradient hairline ---- */
|
||||
.nz{ background: color-mix(in srgb, var(--bg) 68%, transparent); border-bottom:1px solid var(--hair); }
|
||||
.nz::after{ content:""; position:absolute; left:0; right:0; bottom:-1px; height:1px;
|
||||
background:linear-gradient(90deg,transparent,rgba(77,139,255,.4),rgba(52,211,153,.3),transparent); opacity:.6; }
|
||||
.nz-logo{ box-shadow:0 6px 18px -4px rgba(77,139,255,.5), inset 0 1px 0 rgba(255,255,255,.3); }
|
||||
.nz-links a:hover{ background:var(--hair); color:var(--fg); }
|
||||
.nz-links a.active{ color:var(--fg); }
|
||||
.nz-links a.active::after{ content:""; display:block; height:2px; margin-top:7px; border-radius:2px; background:var(--accent-grad); }
|
||||
|
||||
/* ---- buttons: gradient + inner highlight + hover sheen ---- */
|
||||
.btn{ border-radius:12px; letter-spacing:-.1px; transition:transform .16s ease, box-shadow .16s ease, filter .16s ease, border-color .16s ease; }
|
||||
.btn-primary{ background:var(--accent-grad); position:relative; overflow:hidden;
|
||||
box-shadow:0 10px 28px -8px rgba(77,139,255,.55), inset 0 1px 0 rgba(255,255,255,.28); }
|
||||
.btn-primary::before{ content:""; position:absolute; top:0; left:-120%; width:60%; height:100%;
|
||||
background:linear-gradient(100deg,transparent,rgba(255,255,255,.35),transparent); transform:skewX(-18deg); transition:left .6s ease; }
|
||||
.btn-primary:hover::before{ left:140%; }
|
||||
.btn-primary:hover{ transform:translateY(-2px); box-shadow:0 16px 40px -10px rgba(77,139,255,.6), inset 0 1px 0 rgba(255,255,255,.3); filter:none; }
|
||||
.btn-ghost{ background:var(--glass); backdrop-filter:blur(10px); border-color:var(--hair-strong); }
|
||||
.btn-ghost:hover{ border-color:var(--accent); background:var(--card); transform:translateY(-1px); }
|
||||
.btn:focus-visible{ outline:none; box-shadow:var(--ring); }
|
||||
|
||||
/* ---- feature cards: gradient border + cursor spotlight + lifted hover ---- */
|
||||
.feat{ position:relative; border:1px solid transparent; border-radius:var(--r); padding:28px; overflow:hidden;
|
||||
background:linear-gradient(var(--card),var(--card)) padding-box,
|
||||
linear-gradient(180deg,var(--hair-strong),rgba(255,255,255,.015)) border-box;
|
||||
transition:transform .22s cubic-bezier(.2,.7,.2,1), box-shadow .22s ease; }
|
||||
.feat::before{ content:""; position:absolute; inset:0; border-radius:inherit; opacity:0; transition:opacity .3s; pointer-events:none;
|
||||
background:radial-gradient(420px circle at var(--mx,50%) var(--my,-10%), rgba(77,139,255,.16), transparent 42%); }
|
||||
.feat:hover{ transform:translateY(-5px); box-shadow:var(--shadow-lg); }
|
||||
.feat:hover::before{ opacity:1; }
|
||||
.feat .ic{ width:50px; height:50px; border-radius:13px; color:#a8c6ff; border:1px solid var(--hair);
|
||||
background:linear-gradient(135deg, rgba(77,139,255,.20), rgba(52,211,153,.10));
|
||||
box-shadow:inset 0 1px 0 rgba(255,255,255,.14), 0 8px 20px -8px rgba(77,139,255,.45); }
|
||||
.feat h3{ font-size:19px; letter-spacing:-.3px; }
|
||||
.feat p{ font-size:15px; line-height:1.62; }
|
||||
|
||||
/* ---- generic card (pricing/other) spotlight hover parity ---- */
|
||||
.plan{ position:relative; border-radius:var(--r); transition:transform .22s cubic-bezier(.2,.7,.2,1), box-shadow .22s ease; }
|
||||
.plan:hover{ transform:translateY(-4px); box-shadow:var(--shadow-lg); }
|
||||
.plan.pop{ border:1px solid transparent;
|
||||
background:linear-gradient(var(--card),var(--card)) padding-box, var(--accent-grad) border-box;
|
||||
box-shadow:0 0 70px -24px rgba(77,139,255,.6), var(--shadow-lg); }
|
||||
.plan .price{ background:var(--accent-grad); -webkit-background-clip:text; background-clip:text; color:transparent; }
|
||||
.plan.pop .badge{ box-shadow:0 8px 22px -6px rgba(77,139,255,.6); }
|
||||
|
||||
/* ---- section heads: gradient eyebrow + tighter type ---- */
|
||||
.sec-head .eyebrow{ display:inline-block; background:var(--accent-grad); -webkit-background-clip:text; background-clip:text; color:transparent; }
|
||||
.sec-head h2{ letter-spacing:-1.2px; }
|
||||
.section.alt{ background:color-mix(in srgb,var(--bg2) 92%, #000 8%); }
|
||||
|
||||
/* ---- comparison table: container + highlighted NetDesk column + icons ---- */
|
||||
.compare{ border-collapse:separate; border-spacing:0; border:1px solid var(--hair); border-radius:var(--r); overflow:hidden; background:var(--card); box-shadow:var(--shadow-sm); }
|
||||
.compare th, .compare td{ border-bottom:1px solid var(--hair); }
|
||||
.compare tr:last-child td{ border-bottom:none; }
|
||||
.compare th:nth-child(2), .compare td:nth-child(2){ background:color-mix(in srgb,var(--accent) 8%, transparent); position:relative; }
|
||||
.compare th:nth-child(2){ color:var(--accent2); }
|
||||
.compare thead th:nth-child(2)::after, .compare tr:first-child td:nth-child(2)::before{ }
|
||||
.compare td.y{ color:var(--ok); }
|
||||
.compare tr td:first-child{ color:var(--fg); }
|
||||
|
||||
/* ---- enterprise trust: stats band ---- */
|
||||
.statband{ border-top:1px solid var(--hair); border-bottom:1px solid var(--hair);
|
||||
background:color-mix(in srgb,var(--bg2) 60%, transparent); }
|
||||
.statband .wrap{ display:grid; grid-template-columns:repeat(4,1fr); gap:18px; padding:30px 22px; }
|
||||
.stat{ text-align:center; position:relative; }
|
||||
.stat:not(:last-child)::after{ content:""; position:absolute; right:-9px; top:14%; height:72%; width:1px; background:var(--hair); }
|
||||
.stat b{ display:block; font-size:clamp(26px,3.4vw,36px); font-weight:850; letter-spacing:-1px; line-height:1;
|
||||
background:var(--accent-grad); -webkit-background-clip:text; background-clip:text; color:transparent; }
|
||||
.stat span{ display:block; margin-top:8px; color:var(--muted); font-size:13.5px; font-weight:500; }
|
||||
|
||||
/* ---- logos strip: refined ---- */
|
||||
.logos{ gap:40px; opacity:1; }
|
||||
.logos span{ color:var(--faint); font-weight:650; font-size:15px; letter-spacing:.2px; transition:color .2s, filter .2s; filter:grayscale(1) opacity(.7); }
|
||||
.logos span:hover{ color:var(--muted); filter:none; }
|
||||
|
||||
/* ---- hero shot: layered depth ---- */
|
||||
.hero-shot, .hz-shot{ box-shadow:var(--shadow-lg), 0 0 0 1px var(--hair); }
|
||||
|
||||
/* ---- footer: subtle top glow ---- */
|
||||
.ft{ position:relative; border-top:1px solid var(--hair); }
|
||||
.ft::before{ content:""; position:absolute; top:-1px; left:0; right:0; height:1px;
|
||||
background:linear-gradient(90deg,transparent,rgba(77,139,255,.35),transparent); }
|
||||
.theme-toggle:hover{ border-color:var(--accent); }
|
||||
|
||||
/* ---- form fields: softer focus ---- */
|
||||
.field input:focus, .field textarea:focus, .field select:focus{ box-shadow:var(--ring); }
|
||||
|
||||
/* ---- responsive: stats stack ---- */
|
||||
@media (max-width:760px){
|
||||
.statband .wrap{ grid-template-columns:repeat(2,1fr); gap:24px 12px; }
|
||||
.stat:nth-child(2)::after{ display:none; }
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
/* NetDesk marketing site: shared header + footer, theme, mobile nav. */
|
||||
(function () {
|
||||
"use strict";
|
||||
var YEAR = 2026;
|
||||
var NAV = [
|
||||
["Features", "/features"], ["Pricing", "/pricing"],
|
||||
["Security", "/security"], ["Download", "/download"],
|
||||
["Source", "/git/akronlogic/netdesk"]
|
||||
];
|
||||
var page = (document.body.getAttribute("data-page") || "").toLowerCase();
|
||||
|
||||
function svgLogo() {
|
||||
return '<svg width="20" height="20" viewBox="0 0 32 32"><path d="M8 20l8-12 8 12-8-4z" fill="#fff"/>' +
|
||||
'<rect x="12" y="18" width="8" height="7" rx="2" fill="#fff"/></svg>';
|
||||
}
|
||||
|
||||
function header() {
|
||||
var links = NAV.map(function (n) {
|
||||
var active = page === n[0].toLowerCase() ? " class=\"active\"" : "";
|
||||
return '<a href="' + n[1] + '"' + active + '>' + n[0] + "</a>";
|
||||
}).join("");
|
||||
return '<header class="nz"><div class="wrap nz-in">' +
|
||||
'<a class="nz-brand" href="/"><span class="nz-logo">' + svgLogo() + "</span>NetDesk</a>" +
|
||||
'<nav class="nz-links" id="nzLinks">' + links +
|
||||
'<a href="/contact">Contact</a><a href="/portal">Self-Host Console</a></nav>' +
|
||||
'<div class="nz-cta">' +
|
||||
'<a class="btn btn-ghost" href="/app" id="btnSignin">Sign in</a>' +
|
||||
'<a class="btn btn-primary" href="/download">Get NetDesk</a>' +
|
||||
'<button class="nz-burger" id="nzBurger" aria-label="Menu">☰</button>' +
|
||||
"</div></div></header>";
|
||||
}
|
||||
|
||||
function footer() {
|
||||
return '<footer class="ft"><div class="wrap">' +
|
||||
'<div class="ft-top">' +
|
||||
'<div class="ft-brand"><a class="nz-brand" href="/"><span class="nz-logo">' + svgLogo() + "</span>NetDesk</a>" +
|
||||
"<p>The fast, secure, self-hosted remote desktop. Own your infrastructure, your keys, and your data — " +
|
||||
"the alternative to TeamViewer, AnyDesk and RustDesk that you fully control.</p>" +
|
||||
'<button class="theme-toggle" id="ftTheme">☀ Toggle theme</button></div>' +
|
||||
'<div class="ft-col"><h4>Product</h4>' +
|
||||
'<a href="/features">Features</a><a href="/pricing">Pricing</a><a href="/download">Download</a>' +
|
||||
'<a href="/security">Security</a><a href="/app">Web Client</a><a href="/git/akronlogic/netdesk">Source Code</a></div>' +
|
||||
'<div class="ft-col"><h4>Company</h4>' +
|
||||
'<a href="/about">About</a><a href="/contact">Contact</a><a href="/portal">Self-Hosting Console</a>' +
|
||||
'<a href="/git/akronlogic/netdesk">Open Source</a></div>' +
|
||||
'<div class="ft-col"><h4>Legal</h4>' +
|
||||
'<a href="/terms">Terms of Service</a><a href="/privacy">Privacy Policy</a>' +
|
||||
'<a href="/security">Security & Trust</a><a href="/refund">Refund Policy</a></div>' +
|
||||
"</div>" +
|
||||
'<div class="ft-bot"><span>© ' + YEAR + ' NetDesk. Self-hosted remote access, done right.</span>' +
|
||||
'<span class="ft-social">' +
|
||||
'<a href="/git/akronlogic/netdesk" aria-label="Source code">Source</a>' +
|
||||
'<a href="/contact">Support</a><a href="/privacy">Privacy</a></span>' +
|
||||
"</div></div></footer>";
|
||||
}
|
||||
|
||||
function applyTheme(t) {
|
||||
document.documentElement.setAttribute("data-theme", t);
|
||||
try { localStorage.setItem("nd.theme", t); } catch (e) {}
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", function () {
|
||||
var h = document.getElementById("siteHeader");
|
||||
var f = document.getElementById("siteFooter");
|
||||
if (h) h.innerHTML = header();
|
||||
if (f) f.innerHTML = footer();
|
||||
|
||||
var burger = document.getElementById("nzBurger");
|
||||
if (burger) burger.onclick = function () { document.getElementById("nzLinks").classList.toggle("open"); };
|
||||
var ft = document.getElementById("ftTheme");
|
||||
if (ft) ft.onclick = function () {
|
||||
applyTheme(document.documentElement.getAttribute("data-theme") === "light" ? "dark" : "light");
|
||||
};
|
||||
// reflect signed-in state in the header
|
||||
fetch("/api/auth/me").then(function (r) { return r.ok ? r.json() : null; }).then(function (d) {
|
||||
var b = document.getElementById("btnSignin");
|
||||
if (d && d.email && b) { b.textContent = "My Computers"; b.href = "/app"; }
|
||||
}).catch(function () {});
|
||||
|
||||
// --- scroll reveal (every page that uses .reveal) ---
|
||||
var revs = document.querySelectorAll(".reveal");
|
||||
if (revs.length && "IntersectionObserver" in window) {
|
||||
var io = new IntersectionObserver(function (es) {
|
||||
es.forEach(function (e) { if (e.isIntersecting) { e.target.classList.add("in"); io.unobserve(e.target); } });
|
||||
}, { threshold: 0.1, rootMargin: "0px 0px -8% 0px" });
|
||||
revs.forEach(function (el) { io.observe(el); });
|
||||
} else {
|
||||
revs.forEach(function (el) { el.classList.add("in"); });
|
||||
}
|
||||
|
||||
// --- cursor spotlight on cards (sets --mx/--my for the ::before glow) ---
|
||||
function spot(e) {
|
||||
var r = e.currentTarget.getBoundingClientRect();
|
||||
e.currentTarget.style.setProperty("--mx", (e.clientX - r.left) + "px");
|
||||
e.currentTarget.style.setProperty("--my", (e.clientY - r.top) + "px");
|
||||
}
|
||||
document.querySelectorAll(".feat, .plan").forEach(function (c) {
|
||||
c.addEventListener("mousemove", spot, { passive: true });
|
||||
});
|
||||
});
|
||||
|
||||
try { applyTheme(localStorage.getItem("nd.theme") || "dark"); } catch (e) { applyTheme("dark"); }
|
||||
})();
|
||||
@@ -0,0 +1,13 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||
<url><loc>https://netdesk.space/</loc><changefreq>weekly</changefreq><priority>1.0</priority></url>
|
||||
<url><loc>https://netdesk.space/pricing</loc><changefreq>weekly</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://netdesk.space/features</loc><changefreq>weekly</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://netdesk.space/security</loc><changefreq>weekly</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://netdesk.space/download</loc><changefreq>weekly</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://netdesk.space/contact</loc><changefreq>weekly</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://netdesk.space/about</loc><changefreq>weekly</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://netdesk.space/terms</loc><changefreq>weekly</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://netdesk.space/privacy</loc><changefreq>weekly</changefreq><priority>0.7</priority></url>
|
||||
<url><loc>https://netdesk.space/refund</loc><changefreq>weekly</changefreq><priority>0.7</priority></url>
|
||||
</urlset>
|
||||
@@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Terms of Service — NetDesk</title>
|
||||
<meta name="description" content="NetDesk Terms of Service.">
|
||||
<link rel="canonical" href="https://netdesk.space/terms">
|
||||
<meta property="og:title" content="Terms of Service — NetDesk">
|
||||
<meta property="og:description" content="NetDesk Terms of Service.">
|
||||
<meta property="og:url" content="https://netdesk.space/terms">
|
||||
<meta name="theme-color" content="#0a0d14">
|
||||
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%233d7eff'/%3E%3Cpath d='M8 20l8-12 8 12-8-4z' fill='white'/%3E%3Crect x='12' y='18' width='8' height='7' rx='2' fill='white'/%3E%3C/svg%3E">
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
|
||||
<link rel="stylesheet" href="site.css?v=2">
|
||||
</head>
|
||||
<body data-page="terms">
|
||||
<div id="siteHeader"></div>
|
||||
|
||||
<section class="section"><div class="wrap"><div class="prose">
|
||||
<h1>Terms of Service</h1><p class="updated">Last updated: September 27, 2026</p>
|
||||
<p>These Terms govern your use of NetDesk software and the optional hosted services provided at netdesk.space
|
||||
("Service"). By downloading, self-hosting or using NetDesk, you agree to these Terms.</p>
|
||||
<h2>1. License</h2><p>NetDesk's source code is provided under its open-source license. You may self-host, modify and
|
||||
use it, including commercially, subject to that license. The hosted Pro and Business services are provided under these Terms.</p>
|
||||
<h2>2. Acceptable use</h2><p>You may only use NetDesk to access computers you own or are explicitly authorized to
|
||||
administer. You must not use NetDesk to gain unauthorized access to any system, to distribute malware, or for any
|
||||
unlawful purpose. You are responsible for complying with all applicable laws in your jurisdiction.</p>
|
||||
<h2>3. Accounts</h2><p>You are responsible for safeguarding your account credentials, host passwords and 2FA secrets.
|
||||
Notify us of any unauthorized use.</p>
|
||||
<h2>4. Free tier & limits</h2><p>The free Individual tier is provided as-is and may include session time limits and
|
||||
device limits. We may adjust free-tier limits with reasonable notice.</p>
|
||||
<h2>5. Payment</h2><p>Paid plans are billed in advance and renew automatically until cancelled. Fees are non-refundable
|
||||
except as stated in our <a href="/refund">Refund Policy</a>.</p>
|
||||
<h2>6. Disclaimer</h2><p>THE SOFTWARE AND SERVICE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND. To the maximum extent
|
||||
permitted by law, NetDesk disclaims all warranties and is not liable for any indirect, incidental or consequential damages.</p>
|
||||
<h2>7. Changes</h2><p>We may update these Terms; material changes will be posted here with a new date.</p>
|
||||
<h2>8. Contact</h2><p>Questions? <a href="/contact">Contact us</a> or email hello@netdesk.space.</p>
|
||||
</div></div></section>
|
||||
<div id="siteFooter"></div>
|
||||
<script src="site.js?v=3"></script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user