cloudflare-tunnel-kit 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,9 +2,11 @@
2
2
 
3
3
  An open-source toolkit for creating and integrating Cloudflare Tunnels through two simple interfaces: a command-line wizard and a local live UI. It replaces scattered shell scripts and Makefile targets with a validated, reviewable, and confirmation-based workflow.
4
4
 
5
+ ![Demo](images/demo.png)
6
+
5
7
  ## Current version
6
8
 
7
- `0.1.0` is the current MVP and includes:
9
+ `0.1.6` is the current MVP and includes:
8
10
 
9
11
  - A reusable TypeScript API for validation, plan generation, execution, and redaction.
10
12
  - The `cf-tunnel` CLI with `init`, `create`, `quick`, `start`, `stop`, `status`, `doctor`, and `ui` commands.
@@ -41,6 +43,21 @@ Install the package in the project that needs a tunnel:
41
43
  npm install --save-dev cloudflare-tunnel-kit
42
44
  ```
43
45
 
46
+ For a machine-wide command available from any directory, install it globally:
47
+
48
+ ```bash
49
+ npm install --global cloudflare-tunnel-kit
50
+ ```
51
+
52
+ Then use either command name:
53
+
54
+ ```bash
55
+ cf-tunnel doctor
56
+ cftunnel ui
57
+ ```
58
+
59
+ Both commands use the same local application data directory and can be run from any project folder. The global install provides the CLI; `cloudflared` is still a separate prerequisite.
60
+
44
61
  The package exposes the `cf-tunnel` binary locally. Run it through `npx` so no global installation is required:
45
62
 
46
63
  ```bash
@@ -1,5 +1,5 @@
1
1
  import crypto from 'node:crypto';
2
- import { existsSync } from 'node:fs';
2
+ import { existsSync, realpathSync, statSync } from 'node:fs';
3
3
  import { resolveAppPaths } from './paths.js';
4
4
  import { openStateDatabase } from '../persistence/database.js';
5
5
  import { StateStore } from '../persistence/store.js';
@@ -71,8 +71,23 @@ export class TunnelKitService {
71
71
  await this.stop(projectId);
72
72
  }
73
73
  catch { } return this.start(projectId); }
