← 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
A .gitignoreA README.mdA server.jsA shared/welcome.html
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) =>
+ ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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 →