cloudflare-tunnel-kit 0.1.4 → 0.1.5
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 +18 -1
- package/dist/app/service.js +18 -3
- package/dist/core/named-workflow.js +5 -0
- package/dist/core/redact.js +5 -1
- package/dist/core/types.d.ts +2 -0
- package/dist/core/validation.js +15 -1
- package/dist/providers/process-supervisor.js +10 -1
- package/dist/ui/server.js +14 -2
- package/package.json +5 -3
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
|
+

|
|
6
|
+
|
|
5
7
|
## Current version
|
|
6
8
|
|
|
7
|
-
`0.1.
|
|
9
|
+
`0.1.5` 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
|
package/dist/app/service.js
CHANGED
|
@@ -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) {
|
|
75
|
-
|
|
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 {
|
|
@@ -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())
|
package/dist/core/redact.js
CHANGED
|
@@ -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
|
|
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))
|
package/dist/core/types.d.ts
CHANGED
package/dist/core/validation.js
CHANGED
|
@@ -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
|
-
|
|
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/server.js
CHANGED
|
@@ -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 (
|
|
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.
|
|
3
|
+
"version": "0.1.5",
|
|
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 &&
|
|
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": {
|