74
- async relinkProject(projectId, nextPath) { if (!nextPath?.trim())
75
- throw new Error('A new project folder is required.'); this.store.relinkProject(projectId, nextPath); }
74
+ async relinkProject(projectId, nextPath) {
75
+ if (!nextPath?.trim())
76
+ throw new Error('A new project folder is required.');
77
+ let resolved;
78
+ try {
79
+ resolved = realpathSync(nextPath);
80
+ }
81
+ catch {
82
+ throw new Error('The new project path must be an existing project directory.');
83
+ }
84
+ if (!statSync(resolved).isDirectory())
85
+ throw new Error('The new project path must be an existing project directory.');
86
+ const duplicate = this.store.listProjects().find((project) => project.path === resolved && project.id !== projectId);
87
+ if (duplicate)
88
+ throw new Error('Another local project already uses this folder.');
89
+ this.store.relinkProject(projectId, resolved);
90
+ }
76
91
  async removeLocal(projectId) {
77
92
  let session;
78
93
  try {
package/dist/cli/main.js CHANGED
@@ -14,8 +14,9 @@ function interactiveRequired() { console.error('[INTERACTIVE_INPUT_REQUIRED] Thi
14
14
  async function startUi() {
15
15
  console.log('Starting Cloudflare Tunnel Kit UI on this machine...');
16
16
  const service = createTunnelKitService({ dataDir: process.env.CLOUDFLARE_TUNNEL_KIT_DATA_DIR });
17
- const server = createServer({ service });
17
+ const server = createServer({ service, version: packageVersion });
18
18
  server.once('error', error => { console.error(`Unable to start the local UI: ${error.message}`); console.error('Run `npx cf-tunnel ui` again after checking local server permissions.'); process.exitCode = 1; service.close(); });
19
+ console.log(`Cloudflare Tunnel Kit v${packageVersion}`);
19
20
  server.listen(0, '127.0.0.1', async () => { const address = server.address(); if (!address || typeof address === 'string')
20
21
  return; const url = `http://127.0.0.1:${address.port}`; console.log(`UI ready at ${url}`); if (!args.includes('--no-open'))
21
22
  console.log((await launchBrowser(url)).message); });
@@ -90,6 +90,11 @@ export class NamedTunnelWorkflow {
90
90
  }
91
91
  if (!tunnel.uuid || !tunnel.credentialsPath || !tunnel.configPath)
92
92
  throw new Error('Saved tunnel identity is incomplete.');
93
+ if (tunnel.hostname !== input.hostname || tunnel.localUrl !== input.localUrl || tunnel.name !== input.tunnelName) {
94
+ tunnel = this.store.saveTunnel({ ...tunnel, hostname: input.hostname, localUrl: input.localUrl, name: input.tunnelName });
95
+ if (!tunnel.uuid || !tunnel.credentialsPath || !tunnel.configPath)
96
+ throw new Error('Saved tunnel identity is incomplete.');
97
+ }
93
98
  try {
94
99
  const credentialStat = await stat(tunnel.credentialsPath);
95
100
  if (!credentialStat.isFile())
@@ -2,7 +2,11 @@ const secretKey = /(token|secret|password|private|credential|key|cert)/i;
2
2
  export function redact(value, key) {
3
3
  if (key && secretKey.test(key))
4
4
  return '[REDACTED]';
5
- return value.replace(/(Bearer\s+)[^\s]+/gi, '$1[REDACTED]').replace(/([A-Za-z0-9_-]{24,})/g, '[REDACTED]');
5
+ return value
6
+ .replace(/-----BEGIN [^-]+-----[\s\S]*?-----END [^-]+-----/gi, '[REDACTED PEM]')
7
+ .replace(/(Bearer\s+)[^\s]+/gi, '$1[REDACTED]')
8
+ .replace(/([?&](?:token|secret|password|api[_-]?key|signature|credential)=)[^&\s]+/gi, '$1[REDACTED]')
9
+ .replace(/([A-Za-z0-9_-]{24,})/g, '[REDACTED]');
6
10
  }
7
11
  export function redactValue(value, key) {
8
12
  if (key && secretKey.test(key))
@@ -33,6 +33,8 @@ export interface TunnelConfig {
33
33
  tunnelName?: string;
34
34
  hostname?: string;
35
35
  configPath?: string;
36
+ allowPublicOrigin?: boolean;
37
+ allowPrivateNetwork?: boolean;
36
38
  laravel?: {
37
39
  mapAppUrl?: boolean;
38
40
  mapAssetUrl?: boolean;
@@ -2,6 +2,17 @@ import path from 'node:path';
2
2
  import { realpathSync } from 'node:fs';
3
3
  import { domainToASCII } from 'node:url';
4
4
  function issue(code, reason, fix, field) { return { code, reason, fix, field }; }
5
+ function safeOrigin(url, input) {
6
+ if (url.username || url.password)
7
+ return issue('INPUT_UNSAFE_ORIGIN', 'localUrl must not contain username or password information.', 'Remove credentials from the URL and configure authentication in the local application.', 'localUrl');
8
+ const hostname = url.hostname.toLowerCase();
9
+ const loopback = hostname === 'localhost' || hostname === '::1' || hostname === '127.0.0.1' || hostname.startsWith('127.');
10
+ if (loopback)
11
+ return undefined;
12
+ if (input.allowPrivateNetwork || input.allowPublicOrigin)
13
+ return undefined;
14
+ return issue('INPUT_UNSAFE_ORIGIN', 'Only loopback origins are allowed by default.', 'Use localhost or 127.0.0.1, or explicitly enable the reviewed advanced network option.', 'localUrl');
15
+ }
5
16
  function inside(root, candidate) {
6
17
  try {
7
18
  const r = realpathSync(root);
@@ -18,7 +29,10 @@ export function validateTunnelConfig(input) {
18
29
  try {
19
30
  url = new URL(input.localUrl);
20
31
  if (!['http:', 'https:'].includes(url.protocol))
21
- throw new Error();
32
+ throw new Error('invalid protocol');
33
+ const unsafe = safeOrigin(url, input);
34
+ if (unsafe)
35
+ issues.push(unsafe);
22
36
  }
23
37
  catch {
24
38
  issues.push(issue('INPUT_INVALID_URL', 'localUrl must be an http or https URL.', 'Use a value such as http://127.0.0.1:8000.', 'localUrl'));
@@ -72,6 +72,15 @@ export class ProcessSupervisor {
72
72
  });
73
73
  if (stopped)
74
74
  session.state = 'stopped';
75
- return { stopped, forceRequired: !stopped };
75
+ if (stopped)
76
+ return { stopped: true, forceRequired: false };
77
+ session.child.kill('SIGKILL');
78
+ const forceStopped = await new Promise(resolve => {
79
+ const timer = setTimeout(() => resolve(false), graceMs);
80
+ session.child.once('close', () => { clearTimeout(timer); resolve(true); });
81
+ });
82
+ if (forceStopped)
83
+ session.state = 'stopped';
84
+ return { stopped: forceStopped, forceRequired: true };
76
85
  }
77
86
  }
package/dist/ui/page.d.ts CHANGED
@@ -1 +1 @@
1
- export declare function dashboardPage(): string;
1
+ export declare function dashboardPage(version?: string): string;
package/dist/ui/page.js CHANGED
@@ -1,14 +1,14 @@
1
- export function dashboardPage() {
1
+ export function dashboardPage(version = '0.1.5') {
2
2
  return String.raw `<!doctype html>
3
3
  <html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
4
- <title>Cloudflare Tunnel Kit</title>
4
+ <title>Cloudflare Tunnel Kit v${escapeAttribute(version)}</title>
5
5
  <style>
6
6
  :root{color-scheme:light;--ink:#172033;--muted:#68738a;--line:#e4e8f0;--panel:#fff;--bg:#f4f6fa;--blue:#1665d8;--blue2:#0c4fad;--orange:#f48120;--green:#11845b;--red:#c93c4b;--shadow:0 18px 50px rgba(26,39,70,.10)}
7
7
  *{box-sizing:border-box}body{margin:0;font:15px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color:var(--ink);background:var(--bg)}button,input,select{font:inherit}button{cursor:pointer}.shell{min-height:100vh}.topbar{height:68px;background:#101827;color:white;display:flex;align-items:center;justify-content:space-between;padding:0 clamp(20px,5vw,72px);box-shadow:0 1px 0 #ffffff18}.brand{display:flex;align-items:center;gap:12px;font-weight:760;letter-spacing:-.02em}.logo{width:34px;height:34px;border-radius:11px;background:linear-gradient(145deg,#ffad38,var(--orange));display:grid;place-items:center;color:white;font-size:19px;box-shadow:0 8px 24px #f4812050}.top-actions{display:flex;gap:10px}.main{max-width:1180px;margin:0 auto;padding:42px 28px 70px}.hero{display:grid;grid-template-columns:1.4fr .8fr;gap:24px;align-items:stretch}.hero-copy,.health-panel,.section{background:var(--panel);border:1px solid var(--line);border-radius:22px;box-shadow:var(--shadow)}.hero-copy{padding:38px;background:radial-gradient(circle at 100% 0,#eaf3ff 0,transparent 42%),#fff}.eyebrow{font-size:12px;font-weight:800;letter-spacing:.12em;text-transform:uppercase;color:var(--blue)}h1{font-size:clamp(30px,4vw,48px);line-height:1.05;letter-spacing:-.045em;margin:10px 0 14px}.lead{font-size:17px;color:var(--muted);max-width:650px;margin:0 0 26px}.actions{display:flex;flex-wrap:wrap;gap:12px}.btn{border:0;border-radius:11px;padding:11px 16px;font-weight:700;display:inline-flex;align-items:center;justify-content:center;gap:8px}.btn.primary{background:var(--blue);color:white;box-shadow:0 8px 18px #1665d835}.btn.primary:hover{background:var(--blue2)}.btn.secondary{background:#edf3fc;color:#174b91}.btn.ghost{background:white;color:var(--ink);border:1px solid var(--line)}.btn.danger{background:#fff0f1;color:var(--red)}.health-panel{padding:28px}.health-title{display:flex;justify-content:space-between;align-items:center;margin-bottom:20px}.health-title h2,.section-head h2{margin:0;font-size:19px;letter-spacing:-.02em}.check{display:flex;gap:12px;padding:12px 0;border-top:1px solid var(--line)}.check:first-of-type{border:0}.dot{width:10px;height:10px;border-radius:50%;margin-top:6px;background:#aab3c3}.dot.ok{background:var(--green);box-shadow:0 0 0 4px #dff5ed}.check b{display:block}.check span{color:var(--muted);font-size:13px}.section{margin-top:24px;padding:28px}.section-head{display:flex;justify-content:space-between;align-items:center;gap:18px;margin-bottom:20px}.section-head p{margin:4px 0 0;color:var(--muted)}.projects{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:16px}.empty{grid-column:1/-1;border:1px dashed #cfd6e3;border-radius:16px;padding:42px;text-align:center;color:var(--muted);background:#fafbfc}.card{border:1px solid var(--line);border-radius:16px;padding:20px;background:#fff;min-width:0}.card-top{display:flex;justify-content:space-between;gap:12px}.card h3{margin:0;font-size:17px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.badge{font-size:12px;font-weight:750;padding:4px 9px;border-radius:999px;background:#eef1f6;color:#536078;white-space:nowrap}.badge.running{background:#dcf6eb;color:#08734d}.badge.attention{background:#fff0d9;color:#9a5b00}.meta{margin:16px 0;color:var(--muted);font-size:13px;display:grid;gap:6px}.meta code{overflow:hidden;text-overflow:ellipsis}.card-actions{display:flex;gap:8px}.card-actions .btn{padding:8px 11px;font-size:13px}.notice{padding:12px 14px;border-radius:11px;background:#fff6e9;color:#854e00;margin:14px 0;font-size:13px}.hidden{display:none!important}
8
8
  dialog{border:0;border-radius:22px;padding:0;width:min(680px,calc(100vw - 30px));box-shadow:0 30px 100px #11182755}dialog::backdrop{background:#10182799;backdrop-filter:blur(3px)}.dialog-head{padding:24px 28px 18px;border-bottom:1px solid var(--line);display:flex;justify-content:space-between;align-items:flex-start}.dialog-head h2{margin:0;font-size:22px}.dialog-head p{margin:5px 0 0;color:var(--muted)}.close{border:0;background:#f0f2f6;border-radius:9px;width:34px;height:34px}.dialog-body{padding:25px 28px}.steps{display:flex;gap:8px;margin-bottom:24px}.step{height:5px;flex:1;border-radius:4px;background:#e3e7ee}.step.active{background:var(--blue)}label{display:block;font-weight:700;margin:15px 0 6px}.hint{font-size:12px;color:var(--muted);margin-top:5px}input,select{width:100%;padding:11px 12px;border:1px solid #cfd6e3;border-radius:10px;background:white;color:var(--ink);outline:none}input:focus,select:focus{border-color:var(--blue);box-shadow:0 0 0 3px #1665d81a}.grid2{display:grid;grid-template-columns:1fr 1fr;gap:14px}.dialog-foot{padding:18px 28px;border-top:1px solid var(--line);display:flex;justify-content:flex-end;gap:10px}.review{background:#f6f8fb;border-radius:13px;padding:16px}.review ul{margin:8px 0 0;padding-left:20px}.result{white-space:pre-wrap;background:#101827;color:#e7edf8;border-radius:13px;padding:16px;max-height:280px;overflow:auto;font:12px/1.6 ui-monospace,SFMono-Regular,Menlo,monospace}.toast{position:fixed;right:24px;bottom:24px;background:#101827;color:#fff;padding:13px 17px;border-radius:11px;box-shadow:var(--shadow);z-index:20}
9
9
  @media(max-width:850px){.hero{grid-template-columns:1fr}.projects{grid-template-columns:1fr 1fr}}@media(max-width:580px){.main{padding:24px 15px}.topbar{padding:0 16px}.top-actions .btn span{display:none}.hero-copy,.health-panel,.section{border-radius:16px}.hero-copy{padding:26px}.projects{grid-template-columns:1fr}.grid2{grid-template-columns:1fr}.dialog-head,.dialog-body,.dialog-foot{padding-left:19px;padding-right:19px}.actions>.btn{width:100%}}
10
10
  </style></head><body><div class="shell">
11
- <header class="topbar"><div class="brand"><div class="logo">☁</div><span>Cloudflare Tunnel Kit</span></div><div class="top-actions"><button class="btn ghost" data-action="doctor">System check</button></div></header>
11
+ <header class="topbar"><div class="brand"><div class="logo">☁</div><span>Cloudflare Tunnel Kit <small>v${escapeAttribute(version)}</small></span></div><div class="top-actions"><button class="btn ghost" data-action="doctor">System check</button></div></header>
12
12
  <main class="main"><section class="hero"><div class="hero-copy"><div class="eyebrow">Local tunnel manager</div><h1>Your tunnels,<br>without the guesswork.</h1><p class="lead">Expose a local project through a temporary URL or connect your own Cloudflare domain. Every change is reviewed before it runs.</p><div class="actions"><button class="btn primary" data-new="quick">⚡ Create Quick Tunnel</button><button class="btn secondary" data-new="named">◎ Use a custom domain</button></div></div><aside class="health-panel"><div class="health-title"><h2>Environment</h2><button class="btn ghost" data-action="doctor">Run checks</button></div><div id="checks"><div class="check"><i class="dot"></i><div><b>Not checked yet</b><span>Run system checks before your first tunnel.</span></div></div></div></aside></section>
13
13
  <section class="section"><div class="section-head"><div><h2>Saved projects</h2><p>Settings and history stored only on this machine.</p></div><button class="btn ghost" data-action="refresh">↻ Refresh</button></div><div class="projects" id="projects"><div class="empty">Loading your projects…</div></div></section></main></div>
14
14
  <dialog id="wizard"><div class="dialog-head"><div><h2 id="wizard-title">Create a tunnel</h2><p id="wizard-subtitle">Step-by-step setup</p></div><button class="close" data-close aria-label="Close">×</button></div><div class="dialog-body"><div class="steps"><i class="step active"></i><i class="step"></i><i class="step"></i></div>
@@ -21,13 +21,13 @@ const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&
21
21
  async function api(url,options={}){const headers={'content-type':'application/json',...(options.headers||{})};if(token)headers['x-confirmation-token']=token;const r=await fetch(url,{...options,headers});const body=await r.json().catch(()=>({error:'Invalid server response'}));if(!r.ok)throw new Error(body.error||body.issues?.[0]?.reason||'Request failed');return body}
22
22
  function toast(message){const el=q('#toast');el.textContent=message;el.classList.remove('hidden');setTimeout(()=>el.classList.add('hidden'),3200)}
23
23
  async function load(){try{const data=await api('/api/projects');renderProjects(data.projects)}catch(e){q('#projects').innerHTML='<div class="empty">'+esc(e.message)+'</div>'}}
24
- function renderProjects(items){q('#projects').innerHTML=items.length?items.map(p=>'<article class="card"><div class="card-top"><h3>'+esc(p.displayName)+'</h3><span class="badge '+(p.status==='Running'?'running':p.status==='Needs attention'?'attention':'')+'">'+esc(p.status)+'</span></div><div class="meta"><span>'+esc(p.kind==='named'?p.hostname||'Custom domain':'Quick Tunnel')+'</span><code>'+esc(p.localUrl||p.path)+'</code></div><div class="card-actions">'+(p.status==='Running'?'<button class="btn ghost" data-project="'+p.id+'" data-command="stop">Stop</button>':'<button class="btn secondary" data-project="'+p.id+'" data-command="start">Start</button>')+'<button class="btn ghost" data-project="'+p.id+'" data-command="retry">Retry setup</button></div></article>').join(''):'<div class="empty"><b>No saved projects yet.</b><br>Create a Quick Tunnel or connect a custom domain to get started.</div>'}
24
+ function renderProjects(items){q('#projects').innerHTML=items.length?items.map(p=>'<article class="card"><div class="card-top"><h3>'+esc(p.displayName)+'</h3><span class="badge '+(p.status==='Running'?'running':p.status==='Needs attention'?'attention':'')+'">'+esc(p.status)+'</span></div><div class="meta"><span>'+esc(p.kind==='named'?p.hostname||'Custom domain':'Quick Tunnel')+'</span><code>'+esc(p.localUrl||p.path)+'</code></div><div class="card-actions">'+(p.status==='Running'?'<button class="btn ghost" data-project="'+p.id+'" data-command="stop">Stop</button>':'<button class="btn secondary" data-project="'+p.id+'" data-command="start">Start</button>')+'<button class="btn ghost" data-project="'+p.id+'" data-command="retry">Retry setup</button><button class="btn danger" data-project="'+p.id+'" data-command="remove-local">Remove local</button></div></article>').join(''):'<div class="empty"><b>No saved projects yet.</b><br>Create a Quick Tunnel or connect a custom domain to get started.</div>'}
25
25
  function openWizard(nextMode){mode=nextMode;stage=0;plan=null;q('#setup-form').reset();q('[name=projectPath]').value=${JSON.stringify(process.cwd())};q('[name=localUrl]').value='http://127.0.0.1:8000';q('#named-fields').classList.toggle('hidden',mode!=='named');q('#named-notice').classList.toggle('hidden',mode!=='named');q('#wizard-title').textContent=mode==='named'?'Set up a custom domain':'Create a Quick Tunnel';q('#wizard-subtitle').textContent=mode==='named'?'Cloudflare login, tunnel, DNS, and connector':'No Cloudflare login required';showStage();q('#wizard').showModal()}
26
26
  function showStage(){q('#fields').classList.toggle('hidden',stage!==0);q('#review').classList.toggle('hidden',stage!==1);q('#running').classList.toggle('hidden',stage!==2);qa('.step').forEach((el,i)=>el.classList.toggle('active',i<=stage));q('#continue').textContent=stage===0?'Review changes':stage===1?'Confirm and run':'Done'}
27
27
  function values(){const data=Object.fromEntries(new FormData(q('#setup-form')));return {...data,profile:data.profile||'custom'}}
28
28
  async function next(){try{if(stage===0){const data=values();if(mode==='named'&&(!data.tunnelName||!data.hostname))throw new Error('Tunnel name and public hostname are required.');plan=await api('/api/plans/'+mode,{method:'POST',body:JSON.stringify(data)});q('#effects').innerHTML=plan.effects.map(x=>'<li>'+esc(x)+'</li>').join('');stage=1;showStage()}else if(stage===1){stage=2;showStage();q('#result').textContent='Running validated workflow…';const result=await api('/api/execute',{method:'POST',body:JSON.stringify({planId:plan.id,confirmations:plan.confirmations})});q('#result').textContent=JSON.stringify(result,null,2);await load()}else q('#wizard').close()}catch(e){toast(e.message)}}
29
29
  async function doctor(){try{const data=await api('/api/doctor');q('#checks').innerHTML=data.checks.map(c=>'<div class="check"><i class="dot '+(c.state==='passed'?'ok':'')+'"></i><div><b>'+esc(c.name)+'</b><span>'+esc(c.detail)+'</span></div></div>').join('')}catch(e){toast(e.message)}}
30
- document.addEventListener('click',async e=>{const t=e.target.closest('button');if(!t)return;if(t.dataset.new)openWizard(t.dataset.new);if(t.dataset.close)q('#wizard').close();if(t.id==='continue'){e.preventDefault();await next()}if(t.dataset.action==='refresh')await load();if(t.dataset.action==='doctor')await doctor();if(t.dataset.project){try{await api('/api/projects/'+t.dataset.project+'/'+t.dataset.command,{method:'POST',body:'{}'});await load()}catch(err){toast(err.message)}}});
30
+ document.addEventListener('click',async e=>{const t=e.target.closest('button');if(!t)return;if(t.dataset.new)openWizard(t.dataset.new);if(t.dataset.close)q('#wizard').close();if(t.id==='continue'){e.preventDefault();await next()}if(t.dataset.action==='refresh')await load();if(t.dataset.action==='doctor')await doctor();if(t.dataset.project){if(t.dataset.command==='remove-local'&&!confirm('Remove this project from the local dashboard? Cloudflare tunnel and DNS resources will not be deleted.'))return;try{await api('/api/projects/'+t.dataset.project+'/'+t.dataset.command,{method:'POST',body:'{}'});await load()}catch(err){toast(err.message)}}});
31
31
  api('/api/session').then(x=>{token=x.confirmationToken;load();doctor()}).catch(e=>toast(e.message));
32
32
  </script></body></html>`;
33
33
  }
@@ -3,4 +3,5 @@ export declare function createServer(options?: {
3
3
  service?: any;
4
4
  sessionToken?: string;
5
5
  maxBodyBytes?: number;
6
+ version?: string;
6
7
  }): Server;
package/dist/ui/server.js CHANGED
@@ -15,7 +15,7 @@ export function createServer(options = {}) {
15
15
  secureHeaders(res);
16
16
  const url = new URL(req.url ?? '/', 'http://127.0.0.1');
17
17
  if (req.method === 'GET' && url.pathname === '/')
18
- return html(res, dashboardPage());
18
+ return html(res, dashboardPage(options.version));
19
19
  if (req.method === 'GET' && url.pathname === '/api/session')
20
20
  return json(res, { confirmationToken: token });
21
21
  if (req.method === 'GET' && url.pathname === '/api/projects')
@@ -37,6 +37,8 @@ export function createServer(options = {}) {
37
37
  catch (error) {
38
38
  return json(res, { error: error instanceof Error ? error.message : 'Invalid JSON request.' }, error?.code === 'BODY_TOO_LARGE' ? 413 : 400);
39
39
  }
40
+ if (!body || typeof body !== 'object' || Array.isArray(body) || containsCredentialField(body))
41
+ return json(res, { error: 'Requests must be objects and must not contain secret or credential fields.' }, 400);
40
42
  if (url.pathname === '/api/plans/quick')
41
43
  return handle(res, () => service.prepareQuick(body));
42
44
  if (url.pathname === '/api/plans/named')
@@ -46,6 +48,9 @@ export function createServer(options = {}) {
46
48
  const action = url.pathname.match(/^\/api\/projects\/([^/]+)\/(start|stop|retry|restart)$/);
47
49
  if (action)
48
50
  return handle(res, () => service[action[2]](action[1]));
51
+ const management = url.pathname.match(/^\/api\/projects\/([^/]+)\/(relink|remove-local)$/);
52
+ if (management)
53
+ return handle(res, () => management[2] === 'relink' ? service.relinkProject(management[1], body.path) : service.removeLocal(management[1]));
49
54
  if (url.pathname === '/api/plan') {
50
55
  const config = body.config ?? {};
51
56
  return handle(res, async () => ({ plan: config.operation === 'quick' ? await service.prepareQuick({ projectPath: config.projectRoot ?? process.cwd(), ...config }) : await service.prepareNamed({ projectPath: config.projectRoot ?? process.cwd(), ...config }) }));
@@ -54,12 +59,19 @@ export function createServer(options = {}) {
54
59
  return json(res, { error: 'Not found.' }, 404);
55
60
  });
56
61
  }
62
+ function containsCredentialField(value) {
63
+ if (!value || typeof value !== 'object')
64
+ return false;
65
+ if (Array.isArray(value))
66
+ return value.some(containsCredentialField);
67
+ return Object.entries(value).some(([key, nested]) => /(secret|token|password|credential|private[_-]?key|cert)/i.test(key) || containsCredentialField(nested));
68
+ }
57
69
  function validMutationRequest(req, token) {
58
70
  const received = String(req.headers['x-confirmation-token'] ?? '');
59
71
  if (received.length !== token.length || !crypto.timingSafeEqual(Buffer.from(received), Buffer.from(token)))
60
72
  return false;
61
73
  const host = String(req.headers.host ?? '');
62
- if (host && !/^(127\.0\.0\.1|localhost)(:\d+)?$/i.test(host))
74
+ if (!/^(127\.0\.0\.1|localhost)(:\d+)?$/i.test(host))
63
75
  return false;
64
76
  const origin = String(req.headers.origin ?? '');
65
77
  return !origin || /^http:\/\/(127\.0\.0\.1|localhost)(:\d+)?$/i.test(origin);
@@ -77,6 +89,6 @@ async function handle(res, operation) { try {
77
89
  catch (error) {
78
90
  return json(res, { error: error instanceof Error ? error.message : String(error) }, 400);
79
91
  } }
80
- function secureHeaders(res) { res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Content-Security-Policy', "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'"); res.setHeader('Referrer-Policy', 'no-referrer'); res.setHeader('Cache-Control', 'no-store'); }
92
+ function secureHeaders(res) { res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('Content-Security-Policy', "default-src 'self'; style-src 'unsafe-inline'; script-src 'unsafe-inline'; connect-src 'self'; frame-ancestors 'none'"); res.setHeader('X-Frame-Options', 'DENY'); res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()'); res.setHeader('Referrer-Policy', 'no-referrer'); res.setHeader('Cache-Control', 'no-store'); }
81
93
  function html(res, value) { res.statusCode = 200; res.setHeader('Content-Type', 'text/html; charset=utf-8'); res.end(value); }
82
94
  function json(res, value, status = 200) { res.statusCode = status; res.setHeader('Content-Type', 'application/json; charset=utf-8'); res.end(JSON.stringify(value)); }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "cloudflare-tunnel-kit",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
4
  "description": "Safe, reusable Cloudflare Tunnel setup for custom and Laravel projects.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "cf-tunnel": "dist/cli/main.js"
7
+ "cf-tunnel": "dist/cli/main.js",
8
+ "cftunnel": "dist/cli/main.js"
8
9
  },
9
10
  "main": "dist/index.js",
10
11
  "types": "dist/index.d.ts",
@@ -21,7 +22,8 @@
21
22
  ],
22
23
  "scripts": {
23
24
  "build": "tsc",
24
- "test": "npm run build && node --test tests/**/*.test.js",
25
+ "test": "npm run build && npm run test:unit",
26
+ "test:unit": "node --test --test-timeout=10000 tests/**/*.test.js",
25
27
  "dev": "tsc --watch"
26
28
  },
27
29
  "devDependencies": {