@zeroexcore/tuna 0.1.0
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/.turbo/turbo-test.log +14 -0
- package/.turbo/turbo-typecheck.log +4 -0
- package/package.json +60 -0
- package/src/commands/delete.ts +193 -0
- package/src/commands/init.ts +219 -0
- package/src/commands/list.ts +159 -0
- package/src/commands/login.ts +133 -0
- package/src/commands/run.ts +241 -0
- package/src/commands/stop.ts +44 -0
- package/src/index.ts +129 -0
- package/src/lib/access.ts +191 -0
- package/src/lib/api.ts +383 -0
- package/src/lib/cloudflared.ts +279 -0
- package/src/lib/config.ts +125 -0
- package/src/lib/credentials.ts +67 -0
- package/src/lib/dns.ts +164 -0
- package/src/lib/service.ts +354 -0
- package/src/types/cloudflare.ts +155 -0
- package/src/types/config.ts +33 -0
- package/src/types/index.ts +6 -0
- package/tests/unit/config.test.ts +176 -0
- package/tsconfig.json +18 -0
- package/vitest.config.ts +18 -0
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cloudflared binary management - detection, download, and execution
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { execa, type ExecaError } from 'execa';
|
|
6
|
+
import { existsSync, mkdirSync, chmodSync, createWriteStream } from 'fs';
|
|
7
|
+
import { homedir, platform, arch } from 'os';
|
|
8
|
+
import { join } from 'path';
|
|
9
|
+
|
|
10
|
+
const TUNA_DIR = join(homedir(), '.tuna');
|
|
11
|
+
const BIN_DIR = join(TUNA_DIR, 'bin');
|
|
12
|
+
const TUNNELS_DIR = join(TUNA_DIR, 'tunnels');
|
|
13
|
+
|
|
14
|
+
// GitHub release download URLs
|
|
15
|
+
const DOWNLOAD_BASE = 'https://github.com/cloudflare/cloudflared/releases/latest/download';
|
|
16
|
+
|
|
17
|
+
interface ExecResult {
|
|
18
|
+
stdout: string;
|
|
19
|
+
stderr: string;
|
|
20
|
+
exitCode: number;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Get the download URL for cloudflared based on platform and architecture
|
|
25
|
+
*/
|
|
26
|
+
function getDownloadUrl(): string {
|
|
27
|
+
const os = platform();
|
|
28
|
+
const architecture = arch();
|
|
29
|
+
|
|
30
|
+
if (os === 'darwin') {
|
|
31
|
+
if (architecture === 'arm64') {
|
|
32
|
+
return `${DOWNLOAD_BASE}/cloudflared-darwin-arm64.tgz`;
|
|
33
|
+
}
|
|
34
|
+
return `${DOWNLOAD_BASE}/cloudflared-darwin-amd64.tgz`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (os === 'linux') {
|
|
38
|
+
if (architecture === 'arm64') {
|
|
39
|
+
return `${DOWNLOAD_BASE}/cloudflared-linux-arm64`;
|
|
40
|
+
}
|
|
41
|
+
if (architecture === 'arm') {
|
|
42
|
+
return `${DOWNLOAD_BASE}/cloudflared-linux-arm`;
|
|
43
|
+
}
|
|
44
|
+
return `${DOWNLOAD_BASE}/cloudflared-linux-amd64`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (os === 'win32') {
|
|
48
|
+
if (architecture === 'x64') {
|
|
49
|
+
return `${DOWNLOAD_BASE}/cloudflared-windows-amd64.exe`;
|
|
50
|
+
}
|
|
51
|
+
return `${DOWNLOAD_BASE}/cloudflared-windows-386.exe`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
throw new Error(`Unsupported platform: ${os} ${architecture}`);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get the path to the cloudflared binary (either in PATH or downloaded)
|
|
59
|
+
*/
|
|
60
|
+
export function getExecutablePath(): string {
|
|
61
|
+
// First check if cloudflared is in PATH
|
|
62
|
+
const systemPath = getSystemCloudflaredPath();
|
|
63
|
+
if (systemPath) {
|
|
64
|
+
return systemPath;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Fall back to downloaded binary
|
|
68
|
+
const os = platform();
|
|
69
|
+
const binaryName = os === 'win32' ? 'cloudflared.exe' : 'cloudflared';
|
|
70
|
+
return join(BIN_DIR, binaryName);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Check if cloudflared is available in system PATH
|
|
75
|
+
*/
|
|
76
|
+
function getSystemCloudflaredPath(): string | null {
|
|
77
|
+
try {
|
|
78
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
79
|
+
const { execSync } = require('child_process') as typeof import('child_process');
|
|
80
|
+
const stdout = execSync('which cloudflared 2>/dev/null || where cloudflared 2>nul', {
|
|
81
|
+
encoding: 'utf-8',
|
|
82
|
+
stdio: ['pipe', 'pipe', 'pipe']
|
|
83
|
+
});
|
|
84
|
+
const path = stdout.trim().split('\n')[0];
|
|
85
|
+
return path || null;
|
|
86
|
+
} catch {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Check if cloudflared is installed (either in PATH or downloaded)
|
|
93
|
+
*/
|
|
94
|
+
export function isInstalled(): boolean {
|
|
95
|
+
// Check system PATH first
|
|
96
|
+
if (getSystemCloudflaredPath()) {
|
|
97
|
+
return true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Check downloaded binary
|
|
101
|
+
const binaryPath = getExecutablePath();
|
|
102
|
+
return existsSync(binaryPath);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Get the installed cloudflared version
|
|
107
|
+
*/
|
|
108
|
+
export async function getVersion(): Promise<string> {
|
|
109
|
+
const result = await exec(['--version']);
|
|
110
|
+
// Output format: "cloudflared version 2024.1.0 (built 2024-01-15-1234 revision abcd1234)"
|
|
111
|
+
const match = result.stdout.match(/version\s+(\d+\.\d+\.\d+)/);
|
|
112
|
+
if (match) {
|
|
113
|
+
return match[1];
|
|
114
|
+
}
|
|
115
|
+
throw new Error('Could not parse cloudflared version');
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Ensure the tuna directories exist
|
|
120
|
+
*/
|
|
121
|
+
export function ensureDirectories(): void {
|
|
122
|
+
if (!existsSync(TUNA_DIR)) {
|
|
123
|
+
mkdirSync(TUNA_DIR, { recursive: true });
|
|
124
|
+
}
|
|
125
|
+
if (!existsSync(BIN_DIR)) {
|
|
126
|
+
mkdirSync(BIN_DIR, { recursive: true });
|
|
127
|
+
}
|
|
128
|
+
if (!existsSync(TUNNELS_DIR)) {
|
|
129
|
+
mkdirSync(TUNNELS_DIR, { recursive: true, mode: 0o700 });
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Download cloudflared binary for the current platform
|
|
135
|
+
*/
|
|
136
|
+
export async function download(
|
|
137
|
+
onProgress?: (percent: number) => void
|
|
138
|
+
): Promise<string> {
|
|
139
|
+
ensureDirectories();
|
|
140
|
+
|
|
141
|
+
const url = getDownloadUrl();
|
|
142
|
+
const isTarball = url.endsWith('.tgz');
|
|
143
|
+
const binaryPath = getExecutablePath();
|
|
144
|
+
|
|
145
|
+
// Download the file
|
|
146
|
+
const response = await fetch(url);
|
|
147
|
+
if (!response.ok) {
|
|
148
|
+
throw new Error(`Failed to download cloudflared: ${response.statusText}`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const contentLength = response.headers.get('content-length');
|
|
152
|
+
const totalSize = contentLength ? parseInt(contentLength, 10) : 0;
|
|
153
|
+
let downloadedSize = 0;
|
|
154
|
+
|
|
155
|
+
if (isTarball) {
|
|
156
|
+
// For macOS, we need to extract from tarball
|
|
157
|
+
const tempTarPath = join(BIN_DIR, 'cloudflared.tgz');
|
|
158
|
+
const writeStream = createWriteStream(tempTarPath);
|
|
159
|
+
|
|
160
|
+
const reader = response.body?.getReader();
|
|
161
|
+
if (!reader) {
|
|
162
|
+
throw new Error('Failed to get response body reader');
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// Download with progress
|
|
166
|
+
while (true) {
|
|
167
|
+
const { done, value } = await reader.read();
|
|
168
|
+
if (done) break;
|
|
169
|
+
|
|
170
|
+
writeStream.write(Buffer.from(value));
|
|
171
|
+
downloadedSize += value.length;
|
|
172
|
+
|
|
173
|
+
if (onProgress && totalSize > 0) {
|
|
174
|
+
onProgress(Math.round((downloadedSize / totalSize) * 100));
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
writeStream.end();
|
|
179
|
+
await new Promise((resolve) => writeStream.on('finish', resolve));
|
|
180
|
+
|
|
181
|
+
// Extract tarball using tar command
|
|
182
|
+
await execa('tar', ['-xzf', tempTarPath, '-C', BIN_DIR]);
|
|
183
|
+
|
|
184
|
+
// Remove tarball
|
|
185
|
+
const { unlinkSync } = await import('fs');
|
|
186
|
+
unlinkSync(tempTarPath);
|
|
187
|
+
} else {
|
|
188
|
+
// Direct binary download (Linux, Windows)
|
|
189
|
+
const writeStream = createWriteStream(binaryPath);
|
|
190
|
+
const reader = response.body?.getReader();
|
|
191
|
+
if (!reader) {
|
|
192
|
+
throw new Error('Failed to get response body reader');
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
while (true) {
|
|
196
|
+
const { done, value } = await reader.read();
|
|
197
|
+
if (done) break;
|
|
198
|
+
|
|
199
|
+
writeStream.write(Buffer.from(value));
|
|
200
|
+
downloadedSize += value.length;
|
|
201
|
+
|
|
202
|
+
if (onProgress && totalSize > 0) {
|
|
203
|
+
onProgress(Math.round((downloadedSize / totalSize) * 100));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
writeStream.end();
|
|
208
|
+
await new Promise((resolve) => writeStream.on('finish', resolve));
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Make executable on Unix
|
|
212
|
+
if (platform() !== 'win32') {
|
|
213
|
+
chmodSync(binaryPath, 0o755);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
return binaryPath;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Execute a cloudflared command
|
|
221
|
+
*/
|
|
222
|
+
export async function exec(args: string[]): Promise<ExecResult> {
|
|
223
|
+
const binaryPath = getExecutablePath();
|
|
224
|
+
|
|
225
|
+
if (!existsSync(binaryPath) && !getSystemCloudflaredPath()) {
|
|
226
|
+
throw new Error(
|
|
227
|
+
'cloudflared is not installed. Run `tuna` to auto-download, or install manually:\n' +
|
|
228
|
+
' brew install cloudflared\n' +
|
|
229
|
+
' https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/'
|
|
230
|
+
);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
try {
|
|
234
|
+
const result = await execa(binaryPath, args);
|
|
235
|
+
return {
|
|
236
|
+
stdout: String(result.stdout ?? ''),
|
|
237
|
+
stderr: String(result.stderr ?? ''),
|
|
238
|
+
exitCode: result.exitCode ?? 0,
|
|
239
|
+
};
|
|
240
|
+
} catch (error) {
|
|
241
|
+
const execaError = error as ExecaError;
|
|
242
|
+
if (execaError.exitCode !== undefined) {
|
|
243
|
+
return {
|
|
244
|
+
stdout: String(execaError.stdout ?? ''),
|
|
245
|
+
stderr: String(execaError.stderr ?? ''),
|
|
246
|
+
exitCode: execaError.exitCode,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
throw error;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Get the tuna directory path
|
|
255
|
+
*/
|
|
256
|
+
export function getTunaDir(): string {
|
|
257
|
+
return TUNA_DIR;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Get the tunnels directory path
|
|
262
|
+
*/
|
|
263
|
+
export function getTunnelsDir(): string {
|
|
264
|
+
return TUNNELS_DIR;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Get path for a tunnel credentials file
|
|
269
|
+
*/
|
|
270
|
+
export function getTunnelCredentialsPath(tunnelId: string): string {
|
|
271
|
+
return join(TUNNELS_DIR, `${tunnelId}.json`);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Get path for a tunnel config file
|
|
276
|
+
*/
|
|
277
|
+
export function getTunnelConfigPath(tunnelId: string): string {
|
|
278
|
+
return join(TUNA_DIR, `config-${tunnelId}.yml`);
|
|
279
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration management - reads package.json and interpolates environment variables
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { findUp } from 'find-up';
|
|
6
|
+
import { readFile } from 'fs/promises';
|
|
7
|
+
import type { PackageJson, TunaConfig } from '../types/index.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Find package.json in current directory or parent directories
|
|
11
|
+
*/
|
|
12
|
+
export async function findPackageJson(): Promise<string | null> {
|
|
13
|
+
const found = await findUp('package.json');
|
|
14
|
+
return found || null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Interpolate environment variables in a string
|
|
19
|
+
* Priority: $TUNA_USER > $USER > 'unknown'
|
|
20
|
+
*/
|
|
21
|
+
export function interpolateEnvVars(value: string): string {
|
|
22
|
+
const tunaUser = process.env.TUNA_USER;
|
|
23
|
+
const user = process.env.USER;
|
|
24
|
+
const home = process.env.HOME;
|
|
25
|
+
|
|
26
|
+
let result = value;
|
|
27
|
+
|
|
28
|
+
// Replace $TUNA_USER first (if set)
|
|
29
|
+
if (tunaUser) {
|
|
30
|
+
result = result.replace(/\$TUNA_USER/g, tunaUser);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// Replace $USER with TUNA_USER if set, otherwise USER, otherwise 'unknown'
|
|
34
|
+
result = result.replace(/\$USER/g, tunaUser || user || 'unknown');
|
|
35
|
+
|
|
36
|
+
// Replace $HOME
|
|
37
|
+
result = result.replace(/\$HOME/g, home || '~');
|
|
38
|
+
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Validate tuna configuration
|
|
44
|
+
*/
|
|
45
|
+
export function validateConfig(config: TunaConfig): void {
|
|
46
|
+
if (!config.forward) {
|
|
47
|
+
throw new Error('Missing "forward" in tuna config');
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (config.port === undefined || config.port === null) {
|
|
51
|
+
throw new Error('Missing "port" in tuna config');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (typeof config.port !== 'number') {
|
|
55
|
+
throw new Error('"port" must be a number');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (config.port < 1 || config.port > 65535) {
|
|
59
|
+
throw new Error('"port" must be between 1 and 65535');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Ensure no unresolved variables remain (check this before domain validation)
|
|
63
|
+
if (config.forward.includes('$')) {
|
|
64
|
+
throw new Error(
|
|
65
|
+
`Unresolved environment variables in forward field: ${config.forward}\n` +
|
|
66
|
+
'Supported: $USER, $TUNA_USER, $HOME'
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Basic domain validation (after interpolation)
|
|
71
|
+
// Allow dots but not consecutive dots
|
|
72
|
+
const domainRegex = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}$/i;
|
|
73
|
+
if (!domainRegex.test(config.forward)) {
|
|
74
|
+
throw new Error(`Invalid domain format: ${config.forward}`);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Read and parse tuna configuration from package.json
|
|
80
|
+
*/
|
|
81
|
+
export async function readConfig(): Promise<TunaConfig> {
|
|
82
|
+
const pkgPath = await findPackageJson();
|
|
83
|
+
|
|
84
|
+
if (!pkgPath) {
|
|
85
|
+
throw new Error(
|
|
86
|
+
'No package.json found.\n' +
|
|
87
|
+
'Run this command from your project directory.'
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const content = await readFile(pkgPath, 'utf-8');
|
|
92
|
+
const pkg: PackageJson = JSON.parse(content);
|
|
93
|
+
|
|
94
|
+
if (!pkg.tuna) {
|
|
95
|
+
throw new Error(
|
|
96
|
+
'No "tuna" config in package.json.\n' +
|
|
97
|
+
'Add to package.json:\n' +
|
|
98
|
+
'{\n' +
|
|
99
|
+
' "tuna": {\n' +
|
|
100
|
+
' "forward": "my-app.example.com",\n' +
|
|
101
|
+
' "port": 3000\n' +
|
|
102
|
+
' }\n' +
|
|
103
|
+
'}'
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Interpolate environment variables in forward field
|
|
108
|
+
const interpolatedConfig: TunaConfig = {
|
|
109
|
+
...pkg.tuna,
|
|
110
|
+
forward: interpolateEnvVars(pkg.tuna.forward),
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
validateConfig(interpolatedConfig);
|
|
114
|
+
return interpolatedConfig;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Generate tunnel name from forward domain
|
|
119
|
+
* Format: tuna-{sanitized-forward}
|
|
120
|
+
*/
|
|
121
|
+
export function generateTunnelName(forward: string): string {
|
|
122
|
+
// Sanitize: replace non-alphanumeric with -, lowercase
|
|
123
|
+
const sanitized = forward.replace(/[^a-z0-9]/gi, '-').toLowerCase();
|
|
124
|
+
return `tuna-${sanitized}`;
|
|
125
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Credential management - secure storage in macOS Keychain
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import keytar from 'keytar';
|
|
6
|
+
import type { Credentials } from '../types/index.ts';
|
|
7
|
+
|
|
8
|
+
// Use a single service name so findCredentials works correctly
|
|
9
|
+
const SERVICE_NAME = 'tuna';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Store credentials in macOS Keychain
|
|
13
|
+
*/
|
|
14
|
+
export async function storeCredentials(
|
|
15
|
+
domain: string,
|
|
16
|
+
creds: Credentials
|
|
17
|
+
): Promise<void> {
|
|
18
|
+
const password = JSON.stringify(creds);
|
|
19
|
+
await keytar.setPassword(SERVICE_NAME, domain, password);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Retrieve credentials from macOS Keychain
|
|
24
|
+
* Triggers biometric authentication
|
|
25
|
+
*/
|
|
26
|
+
export async function getCredentials(
|
|
27
|
+
domain: string
|
|
28
|
+
): Promise<Credentials | null> {
|
|
29
|
+
const password = await keytar.getPassword(SERVICE_NAME, domain);
|
|
30
|
+
|
|
31
|
+
if (!password) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(password) as Credentials;
|
|
37
|
+
} catch {
|
|
38
|
+
throw new Error(`Failed to parse credentials for ${domain}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Delete credentials from macOS Keychain
|
|
44
|
+
*/
|
|
45
|
+
export async function deleteCredentials(domain: string): Promise<boolean> {
|
|
46
|
+
return await keytar.deletePassword(SERVICE_NAME, domain);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* List all configured domains
|
|
51
|
+
*/
|
|
52
|
+
export async function listDomains(): Promise<string[]> {
|
|
53
|
+
const credentials = await keytar.findCredentials(SERVICE_NAME);
|
|
54
|
+
return credentials.map((c) => c.account);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Extract root domain from subdomain
|
|
59
|
+
* e.g., "my-app.example.com" → "example.com"
|
|
60
|
+
*/
|
|
61
|
+
export function getRootDomain(forward: string): string {
|
|
62
|
+
const parts = forward.split('.');
|
|
63
|
+
if (parts.length < 2) {
|
|
64
|
+
throw new Error(`Invalid domain: ${forward}`);
|
|
65
|
+
}
|
|
66
|
+
return parts.slice(-2).join('.');
|
|
67
|
+
}
|
package/src/lib/dns.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* DNS management - higher-level DNS operations built on top of API client
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { CloudflareAPI } from './api.ts';
|
|
6
|
+
import { getRootDomain } from './credentials.ts';
|
|
7
|
+
import type { Credentials } from '../types/index.ts';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Get the CNAME target for a tunnel
|
|
11
|
+
* Format: {tunnel-id}.cfargotunnel.com
|
|
12
|
+
*/
|
|
13
|
+
export function getTunnelCnameTarget(tunnelId: string): string {
|
|
14
|
+
return `${tunnelId}.cfargotunnel.com`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Ensure a DNS CNAME record exists pointing to the tunnel
|
|
19
|
+
* Creates the record if it doesn't exist, updates if it points to wrong target
|
|
20
|
+
*/
|
|
21
|
+
export async function ensureDnsRecord(
|
|
22
|
+
credentials: Credentials,
|
|
23
|
+
forward: string,
|
|
24
|
+
tunnelId: string
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
const api = new CloudflareAPI(credentials);
|
|
27
|
+
const rootDomain = getRootDomain(forward);
|
|
28
|
+
const cnameTarget = getTunnelCnameTarget(tunnelId);
|
|
29
|
+
|
|
30
|
+
// Get the zone
|
|
31
|
+
const zone = await api.getZoneByName(rootDomain);
|
|
32
|
+
|
|
33
|
+
// Check if record already exists
|
|
34
|
+
const existingRecords = await api.listDnsRecords(zone.id, forward);
|
|
35
|
+
const existingCname = existingRecords.find(
|
|
36
|
+
(r) => r.type === 'CNAME' && r.name === forward
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
if (existingCname) {
|
|
40
|
+
// Record exists - check if it points to correct target
|
|
41
|
+
if (existingCname.content === cnameTarget) {
|
|
42
|
+
// Already correct, nothing to do
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Update to point to correct target
|
|
47
|
+
await api.updateDnsRecord(zone.id, existingCname.id!, {
|
|
48
|
+
type: 'CNAME',
|
|
49
|
+
name: forward,
|
|
50
|
+
content: cnameTarget,
|
|
51
|
+
proxied: true,
|
|
52
|
+
ttl: 1, // Auto
|
|
53
|
+
});
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Check for conflicting A/AAAA records
|
|
58
|
+
const conflictingRecords = existingRecords.filter(
|
|
59
|
+
(r) => (r.type === 'A' || r.type === 'AAAA') && r.name === forward
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
if (conflictingRecords.length > 0) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
`DNS conflict: ${forward} already has ${conflictingRecords[0].type} record(s).\n` +
|
|
65
|
+
`Delete them first or use a different subdomain.`
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Create new CNAME record
|
|
70
|
+
await api.createDnsRecord(zone.id, {
|
|
71
|
+
type: 'CNAME',
|
|
72
|
+
name: forward,
|
|
73
|
+
content: cnameTarget,
|
|
74
|
+
proxied: true,
|
|
75
|
+
ttl: 1, // Auto
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Delete the DNS record for a domain
|
|
81
|
+
*/
|
|
82
|
+
export async function deleteDnsRecordForDomain(
|
|
83
|
+
credentials: Credentials,
|
|
84
|
+
forward: string
|
|
85
|
+
): Promise<boolean> {
|
|
86
|
+
const api = new CloudflareAPI(credentials);
|
|
87
|
+
const rootDomain = getRootDomain(forward);
|
|
88
|
+
|
|
89
|
+
try {
|
|
90
|
+
const zone = await api.getZoneByName(rootDomain);
|
|
91
|
+
const records = await api.listDnsRecords(zone.id, forward);
|
|
92
|
+
|
|
93
|
+
// Find and delete CNAME record for this domain
|
|
94
|
+
const cnameRecord = records.find(
|
|
95
|
+
(r) => r.type === 'CNAME' && r.name === forward
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
if (cnameRecord && cnameRecord.id) {
|
|
99
|
+
await api.deleteDnsRecord(zone.id, cnameRecord.id);
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return false;
|
|
104
|
+
} catch (error) {
|
|
105
|
+
// If zone not found, record doesn't exist
|
|
106
|
+
if ((error as Error).message.includes('Zone not found')) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
throw error;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* List all DNS records pointing to a specific tunnel
|
|
115
|
+
*/
|
|
116
|
+
export async function listDnsRecordsForTunnel(
|
|
117
|
+
credentials: Credentials,
|
|
118
|
+
tunnelId: string,
|
|
119
|
+
domain: string
|
|
120
|
+
): Promise<string[]> {
|
|
121
|
+
const api = new CloudflareAPI(credentials);
|
|
122
|
+
const cnameTarget = getTunnelCnameTarget(tunnelId);
|
|
123
|
+
|
|
124
|
+
try {
|
|
125
|
+
const zone = await api.getZoneByName(domain);
|
|
126
|
+
const allRecords = await api.listDnsRecords(zone.id);
|
|
127
|
+
|
|
128
|
+
// Find all CNAME records pointing to this tunnel
|
|
129
|
+
return allRecords
|
|
130
|
+
.filter((r) => r.type === 'CNAME' && r.content === cnameTarget)
|
|
131
|
+
.map((r) => r.name);
|
|
132
|
+
} catch (error) {
|
|
133
|
+
// If zone not found, return empty list
|
|
134
|
+
if ((error as Error).message.includes('Zone not found')) {
|
|
135
|
+
return [];
|
|
136
|
+
}
|
|
137
|
+
throw error;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Validate that the domain is properly configured
|
|
143
|
+
* Returns true if DNS record exists and points to the tunnel
|
|
144
|
+
*/
|
|
145
|
+
export async function validateDnsRecord(
|
|
146
|
+
credentials: Credentials,
|
|
147
|
+
forward: string,
|
|
148
|
+
tunnelId: string
|
|
149
|
+
): Promise<boolean> {
|
|
150
|
+
const api = new CloudflareAPI(credentials);
|
|
151
|
+
const rootDomain = getRootDomain(forward);
|
|
152
|
+
const cnameTarget = getTunnelCnameTarget(tunnelId);
|
|
153
|
+
|
|
154
|
+
try {
|
|
155
|
+
const zone = await api.getZoneByName(rootDomain);
|
|
156
|
+
const records = await api.listDnsRecords(zone.id, forward);
|
|
157
|
+
|
|
158
|
+
return records.some(
|
|
159
|
+
(r) => r.type === 'CNAME' && r.name === forward && r.content === cnameTarget
|
|
160
|
+
);
|
|
161
|
+
} catch {
|
|
162
|
+
return false;
|
|
163
|
+
}
|
|
164
|
+
}
|