[object Object]

← back to Dave Share

dave-share: read-only tailnet drop-folder + launchd keepalive + tailscale serve:8444

cb8038dfb0438413a2f43e8fc054dd14101e37d8 · 2026-06-29 09:56:24 -0700 · Steve

Files touched

Diff

commit cb8038dfb0438413a2f43e8fc054dd14101e37d8
Author: Steve <steve@designerwallcoverings.com>
Date:   Mon Jun 29 09:56:24 2026 -0700

    dave-share: read-only tailnet drop-folder + launchd keepalive + tailscale serve:8444
---
 .gitignore          |   8 +++
 README.md           |  53 +++++++++++++++
 server.js           | 185 ++++++++++++++++++++++++++++++++++++++++++++++++++++
 shared/welcome.html |  26 ++++++++
 4 files changed, 272 insertions(+)

diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..1924158
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,8 @@
+node_modules/
+.env*
+tmp/
+*.log
+.DS_Store
+dist/
+build/
+.next/
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3c20a1d
--- /dev/null
+++ b/README.md
@@ -0,0 +1,53 @@
+# dave-share
+
+A read-only **drop-folder web server** for sharing pages and files with Dave across
+**separate Tailscale tailnets** — without merging the two networks.
+
+## The model (how this stays private)
+
+- Steve's tailnet (`designerwallcoverings.com`) and Dave's tailnet stay fully separate.
+- We expose **one machine** (this Mac Studio) and **one URL** to Dave via Tailscale's
+  **node-sharing** feature. Dave sees only this node — not LeBron, Kobe, the phone, etc.
+- That URL is published with **`tailscale serve`** (tailnet-only HTTPS, real cert, NOT
+  `funnel` — so it's never on the public internet).
+- This server is the only thing behind that URL. It serves the `shared/` folder, read-only.
+
+## What's where
+
+| Thing | Value |
+|---|---|
+| Local server | `http://127.0.0.1:9930` (loopback only) |
+| Tailnet URL | `https://stevestudio2s-mac-studio.tail79cb8e.ts.net:8444` |
+| Share folder | `shared/` — drop files / `.html` pages here |
+| Keep-alive | launchd `com.steve.dave-share` (RunAtLoad + KeepAlive) |
+
+## Sharing something
+
+Just drop it into `shared/`:
+
+```sh
+cp ~/Desktop/some-page.html ~/Projects/dave-share/shared/
+```
+
+It appears in the index immediately. A folder with an `index.html` renders that page;
+otherwise you get an auto directory listing.
+
+## Security notes
+
+- **GET/HEAD only.** No writes, no exec, no dynamic routes. Path traversal is blocked.
+- `tailscale serve` proxies from `127.0.0.1`, so requests look local. That's exactly why
+  this server trusts *nothing* about localhost — the only access control is the node-share
+  (only Dave's node can resolve/reach the URL).
+- Tailscale injects `Tailscale-User-Login` for shared-in peers; the page shows it so you
+  can see who's connected. It is informational, not a gate.
+
+## The one manual step (yours to do — it grants an external person access)
+
+Generate the share invite for Dave in the Tailscale admin console:
+
+1. https://login.tailscale.com/admin/machines
+2. `stevestudio2s-mac-studio` → ⋯ → **Share…**
+3. Send Dave the invite link. He accepts with his own Tailscale account.
+4. Dave opens: `https://stevestudio2s-mac-studio.tail79cb8e.ts.net:8444`
+
+To stop sharing later: same menu → **un-share**, or `tailscale serve --https=8444 off`.
diff --git a/server.js b/server.js
new file mode 100644
index 0000000..659699b
--- /dev/null
+++ b/server.js
@@ -0,0 +1,185 @@
+#!/usr/bin/env node
+/**
+ * dave-share — a read-only "drop folder" web server meant to be exposed to Dave
+ * (a peer on a *separate* Tailscale tailnet) via `tailscale serve` + node-sharing.
+ *
+ * Security posture (deliberate):
+ *   - GET/HEAD only. No writes, no exec, no eval, nothing dynamic but a dir index.
+ *   - Hard-locked to ROOT (./shared). Path-traversal is resolved + bounds-checked.
+ *   - Because `tailscale serve` proxies from 127.0.0.1, every request looks local —
+ *     so this server intentionally has NOTHING that trusts localhost. The only thing
+ *     reachable is the contents of ./shared, which Steve curates by hand.
+ *   - Tailscale injects identity headers (Tailscale-User-Login) for shared-in peers;
+ *     we surface them so you can see who's connected. We do NOT gate on them — the
+ *     node-share itself is the access control (only Dave's node can reach this URL).
+ */
+'use strict';
+
+const http = require('http');
+const fs = require('fs');
+const path = require('path');
+const url = require('url');
+
+const PORT = process.env.DAVE_SHARE_PORT ? Number(process.env.DAVE_SHARE_PORT) : 9930;
+const HOST = '127.0.0.1'; // only ever bound to loopback; Tailscale serve fronts it
+const ROOT = path.join(__dirname, 'shared');
+
+const MIME = {
+  '.html': 'text/html; charset=utf-8', '.htm': 'text/html; charset=utf-8',
+  '.css': 'text/css; charset=utf-8', '.js': 'text/javascript; charset=utf-8',
+  '.json': 'application/json; charset=utf-8', '.txt': 'text/plain; charset=utf-8',
+  '.md': 'text/plain; charset=utf-8', '.png': 'image/png', '.jpg': 'image/jpeg',
+  '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml',
+  '.webp': 'image/webp', '.pdf': 'application/pdf', '.mp4': 'video/mp4',
+  '.mov': 'video/quicktime', '.zip': 'application/zip', '.csv': 'text/csv; charset=utf-8',
+  '.ico': 'image/x-icon',
+};
+
+const esc = (s) => String(s).replace(/[&<>"']/g, (c) =>
+  ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
+
+function humanSize(n) {
+  if (n < 1024) return n + ' B';
+  const u = ['KB', 'MB', 'GB', 'TB'];
+  let i = -1;
+  do { n /= 1024; i++; } while (n >= 1024 && i < u.length - 1);
+  return n.toFixed(1) + ' ' + u[i];
+}
+
+function who(req) {
+  // Headers injected by `tailscale serve` for identified (incl. shared-in) peers.
+  const login = req.headers['tailscale-user-login'];
+  const name = req.headers['tailscale-user-name'];
+  if (login) return esc(name ? `${name} (${login})` : login);
+  return null;
+}
+
+function page(title, body, viewer) {
+  const whoBadge = viewer
+    ? `<span class="who">🔑 ${viewer}</span>`
+    : `<span class="who who--anon">on the tailnet</span>`;
+  return `<!doctype html><html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>${esc(title)}</title>
+<style>
+  :root{--ink:#1a1a1a;--muted:#6b6b6b;--line:#e6e3dd;--bg:#faf8f4;--accent:#7a5c3e}
+  *{box-sizing:border-box}
+  body{margin:0;font:16px/1.55 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:var(--bg)}
+  header{padding:28px 32px 18px;border-bottom:1px solid var(--line);display:flex;align-items:baseline;justify-content:space-between;flex-wrap:wrap;gap:8px}
+  h1{margin:0;font-size:22px;letter-spacing:.02em;font-weight:600}
+  .sub{color:var(--muted);font-size:13px;margin-top:4px}
+  .who{font-size:12px;color:var(--accent);border:1px solid var(--line);border-radius:999px;padding:4px 12px;background:#fff}
+  .who--anon{color:var(--muted)}
+  main{padding:22px 32px 60px;max-width:880px}
+  ul.list{list-style:none;margin:0;padding:0;border:1px solid var(--line);border-radius:10px;overflow:hidden;background:#fff}
+  ul.list li{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:12px 18px;border-bottom:1px solid var(--line)}
+  ul.list li:last-child{border-bottom:none}
+  ul.list a{text-decoration:none;color:var(--ink);font-weight:500}
+  ul.list a:hover{color:var(--accent)}
+  .meta{color:var(--muted);font-size:12px;white-space:nowrap}
+  .empty{color:var(--muted);padding:40px;text-align:center;border:1px dashed var(--line);border-radius:10px;background:#fff}
+  .ico{display:inline-block;width:22px}
+  footer{color:var(--muted);font-size:12px;padding:0 32px 40px;max-width:880px}
+  code{background:#fff;border:1px solid var(--line);border-radius:5px;padding:1px 6px;font-size:13px}
+  a.crumb{color:var(--accent);text-decoration:none}
+</style></head><body>
+<header>
+  <div><h1>${esc(title)}</h1><div class="sub">Shared privately over Tailscale · read-only</div></div>
+  ${whoBadge}
+</header>
+<main>${body}</main>
+<footer>This page lives only inside Steve's tailnet and the nodes shared into it. Drop files into the
+<code>shared/</code> folder to publish them here.</footer>
+</body></html>`;
+}
+
+function iconFor(name, isDir) {
+  if (isDir) return '📁';
+  const e = path.extname(name).toLowerCase();
+  if (['.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp'].includes(e)) return '🖼️';
+  if (['.mp4', '.mov'].includes(e)) return '🎬';
+  if (e === '.pdf') return '📄';
+  if (['.html', '.htm'].includes(e)) return '🌐';
+  if (e === '.zip') return '🗜️';
+  return '📃';
+}
+
+function listDir(req, res, fsPath, urlPath) {
+  let entries;
+  try { entries = fs.readdirSync(fsPath, { withFileTypes: true }); }
+  catch { res.writeHead(500); return res.end('cannot read directory'); }
+
+  entries = entries
+    .filter((e) => !e.name.startsWith('.'))
+    .sort((a, b) => (b.isDirectory() - a.isDirectory()) || a.name.localeCompare(b.name));
+
+  const crumbs = urlPath.split('/').filter(Boolean);
+  let trail = '<a class="crumb" href="/">shared</a>';
+  let acc = '';
+  for (const c of crumbs) { acc += '/' + c; trail += ` / <a class="crumb" href="${esc(acc)}/">${esc(c)}</a>`; }
+
+  let body = `<p class="sub" style="margin-top:0">${trail}</p>`;
+  if (!entries.length) {
+    body += `<div class="empty">Nothing shared yet.<br>Steve: drop files or <code>.html</code> pages into
+      <code>~/Projects/dave-share/shared/</code> and they appear here.</div>`;
+  } else {
+    body += '<ul class="list">';
+    for (const e of entries) {
+      const href = encodeURIComponent(e.name) + (e.isDirectory() ? '/' : '');
+      let meta = '';
+      try {
+        const st = fs.statSync(path.join(fsPath, e.name));
+        meta = e.isDirectory() ? 'folder' : humanSize(st.size);
+      } catch {}
+      body += `<li><span><span class="ico">${iconFor(e.name, e.isDirectory())}</span>
+        <a href="${esc(href)}">${esc(e.name)}</a></span><span class="meta">${esc(meta)}</span></li>`;
+    }
+    body += '</ul>';
+  }
+  res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
+  res.end(page('Shared with you', body, who(req)));
+}
+
+const server = http.createServer((req, res) => {
+  if (req.method !== 'GET' && req.method !== 'HEAD') {
+    res.writeHead(405, { Allow: 'GET, HEAD' });
+    return res.end('read-only');
+  }
+  let pathname;
+  try { pathname = decodeURIComponent(url.parse(req.url).pathname || '/'); }
+  catch { res.writeHead(400); return res.end('bad request'); }
+
+  // Resolve and bounds-check against ROOT (block path traversal).
+  const resolved = path.resolve(ROOT, '.' + pathname);
+  if (resolved !== ROOT && !resolved.startsWith(ROOT + path.sep)) {
+    res.writeHead(403); return res.end('forbidden');
+  }
+
+  let st;
+  try { st = fs.statSync(resolved); }
+  catch { res.writeHead(404, { 'Content-Type': 'text/html; charset=utf-8' });
+    return res.end(page('Not found', '<div class="empty">That file isn\'t here.</div>', who(req))); }
+
+  if (st.isDirectory()) {
+    const indexFile = path.join(resolved, 'index.html');
+    if (fs.existsSync(indexFile)) { st = fs.statSync(indexFile); return sendFile(req, res, indexFile, st); }
+    return listDir(req, res, resolved, pathname);
+  }
+  return sendFile(req, res, resolved, st);
+});
+
+function sendFile(req, res, file, st) {
+  const type = MIME[path.extname(file).toLowerCase()] || 'application/octet-stream';
+  res.writeHead(200, {
+    'Content-Type': type,
+    'Content-Length': st.size,
+    'Cache-Control': 'no-cache',
+    'X-Content-Type-Options': 'nosniff',
+  });
+  if (req.method === 'HEAD') return res.end();
+  fs.createReadStream(file).pipe(res);
+}
+
+server.listen(PORT, HOST, () => {
+  console.log(`[dave-share] read-only on http://${HOST}:${PORT}  root=${ROOT}`);
+});
diff --git a/shared/welcome.html b/shared/welcome.html
new file mode 100644
index 0000000..0cff2c8
--- /dev/null
+++ b/shared/welcome.html
@@ -0,0 +1,26 @@
+<!doctype html>
+<html lang="en"><head><meta charset="utf-8">
+<meta name="viewport" content="width=device-width,initial-scale=1">
+<title>Welcome, Dave</title>
+<style>
+  body{margin:0;min-height:100vh;display:grid;place-items:center;
+    font:18px/1.6 -apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;
+    color:#1a1a1a;background:#faf8f4}
+  .card{max-width:560px;padding:40px;background:#fff;border:1px solid #e6e3dd;border-radius:14px;margin:24px}
+  h1{margin:0 0 6px;font-size:26px}
+  .sub{color:#7a5c3e;font-size:14px;margin-bottom:18px}
+  p{color:#444}
+  code{background:#faf8f4;border:1px solid #e6e3dd;border-radius:5px;padding:1px 6px;font-size:14px}
+</style></head><body>
+<div class="card">
+  <h1>Hey Dave 👋</h1>
+  <div class="sub">You're reaching this over Steve's Tailscale — your tailnet and his stay separate.</div>
+  <p>This is the shared space. Steve drops pages and files in here for you, and you can read them
+  from any of your devices that have accepted the share — phone, laptop, anywhere.</p>
+  <p>Nothing here is on the public internet. The connection is end-to-end encrypted between your
+  device and Steve's Mac Studio, and only this one machine is shared with you — not the rest of
+  either network.</p>
+  <p style="margin-bottom:0;color:#6b6b6b;font-size:14px">Go back to the <a href="/">index</a> to see
+  everything that's shared.</p>
+</div>
+</body></html>

(oldest)  ·  back to Dave Share  ·  auto-save: 2026-06-29T10:18:23 (1 files) — shared/test-for-d c458ac7 →