a2acalling 0.5.5 → 0.6.1
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 +3 -4
- package/SKILL.md +4 -3
- package/bin/cli.js +2 -4
- package/package.json +1 -1
- package/scripts/install-openclaw.js +256 -48
- package/src/lib/client.js +49 -25
- package/src/lib/invite-host.js +1 -41
- package/src/lib/port-scanner.js +51 -6
- package/src/lib/quick-tunnel.js +0 -332
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@ a2a create --name "My Agent" --owner "Your Name" --tier friends
|
|
|
33
33
|
# Output:
|
|
34
34
|
# 🤝 Your Name is inviting you to connect agents!
|
|
35
35
|
# Your agent can reach My Agent for: chat, web, files
|
|
36
|
-
# a2a://
|
|
36
|
+
# a2a://your-host.com/fed_abc123xyz
|
|
37
37
|
```
|
|
38
38
|
|
|
39
39
|
### Call someone else's agent
|
|
@@ -76,7 +76,7 @@ Setup behavior:
|
|
|
76
76
|
- Runtime auto-detects OpenClaw when available and falls back to generic mode if unavailable.
|
|
77
77
|
- If OpenClaw gateway is detected, dashboard is exposed on gateway at `/a2a` and A2A API at `/api/a2a/*` (proxied to A2A backend).
|
|
78
78
|
- If OpenClaw is not detected, setup bootstraps standalone config + bridge templates and serves dashboard at `/dashboard`.
|
|
79
|
-
-
|
|
79
|
+
- Setup inspects port 80 and prints reverse proxy guidance for stable internet-facing ingress.
|
|
80
80
|
- Setup prints the exact dashboard URL at the end.
|
|
81
81
|
|
|
82
82
|
Before the first `a2a call`, the owner must set permissions and disclosure tiers. Run onboarding first:
|
|
@@ -369,9 +369,8 @@ app.listen(3001);
|
|
|
369
369
|
|
|
370
370
|
| Variable | Description |
|
|
371
371
|
|----------|-------------|
|
|
372
|
-
| `A2A_HOSTNAME` | Hostname for invite URLs (required for
|
|
372
|
+
| `A2A_HOSTNAME` | Hostname for invite URLs (required for internet-facing invites) |
|
|
373
373
|
| `A2A_PORT` | Server port (default: 3001) |
|
|
374
|
-
| `A2A_DISABLE_QUICK_TUNNEL` | Set `true` to disable auto Cloudflare Quick Tunnel host resolution |
|
|
375
374
|
| `A2A_CONFIG_DIR` | Config directory (default: `~/.config/openclaw`) |
|
|
376
375
|
| `A2A_WORKSPACE` | Workspace root for context files like `USER.md` (default: current directory) |
|
|
377
376
|
| `A2A_RUNTIME` | Runtime mode: `auto` (default), `openclaw`, or `generic` |
|
package/SKILL.md
CHANGED
|
@@ -53,9 +53,10 @@ Extract: professional context, interests, goals, skills, sensitive areas. Group
|
|
|
53
53
|
|
|
54
54
|
## Network Ingress (Internet-Facing Invites)
|
|
55
55
|
|
|
56
|
-
-
|
|
57
|
-
-
|
|
58
|
-
-
|
|
56
|
+
- A2A does not bundle an auto-tunneling service for internet-facing ingress.
|
|
57
|
+
- For stable internet-facing invites, set `A2A_HOSTNAME` to your public endpoint (domain or public IP).
|
|
58
|
+
- Recommended: run the A2A backend on an internal port and expose it via a reverse proxy on `:443` (HTTPS) or `:80` (HTTP), routing `/api/a2a/*` to the backend.
|
|
59
|
+
- `npx a2acalling setup` inspects port 80 and prints reverse proxy guidance + an external reachability check.
|
|
59
60
|
|
|
60
61
|
## Commands
|
|
61
62
|
|
package/bin/cli.js
CHANGED
|
@@ -170,15 +170,13 @@ async function resolveInviteHostname() {
|
|
|
170
170
|
const config = new A2AConfig();
|
|
171
171
|
const resolved = await resolveInviteHost({
|
|
172
172
|
config,
|
|
173
|
-
defaultPort: process.env.PORT || process.env.A2A_PORT || 3001
|
|
174
|
-
preferQuickTunnel: true
|
|
173
|
+
defaultPort: process.env.PORT || process.env.A2A_PORT || 3001
|
|
175
174
|
});
|
|
176
175
|
return resolved;
|
|
177
176
|
} catch (err) {
|
|
178
177
|
return resolveInviteHost({
|
|
179
178
|
fallbackHost: process.env.OPENCLAW_HOSTNAME || process.env.HOSTNAME || 'localhost',
|
|
180
|
-
defaultPort: process.env.PORT || process.env.A2A_PORT || 3001
|
|
181
|
-
preferQuickTunnel: true
|
|
179
|
+
defaultPort: process.env.PORT || process.env.A2A_PORT || 3001
|
|
182
180
|
});
|
|
183
181
|
}
|
|
184
182
|
}
|
package/package.json
CHANGED
|
@@ -7,8 +7,8 @@
|
|
|
7
7
|
* so dashboard and A2A API are accessible at /a2a and /api/a2a on gateway.
|
|
8
8
|
* - If gateway is not detected, dashboard runs on standalone A2A server.
|
|
9
9
|
* - If OpenClaw is not installed, bootstrap standalone runtime templates.
|
|
10
|
-
* -
|
|
11
|
-
* for internet-facing
|
|
10
|
+
* - Networking-aware: inspects port 80 and prints reverse proxy guidance
|
|
11
|
+
* for stable internet-facing ingress.
|
|
12
12
|
*
|
|
13
13
|
* Usage:
|
|
14
14
|
* npx a2acalling install
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
const fs = require('fs');
|
|
21
21
|
const path = require('path');
|
|
22
22
|
const crypto = require('crypto');
|
|
23
|
+
const http = require('http');
|
|
24
|
+
const https = require('https');
|
|
23
25
|
const { execSync } = require('child_process');
|
|
24
26
|
|
|
25
27
|
// Paths
|
|
@@ -423,6 +425,7 @@ const plugin = {
|
|
|
423
425
|
|
|
424
426
|
proxyReq.on("error", (err) => {
|
|
425
427
|
const backend = backendBase.toString();
|
|
428
|
+
const suggestedPort = backendBase.port || (backendBase.protocol === "https:" ? "443" : "80");
|
|
426
429
|
if (isApi) {
|
|
427
430
|
sendJson(res, 502, {
|
|
428
431
|
success: false,
|
|
@@ -438,7 +441,7 @@ const plugin = {
|
|
|
438
441
|
<h1>A2A Dashboard Unavailable</h1>
|
|
439
442
|
<p>The gateway proxy is active, but the A2A backend is not reachable.</p>
|
|
440
443
|
<p>Expected backend: <code>\${backend}</code></p>
|
|
441
|
-
<p>Start the backend with: <code>a2a server --port
|
|
444
|
+
<p>Start the backend with: <code>a2a server --port \${suggestedPort}</code></p>
|
|
442
445
|
</body></html>\`);
|
|
443
446
|
});
|
|
444
447
|
|
|
@@ -497,63 +500,211 @@ function readExistingConfiguredInviteHost() {
|
|
|
497
500
|
}
|
|
498
501
|
}
|
|
499
502
|
|
|
500
|
-
|
|
501
|
-
const disabled = Boolean(flags['no-quick-tunnel']) ||
|
|
502
|
-
String(process.env.A2A_DISABLE_QUICK_TUNNEL || '').toLowerCase() === 'true';
|
|
503
|
-
if (disabled) return null;
|
|
504
|
-
|
|
503
|
+
function safeJsonParse(text) {
|
|
505
504
|
try {
|
|
506
|
-
|
|
507
|
-
const tunnel = await ensureQuickTunnel({ localPort: Number.parseInt(String(port), 10) || 3001 });
|
|
508
|
-
if (!tunnel || !tunnel.host) return null;
|
|
509
|
-
return {
|
|
510
|
-
...tunnel,
|
|
511
|
-
inviteHost: `${tunnel.host}:443`
|
|
512
|
-
};
|
|
505
|
+
return JSON.parse(String(text || ''));
|
|
513
506
|
} catch (err) {
|
|
514
|
-
warn(`Quick Tunnel setup failed: ${err.message}`);
|
|
515
|
-
warn('Falling back to direct host invites (may require firewall/NAT config).');
|
|
516
507
|
return null;
|
|
517
508
|
}
|
|
518
509
|
}
|
|
519
510
|
|
|
511
|
+
function looksLikePong(body) {
|
|
512
|
+
const parsed = safeJsonParse(body);
|
|
513
|
+
if (parsed && typeof parsed === 'object' && parsed.pong === true) return true;
|
|
514
|
+
return String(body || '').includes('"pong":true') || String(body || '').includes('"pong": true');
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function fetchUrlText(url, timeoutMs = 5000) {
|
|
518
|
+
return new Promise((resolve, reject) => {
|
|
519
|
+
let parsed;
|
|
520
|
+
try {
|
|
521
|
+
parsed = new URL(url);
|
|
522
|
+
} catch (err) {
|
|
523
|
+
reject(new Error('invalid_url'));
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const client = parsed.protocol === 'https:' ? https : http;
|
|
528
|
+
const req = client.request({
|
|
529
|
+
protocol: parsed.protocol,
|
|
530
|
+
hostname: parsed.hostname,
|
|
531
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
532
|
+
method: 'GET',
|
|
533
|
+
path: parsed.pathname + parsed.search,
|
|
534
|
+
headers: {
|
|
535
|
+
'User-Agent': `a2acalling/${process.env.npm_package_version || 'dev'} (setup-check)`
|
|
536
|
+
},
|
|
537
|
+
timeout: timeoutMs
|
|
538
|
+
}, (res) => {
|
|
539
|
+
let data = '';
|
|
540
|
+
res.setEncoding('utf8');
|
|
541
|
+
|
|
542
|
+
res.on('data', (chunk) => {
|
|
543
|
+
data += chunk;
|
|
544
|
+
if (data.length > 1024 * 256) {
|
|
545
|
+
req.destroy(new Error('response_too_large'));
|
|
546
|
+
}
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
res.on('end', () => {
|
|
550
|
+
resolve({
|
|
551
|
+
statusCode: res.statusCode || 0,
|
|
552
|
+
headers: res.headers || {},
|
|
553
|
+
body: data
|
|
554
|
+
});
|
|
555
|
+
});
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
req.on('error', reject);
|
|
559
|
+
req.on('timeout', () => req.destroy(new Error('timeout')));
|
|
560
|
+
req.end();
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
async function probeLocalA2APing(port) {
|
|
565
|
+
try {
|
|
566
|
+
const res = await fetchUrlText(`http://127.0.0.1:${port}/api/a2a/ping`, 900);
|
|
567
|
+
return { ok: looksLikePong(res.body), statusCode: res.statusCode, body: res.body };
|
|
568
|
+
} catch (err) {
|
|
569
|
+
return { ok: false, error: err && err.message ? err.message : 'request_failed' };
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
async function scanPort80() {
|
|
574
|
+
const { isPortListening, tryBindPort } = require('../src/lib/port-scanner');
|
|
575
|
+
|
|
576
|
+
const listening = await isPortListening(80, '127.0.0.1', { timeoutMs: 500 });
|
|
577
|
+
const bind = await tryBindPort(80, '0.0.0.0');
|
|
578
|
+
const a2aPing = listening.listening ? await probeLocalA2APing(80) : { ok: false };
|
|
579
|
+
|
|
580
|
+
return {
|
|
581
|
+
listening: Boolean(listening.listening),
|
|
582
|
+
listeningCode: listening.code,
|
|
583
|
+
bindOk: Boolean(bind.ok),
|
|
584
|
+
bindCode: bind.code,
|
|
585
|
+
a2aPingOk: Boolean(a2aPing.ok),
|
|
586
|
+
a2aPingStatusCode: a2aPing.statusCode,
|
|
587
|
+
a2aPingError: a2aPing.error
|
|
588
|
+
};
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
async function externalPingCheck(targetUrl) {
|
|
592
|
+
const providers = [
|
|
593
|
+
{
|
|
594
|
+
name: 'allorigins',
|
|
595
|
+
buildUrl: () => {
|
|
596
|
+
const u = new URL('https://api.allorigins.win/raw');
|
|
597
|
+
u.searchParams.set('url', targetUrl);
|
|
598
|
+
return u.toString();
|
|
599
|
+
}
|
|
600
|
+
},
|
|
601
|
+
{
|
|
602
|
+
name: 'jina',
|
|
603
|
+
buildUrl: () => `https://r.jina.ai/${targetUrl}`
|
|
604
|
+
}
|
|
605
|
+
];
|
|
606
|
+
|
|
607
|
+
const attempts = [];
|
|
608
|
+
for (const provider of providers) {
|
|
609
|
+
const providerUrl = provider.buildUrl();
|
|
610
|
+
try {
|
|
611
|
+
// eslint-disable-next-line no-await-in-loop
|
|
612
|
+
const res = await fetchUrlText(providerUrl, 8000);
|
|
613
|
+
const ok = looksLikePong(res.body);
|
|
614
|
+
attempts.push({ provider: provider.name, ok, statusCode: res.statusCode });
|
|
615
|
+
if (ok) {
|
|
616
|
+
return { ok: true, provider: provider.name, statusCode: res.statusCode, attempts };
|
|
617
|
+
}
|
|
618
|
+
} catch (err) {
|
|
619
|
+
attempts.push({ provider: provider.name, ok: false, error: err && err.message ? err.message : 'request_failed' });
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
return { ok: false, attempts };
|
|
624
|
+
}
|
|
625
|
+
|
|
520
626
|
async function install() {
|
|
521
627
|
log('Installing A2A Calling...\n');
|
|
522
628
|
|
|
523
629
|
const localHostname = process.env.HOSTNAME || 'localhost';
|
|
524
630
|
|
|
525
|
-
//
|
|
526
|
-
|
|
631
|
+
// Networking scan (port 80) + backend port selection.
|
|
632
|
+
const port80 = await scanPort80();
|
|
633
|
+
if (port80.a2aPingOk) {
|
|
634
|
+
log('Port 80 already serves /api/a2a/ping (A2A detected on :80).');
|
|
635
|
+
} else if (port80.listening) {
|
|
636
|
+
warn(`Port 80 is already bound (${port80.listeningCode || 'in_use'}). Setup will use an internal port and recommend reverse proxy routing.`);
|
|
637
|
+
} else if (!port80.bindOk && port80.bindCode === 'EACCES') {
|
|
638
|
+
warn('Port 80 appears free but is not bindable by this user (EACCES). Setup will use an unprivileged port unless you run with elevated privileges.');
|
|
639
|
+
} else if (port80.bindOk) {
|
|
640
|
+
log('Port 80 is bindable by this user (recommended for inbound A2A if you intend to serve directly on :80).');
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
let backendPort = null;
|
|
527
644
|
if (flags.port) {
|
|
528
|
-
|
|
645
|
+
backendPort = Number.parseInt(String(flags.port), 10);
|
|
529
646
|
} else if (process.env.A2A_PORT) {
|
|
530
|
-
|
|
647
|
+
backendPort = Number.parseInt(String(process.env.A2A_PORT), 10);
|
|
531
648
|
} else {
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
}
|
|
539
|
-
|
|
649
|
+
if (port80.bindOk && !port80.listening) {
|
|
650
|
+
backendPort = 80;
|
|
651
|
+
} else {
|
|
652
|
+
try {
|
|
653
|
+
const { findAvailablePort } = require('../src/lib/port-scanner');
|
|
654
|
+
backendPort = await findAvailablePort([3001, 8080, 8443, 9001]);
|
|
655
|
+
} catch (e) {
|
|
656
|
+
backendPort = null;
|
|
657
|
+
}
|
|
658
|
+
if (!backendPort) {
|
|
659
|
+
backendPort = 3001;
|
|
540
660
|
}
|
|
541
|
-
|
|
542
|
-
port = '3001';
|
|
661
|
+
log(`Auto-detected available internal port: ${backendPort}`);
|
|
543
662
|
}
|
|
544
663
|
}
|
|
545
|
-
|
|
664
|
+
if (!Number.isFinite(backendPort) || backendPort <= 0 || backendPort > 65535) {
|
|
665
|
+
backendPort = 3001;
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const backendUrl = flags['dashboard-backend'] || `http://127.0.0.1:${backendPort}`;
|
|
669
|
+
|
|
670
|
+
// Invite host selection: explicit > existing configured public host > auto resolve to external IP.
|
|
546
671
|
const explicitInviteHost = flags.hostname ||
|
|
547
672
|
process.env.A2A_HOSTNAME ||
|
|
548
673
|
process.env.OPENCLAW_HOSTNAME ||
|
|
549
674
|
readExistingConfiguredInviteHost();
|
|
550
|
-
|
|
551
|
-
|
|
675
|
+
|
|
676
|
+
let inviteHost = explicitInviteHost;
|
|
677
|
+
let inviteHostWarnings = [];
|
|
678
|
+
|
|
679
|
+
if (!inviteHost) {
|
|
680
|
+
try {
|
|
681
|
+
const { A2AConfig } = require('../src/lib/config');
|
|
682
|
+
const { resolveInviteHost } = require('../src/lib/invite-host');
|
|
683
|
+
const config = new A2AConfig();
|
|
684
|
+
const inviteDefaultPort = port80.listening ? 80 : backendPort;
|
|
685
|
+
const resolved = await resolveInviteHost({
|
|
686
|
+
config,
|
|
687
|
+
fallbackHost: localHostname,
|
|
688
|
+
defaultPort: inviteDefaultPort,
|
|
689
|
+
refreshExternalIp: true
|
|
690
|
+
});
|
|
691
|
+
inviteHost = resolved.host;
|
|
692
|
+
inviteHostWarnings = resolved.warnings || [];
|
|
693
|
+
} catch (err) {
|
|
694
|
+
inviteHost = `${localHostname}:${backendPort}`;
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
for (const w of inviteHostWarnings) {
|
|
699
|
+
warn(w);
|
|
700
|
+
}
|
|
701
|
+
|
|
552
702
|
const forceStandalone = Boolean(flags.standalone) || String(process.env.A2A_FORCE_STANDALONE || '').toLowerCase() === 'true';
|
|
553
703
|
const hasOpenClawBinary = commandExists('openclaw');
|
|
554
704
|
const hasOpenClawConfig = fs.existsSync(OPENCLAW_CONFIG);
|
|
555
705
|
const hasOpenClaw = !forceStandalone && (hasOpenClawBinary || hasOpenClawConfig);
|
|
556
706
|
let standaloneBootstrap = null;
|
|
707
|
+
const forceHostname = Boolean(flags.hostname || process.env.A2A_HOSTNAME || process.env.OPENCLAW_HOSTNAME) || !explicitInviteHost;
|
|
557
708
|
|
|
558
709
|
if (hasOpenClaw) {
|
|
559
710
|
// 1. Create skills directory if needed
|
|
@@ -571,13 +722,13 @@ async function install() {
|
|
|
571
722
|
log(`Installed skill to: ${skillDir}`);
|
|
572
723
|
|
|
573
724
|
// Ensure config and manifest exist even in OpenClaw path
|
|
574
|
-
ensureConfigAndManifest(inviteHost,
|
|
575
|
-
forceHostname
|
|
725
|
+
ensureConfigAndManifest(inviteHost, backendPort, {
|
|
726
|
+
forceHostname
|
|
576
727
|
});
|
|
577
728
|
} else {
|
|
578
729
|
warn('OpenClaw not detected. Enabling standalone A2A bootstrap.');
|
|
579
|
-
standaloneBootstrap = ensureStandaloneBootstrap(inviteHost,
|
|
580
|
-
forceHostname
|
|
730
|
+
standaloneBootstrap = ensureStandaloneBootstrap(inviteHost, backendPort, {
|
|
731
|
+
forceHostname
|
|
581
732
|
});
|
|
582
733
|
}
|
|
583
734
|
|
|
@@ -586,7 +737,7 @@ async function install() {
|
|
|
586
737
|
let configUpdated = false;
|
|
587
738
|
let gatewayDetected = false;
|
|
588
739
|
let dashboardMode = 'standalone';
|
|
589
|
-
let dashboardUrl = `http://${localHostname}:${
|
|
740
|
+
let dashboardUrl = `http://${localHostname}:${backendPort}/dashboard`;
|
|
590
741
|
|
|
591
742
|
if (config) {
|
|
592
743
|
// Add custom command for each enabled channel
|
|
@@ -650,12 +801,76 @@ async function install() {
|
|
|
650
801
|
? 'Runtime auto-selects OpenClaw when available and falls back to generic if needed.'
|
|
651
802
|
: 'Runtime defaults to generic fallback (no OpenClaw dependency required).';
|
|
652
803
|
|
|
804
|
+
const { splitHostPort, isLocalOrUnroutableHost } = require('../src/lib/invite-host');
|
|
805
|
+
const inviteParsed = splitHostPort(inviteHost);
|
|
806
|
+
const invitePort = inviteParsed.port;
|
|
807
|
+
const inviteScheme = (!invitePort || invitePort === 443) ? 'https' : 'http';
|
|
808
|
+
const invitePingUrl = `${inviteScheme}://${inviteHost}/api/a2a/ping`;
|
|
809
|
+
const inviteLooksLocal = isLocalOrUnroutableHost(inviteParsed.hostname);
|
|
810
|
+
const expectsReverseProxy = Boolean(
|
|
811
|
+
(invitePort === 80 && backendPort !== 80) ||
|
|
812
|
+
((!invitePort || invitePort === 443) && backendPort !== 443)
|
|
813
|
+
);
|
|
814
|
+
|
|
815
|
+
let externalPing = null;
|
|
816
|
+
if (!inviteLooksLocal && inviteParsed.hostname) {
|
|
817
|
+
externalPing = await externalPingCheck(invitePingUrl);
|
|
818
|
+
}
|
|
819
|
+
|
|
653
820
|
console.log(`
|
|
654
821
|
${bold('━━━ Server Setup ━━━')}
|
|
655
822
|
|
|
656
823
|
To receive incoming calls and host A2A APIs:
|
|
657
824
|
|
|
658
|
-
${green(`A2A_HOSTNAME="${inviteHost}" a2a server --port ${
|
|
825
|
+
${green(`A2A_HOSTNAME="${inviteHost}" a2a server --port ${backendPort}`)}
|
|
826
|
+
|
|
827
|
+
${bold('━━━ Ingress Setup ━━━')}
|
|
828
|
+
|
|
829
|
+
Invite host: ${green(inviteHost)}
|
|
830
|
+
Expected ping URL: ${green(invitePingUrl)}
|
|
831
|
+
|
|
832
|
+
Port 80 scan:
|
|
833
|
+
${port80.a2aPingOk
|
|
834
|
+
? green('Port 80 responds to /api/a2a/ping (A2A ready on :80)')
|
|
835
|
+
: port80.listening
|
|
836
|
+
? yellow(`Port 80 has a listener (${port80.listeningCode || 'in_use'})`)
|
|
837
|
+
: port80.bindOk
|
|
838
|
+
? green('Port 80 is free and bindable by this user')
|
|
839
|
+
: yellow(`Port 80 not bindable (${port80.bindCode || 'unknown'})`)}
|
|
840
|
+
|
|
841
|
+
${expectsReverseProxy
|
|
842
|
+
? `Reverse proxy required:
|
|
843
|
+
Route ${green('/api/a2a/*')} -> ${green(backendUrl)}
|
|
844
|
+
Route ${green('/a2a/*')} -> ${green(backendUrl.replace(/\/$/, ''))}${green('/dashboard/*')} (optional dashboard)
|
|
845
|
+
|
|
846
|
+
Example (Caddy):
|
|
847
|
+
${green(`YOUR_DOMAIN {
|
|
848
|
+
handle /api/a2a/* {
|
|
849
|
+
reverse_proxy 127.0.0.1:${backendPort}
|
|
850
|
+
}
|
|
851
|
+
handle /a2a* {
|
|
852
|
+
uri replace /a2a /dashboard
|
|
853
|
+
reverse_proxy 127.0.0.1:${backendPort}
|
|
854
|
+
}
|
|
855
|
+
}`)}
|
|
856
|
+
|
|
857
|
+
Example (nginx):
|
|
858
|
+
${green(`location /api/a2a/ {
|
|
859
|
+
proxy_pass http://127.0.0.1:${backendPort}/api/a2a/;
|
|
860
|
+
}
|
|
861
|
+
location = /a2a { return 301 /a2a/; }
|
|
862
|
+
location /a2a/ {
|
|
863
|
+
proxy_pass http://127.0.0.1:${backendPort}/dashboard/;
|
|
864
|
+
}`)}`
|
|
865
|
+
: 'Reverse proxy not required for the selected invite host.'}
|
|
866
|
+
|
|
867
|
+
${bold('━━━ External Ping ━━━')}
|
|
868
|
+
|
|
869
|
+
${inviteLooksLocal
|
|
870
|
+
? yellow('Skipped external ping: invite host looks local/unroutable. Set --hostname to a public endpoint to enable this check.')
|
|
871
|
+
: externalPing && externalPing.ok
|
|
872
|
+
? green(`External ping OK via ${externalPing.provider}`)
|
|
873
|
+
: yellow(`External ping FAILED (expected if the server is not running yet, or ingress is not publicly reachable).`)}
|
|
659
874
|
|
|
660
875
|
${bold('━━━ Dashboard Setup ━━━')}
|
|
661
876
|
|
|
@@ -669,12 +884,6 @@ ${dashboardMode === 'gateway'
|
|
|
669
884
|
${bold('━━━ Runtime Setup ━━━')}
|
|
670
885
|
|
|
671
886
|
${runtimeLine}
|
|
672
|
-
${quickTunnel
|
|
673
|
-
? `Quick Tunnel enabled:
|
|
674
|
-
${green(quickTunnel.url)}
|
|
675
|
-
Invites will use: ${green(inviteHost)}
|
|
676
|
-
`
|
|
677
|
-
: 'Quick Tunnel not enabled (using configured/direct invite host).'}
|
|
678
887
|
${standaloneBootstrap
|
|
679
888
|
? `Standalone bridge templates:
|
|
680
889
|
${green(standaloneBootstrap.turnScript)}
|
|
@@ -740,12 +949,11 @@ Usage:
|
|
|
740
949
|
npx a2acalling server Start A2A server
|
|
741
950
|
|
|
742
951
|
Install Options:
|
|
743
|
-
--hostname <host>
|
|
952
|
+
--hostname <host> Public hostname for invite URLs (e.g. myserver.com, myserver.com:80)
|
|
744
953
|
--port <port> A2A server port (default: 3001)
|
|
745
954
|
--gateway-url <url> Force gateway base URL for printed dashboard link
|
|
746
955
|
--dashboard-backend <url> Backend URL used by gateway dashboard proxy
|
|
747
956
|
--standalone Force standalone bootstrap (ignore OpenClaw detection)
|
|
748
|
-
--no-quick-tunnel Disable auto quick tunnel for no-DNS environments
|
|
749
957
|
|
|
750
958
|
Examples:
|
|
751
959
|
npx a2acalling install --hostname myserver.com --port 3001
|
package/src/lib/client.js
CHANGED
|
@@ -5,6 +5,51 @@
|
|
|
5
5
|
const https = require('https');
|
|
6
6
|
const http = require('http');
|
|
7
7
|
|
|
8
|
+
function splitHostPort(rawHost) {
|
|
9
|
+
const host = String(rawHost || '').trim();
|
|
10
|
+
if (!host) return { hostname: '', port: null };
|
|
11
|
+
|
|
12
|
+
// IPv6 in brackets: [::1]:3001
|
|
13
|
+
const bracketed = host.match(/^\[([^\]]+)\](?::(\d+))?$/);
|
|
14
|
+
if (bracketed) {
|
|
15
|
+
return {
|
|
16
|
+
hostname: bracketed[1],
|
|
17
|
+
port: bracketed[2] ? Number.parseInt(bracketed[2], 10) : null
|
|
18
|
+
};
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Only treat the last ":" as a port separator when there's exactly one colon.
|
|
22
|
+
const lastColon = host.lastIndexOf(':');
|
|
23
|
+
if (lastColon !== -1 && host.indexOf(':') === lastColon) {
|
|
24
|
+
const maybePort = host.slice(lastColon + 1);
|
|
25
|
+
if (/^\d+$/.test(maybePort)) {
|
|
26
|
+
return {
|
|
27
|
+
hostname: host.slice(0, lastColon),
|
|
28
|
+
port: Number.parseInt(maybePort, 10)
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
return { hostname: host, port: null };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function resolveProtocolAndPort(host) {
|
|
37
|
+
const parsed = splitHostPort(host);
|
|
38
|
+
const hostname = parsed.hostname;
|
|
39
|
+
const hasExplicitPort = Number.isFinite(parsed.port);
|
|
40
|
+
const isLocalhost = hostname === 'localhost' ||
|
|
41
|
+
hostname === '127.0.0.1' ||
|
|
42
|
+
hostname === '::1' ||
|
|
43
|
+
hostname.startsWith('127.');
|
|
44
|
+
|
|
45
|
+
const port = hasExplicitPort ? parsed.port : (isLocalhost ? 80 : 443);
|
|
46
|
+
// Use HTTP for localhost or explicit non-443 ports, HTTPS otherwise.
|
|
47
|
+
const useHttp = isLocalhost || (hasExplicitPort && port !== 443);
|
|
48
|
+
const protocol = useHttp ? http : https;
|
|
49
|
+
|
|
50
|
+
return { protocol, hostname, port };
|
|
51
|
+
}
|
|
52
|
+
|
|
8
53
|
class A2AClient {
|
|
9
54
|
constructor(options = {}) {
|
|
10
55
|
this.timeout = options.timeout || 60000;
|
|
@@ -50,13 +95,7 @@ class A2AClient {
|
|
|
50
95
|
timeout_seconds: timeoutSeconds || 60
|
|
51
96
|
});
|
|
52
97
|
|
|
53
|
-
const
|
|
54
|
-
const hasExplicitPort = host.includes(':');
|
|
55
|
-
const port = hasExplicitPort ? parseInt(host.split(':')[1]) : (isLocalhost ? 80 : 443);
|
|
56
|
-
// Use HTTP for localhost or explicit non-443 ports, HTTPS otherwise
|
|
57
|
-
const useHttp = isLocalhost || (hasExplicitPort && port !== 443);
|
|
58
|
-
const protocol = useHttp ? http : https;
|
|
59
|
-
const hostname = host.split(':')[0];
|
|
98
|
+
const { protocol, hostname, port } = resolveProtocolAndPort(host);
|
|
60
99
|
|
|
61
100
|
return new Promise((resolve, reject) => {
|
|
62
101
|
const req = protocol.request({
|
|
@@ -125,12 +164,7 @@ class A2AClient {
|
|
|
125
164
|
conversation_id: conversationId
|
|
126
165
|
});
|
|
127
166
|
|
|
128
|
-
const
|
|
129
|
-
const hasExplicitPort = host.includes(':');
|
|
130
|
-
const port = hasExplicitPort ? parseInt(host.split(':')[1]) : (isLocalhost ? 80 : 443);
|
|
131
|
-
const useHttp = isLocalhost || (hasExplicitPort && port !== 443);
|
|
132
|
-
const protocol = useHttp ? http : https;
|
|
133
|
-
const hostname = host.split(':')[0];
|
|
167
|
+
const { protocol, hostname, port } = resolveProtocolAndPort(host);
|
|
134
168
|
|
|
135
169
|
return new Promise((resolve, reject) => {
|
|
136
170
|
const req = protocol.request({
|
|
@@ -187,12 +221,7 @@ class A2AClient {
|
|
|
187
221
|
({ host } = endpoint);
|
|
188
222
|
}
|
|
189
223
|
|
|
190
|
-
const
|
|
191
|
-
const hasExplicitPort = host.includes(':');
|
|
192
|
-
const port = hasExplicitPort ? parseInt(host.split(':')[1]) : (isLocalhost ? 80 : 443);
|
|
193
|
-
const useHttp = isLocalhost || (hasExplicitPort && port !== 443);
|
|
194
|
-
const protocol = useHttp ? http : https;
|
|
195
|
-
const hostname = host.split(':')[0];
|
|
224
|
+
const { protocol, hostname, port } = resolveProtocolAndPort(host);
|
|
196
225
|
|
|
197
226
|
return new Promise((resolve, reject) => {
|
|
198
227
|
const req = protocol.request({
|
|
@@ -234,12 +263,7 @@ class A2AClient {
|
|
|
234
263
|
({ host } = endpoint);
|
|
235
264
|
}
|
|
236
265
|
|
|
237
|
-
const
|
|
238
|
-
const hasExplicitPort = host.includes(':');
|
|
239
|
-
const port = hasExplicitPort ? parseInt(host.split(':')[1]) : (isLocalhost ? 80 : 443);
|
|
240
|
-
const useHttp = isLocalhost || (hasExplicitPort && port !== 443);
|
|
241
|
-
const protocol = useHttp ? http : https;
|
|
242
|
-
const hostname = host.split(':')[0];
|
|
266
|
+
const { protocol, hostname, port } = resolveProtocolAndPort(host);
|
|
243
267
|
|
|
244
268
|
return new Promise((resolve, reject) => {
|
|
245
269
|
const req = protocol.request({
|
package/src/lib/invite-host.js
CHANGED
|
@@ -124,14 +124,6 @@ function isPublicIpHostname(hostname) {
|
|
|
124
124
|
return false;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
-
function isEphemeralTunnelHostname(hostname) {
|
|
128
|
-
const host = String(hostname || '').trim().toLowerCase();
|
|
129
|
-
if (!host) return false;
|
|
130
|
-
// Quick tunnels (cloudflared) are not stable across restarts.
|
|
131
|
-
if (host.endsWith('.trycloudflare.com')) return true;
|
|
132
|
-
return false;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
127
|
async function resolveInviteHost(options = {}) {
|
|
136
128
|
const config = options.config || null;
|
|
137
129
|
|
|
@@ -169,18 +161,8 @@ async function resolveInviteHost(options = {}) {
|
|
|
169
161
|
? options.externalIpTtlMs
|
|
170
162
|
: undefined;
|
|
171
163
|
|
|
172
|
-
const preferQuickTunnel = Boolean(options.preferQuickTunnel) ||
|
|
173
|
-
String(process.env.A2A_PREFER_QUICK_TUNNEL || '').toLowerCase() === 'true';
|
|
174
|
-
const quickTunnelDisabled = Boolean(options.disableQuickTunnel) ||
|
|
175
|
-
String(process.env.A2A_DISABLE_QUICK_TUNNEL || '').toLowerCase() === 'true';
|
|
176
|
-
|
|
177
|
-
// If a previous run persisted an ephemeral tunnel hostname into config (e.g. trycloudflare),
|
|
178
|
-
// treat it like "unroutable" so we always refresh via tunnel/external IP instead of returning
|
|
179
|
-
// a stale endpoint.
|
|
180
|
-
const candidateIsEphemeralTunnel = candidateSource !== 'env' && isEphemeralTunnelHostname(parsed.hostname);
|
|
181
|
-
|
|
182
164
|
const shouldReplaceWithExternalIp = isLocalOrUnroutableHost(parsed.hostname) ||
|
|
183
|
-
|
|
165
|
+
(
|
|
184
166
|
options.refreshExternalIp && isPublicIpHostname(parsed.hostname)
|
|
185
167
|
);
|
|
186
168
|
|
|
@@ -193,28 +175,6 @@ async function resolveInviteHost(options = {}) {
|
|
|
193
175
|
};
|
|
194
176
|
}
|
|
195
177
|
|
|
196
|
-
if (preferQuickTunnel && !quickTunnelDisabled) {
|
|
197
|
-
try {
|
|
198
|
-
const { ensureQuickTunnel } = require('./quick-tunnel');
|
|
199
|
-
const tunnel = await ensureQuickTunnel({
|
|
200
|
-
localPort: desiredPort
|
|
201
|
-
});
|
|
202
|
-
if (tunnel && tunnel.host) {
|
|
203
|
-
const tunnelParsed = splitHostPort(tunnel.host);
|
|
204
|
-
const finalHost = formatHostPort(tunnelParsed.hostname, tunnelParsed.port || 443);
|
|
205
|
-
warnings.push(`Using secure Quick Tunnel endpoint "${finalHost}" for internet-facing invites.`);
|
|
206
|
-
return {
|
|
207
|
-
host: finalHost,
|
|
208
|
-
source: 'quick_tunnel',
|
|
209
|
-
originalHost: candidateHostWithPort,
|
|
210
|
-
warnings
|
|
211
|
-
};
|
|
212
|
-
}
|
|
213
|
-
} catch (err) {
|
|
214
|
-
warnings.push(`Quick Tunnel unavailable (${err.message}). Falling back to external IP host detection.`);
|
|
215
|
-
}
|
|
216
|
-
}
|
|
217
|
-
|
|
218
178
|
const external = await getExternalIp({
|
|
219
179
|
ttlMs,
|
|
220
180
|
timeoutMs: options.externalIpTimeoutMs,
|
package/src/lib/port-scanner.js
CHANGED
|
@@ -8,20 +8,32 @@
|
|
|
8
8
|
const net = require('net');
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
*
|
|
11
|
+
* Attempt to bind a port and return a structured result.
|
|
12
|
+
* Useful for distinguishing "already in use" from "permission denied".
|
|
13
|
+
*
|
|
12
14
|
* @param {number} port
|
|
13
15
|
* @param {string} [host='0.0.0.0']
|
|
14
|
-
* @returns {Promise<boolean>}
|
|
16
|
+
* @returns {Promise<{ ok: boolean, code?: string, message?: string }>}
|
|
15
17
|
*/
|
|
16
|
-
function
|
|
18
|
+
function tryBindPort(port, host = '0.0.0.0') {
|
|
17
19
|
return new Promise((resolve) => {
|
|
18
20
|
const server = net.createServer();
|
|
19
|
-
server.once('error', () => resolve(false));
|
|
20
|
-
server.once('listening', () => server.close(() => resolve(true)));
|
|
21
|
+
server.once('error', (err) => resolve({ ok: false, code: err && err.code ? String(err.code) : 'ERROR', message: err ? String(err.message || '') : '' }));
|
|
22
|
+
server.once('listening', () => server.close(() => resolve({ ok: true })));
|
|
21
23
|
server.listen(port, host);
|
|
22
24
|
});
|
|
23
25
|
}
|
|
24
26
|
|
|
27
|
+
/**
|
|
28
|
+
* Check if a port is available on the given host for binding by the current user.
|
|
29
|
+
* @param {number} port
|
|
30
|
+
* @param {string} [host='0.0.0.0']
|
|
31
|
+
* @returns {Promise<boolean>}
|
|
32
|
+
*/
|
|
33
|
+
function isPortAvailable(port, host = '0.0.0.0') {
|
|
34
|
+
return tryBindPort(port, host).then((res) => Boolean(res && res.ok));
|
|
35
|
+
}
|
|
36
|
+
|
|
25
37
|
/**
|
|
26
38
|
* Find the first available port from a list of candidates.
|
|
27
39
|
* @param {number[]} candidates - Ports to try in order
|
|
@@ -35,4 +47,37 @@ async function findAvailablePort(candidates, host = '0.0.0.0') {
|
|
|
35
47
|
return null;
|
|
36
48
|
}
|
|
37
49
|
|
|
38
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Check whether a TCP listener exists on the given host/port.
|
|
52
|
+
* This does NOT require binding privileges; it simply attempts to connect.
|
|
53
|
+
*
|
|
54
|
+
* @param {number} port
|
|
55
|
+
* @param {string} [host='127.0.0.1']
|
|
56
|
+
* @param {object} [options]
|
|
57
|
+
* @param {number} [options.timeoutMs=400]
|
|
58
|
+
* @returns {Promise<{ listening: boolean, code?: string, message?: string }>}
|
|
59
|
+
*/
|
|
60
|
+
function isPortListening(port, host = '127.0.0.1', options = {}) {
|
|
61
|
+
const timeoutMs = Number.parseInt(String(options.timeoutMs || 400), 10) || 400;
|
|
62
|
+
|
|
63
|
+
return new Promise((resolve) => {
|
|
64
|
+
const socket = net.connect({ host, port });
|
|
65
|
+
let settled = false;
|
|
66
|
+
|
|
67
|
+
const finish = (result) => {
|
|
68
|
+
if (settled) return;
|
|
69
|
+
settled = true;
|
|
70
|
+
try { socket.destroy(); } catch (e) {}
|
|
71
|
+
resolve(result);
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
socket.setTimeout(timeoutMs, () => finish({ listening: false, code: 'ETIMEDOUT', message: 'connect_timeout' }));
|
|
75
|
+
socket.once('connect', () => finish({ listening: true }));
|
|
76
|
+
socket.once('error', (err) => {
|
|
77
|
+
const code = err && err.code ? String(err.code) : 'ERROR';
|
|
78
|
+
finish({ listening: false, code, message: err ? String(err.message || '') : '' });
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
module.exports = { tryBindPort, isPortAvailable, isPortListening, findAvailablePort };
|
package/src/lib/quick-tunnel.js
DELETED
|
@@ -1,332 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Quick Tunnel Support (Cloudflare)
|
|
3
|
-
*
|
|
4
|
-
* Default behavior for no-DNS environments:
|
|
5
|
-
* - lazily download cloudflared on first use (smallest viable binary path)
|
|
6
|
-
* - start a quick tunnel to local A2A server
|
|
7
|
-
* - persist tunnel metadata so invite generation can reuse it
|
|
8
|
-
*
|
|
9
|
-
* Intentionally minimal:
|
|
10
|
-
* - no account-required managed tunnel flow yet
|
|
11
|
-
* - no domain routing automation yet
|
|
12
|
-
*
|
|
13
|
-
* If owners need fixed hostnames or custom ingress, they can wire their own
|
|
14
|
-
* proxy/tunnel and set A2A_HOSTNAME to that endpoint.
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
const fs = require('fs');
|
|
18
|
-
const path = require('path');
|
|
19
|
-
const http = require('http');
|
|
20
|
-
const https = require('https');
|
|
21
|
-
const { spawn } = require('child_process');
|
|
22
|
-
|
|
23
|
-
const CONFIG_DIR = process.env.A2A_CONFIG_DIR ||
|
|
24
|
-
process.env.OPENCLAW_CONFIG_DIR ||
|
|
25
|
-
path.join(process.env.HOME || '/tmp', '.config', 'openclaw');
|
|
26
|
-
|
|
27
|
-
const BIN_DIR = path.join(CONFIG_DIR, 'bin');
|
|
28
|
-
const CLOUDFLARED_BIN = path.join(BIN_DIR, process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared');
|
|
29
|
-
const TUNNEL_STATE_FILE = path.join(CONFIG_DIR, 'a2a-quick-tunnel.json');
|
|
30
|
-
|
|
31
|
-
function ensureDir(dirPath) {
|
|
32
|
-
if (!fs.existsSync(dirPath)) {
|
|
33
|
-
fs.mkdirSync(dirPath, { recursive: true });
|
|
34
|
-
}
|
|
35
|
-
}
|
|
36
|
-
|
|
37
|
-
function isExecutable(filePath) {
|
|
38
|
-
try {
|
|
39
|
-
fs.accessSync(filePath, fs.constants.X_OK);
|
|
40
|
-
return true;
|
|
41
|
-
} catch (err) {
|
|
42
|
-
return false;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function commandExists(name) {
|
|
47
|
-
const paths = String(process.env.PATH || '').split(path.delimiter);
|
|
48
|
-
for (const p of paths) {
|
|
49
|
-
const full = path.join(p, name);
|
|
50
|
-
if (fs.existsSync(full) && isExecutable(full)) return full;
|
|
51
|
-
if (process.platform === 'win32') {
|
|
52
|
-
const exe = `${full}.exe`;
|
|
53
|
-
if (fs.existsSync(exe)) return exe;
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
return null;
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
function readJson(filePath) {
|
|
60
|
-
try {
|
|
61
|
-
if (!fs.existsSync(filePath)) return null;
|
|
62
|
-
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
|
63
|
-
} catch (err) {
|
|
64
|
-
return null;
|
|
65
|
-
}
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
function writeJson(filePath, value) {
|
|
69
|
-
ensureDir(path.dirname(filePath));
|
|
70
|
-
const tmp = `${filePath}.tmp`;
|
|
71
|
-
fs.writeFileSync(tmp, JSON.stringify(value, null, 2), { mode: 0o600 });
|
|
72
|
-
fs.renameSync(tmp, filePath);
|
|
73
|
-
try {
|
|
74
|
-
fs.chmodSync(filePath, 0o600);
|
|
75
|
-
} catch (err) {
|
|
76
|
-
// Best effort.
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function isProcessAlive(pid) {
|
|
81
|
-
if (!pid || !Number.isFinite(pid)) return false;
|
|
82
|
-
try {
|
|
83
|
-
process.kill(pid, 0);
|
|
84
|
-
return true;
|
|
85
|
-
} catch (err) {
|
|
86
|
-
return false;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function resolveCloudflaredAssetName() {
|
|
91
|
-
const arch = process.arch;
|
|
92
|
-
const platform = process.platform;
|
|
93
|
-
|
|
94
|
-
if (platform === 'linux') {
|
|
95
|
-
if (arch === 'x64') return 'cloudflared-linux-amd64';
|
|
96
|
-
if (arch === 'arm64') return 'cloudflared-linux-arm64';
|
|
97
|
-
}
|
|
98
|
-
if (platform === 'darwin') {
|
|
99
|
-
if (arch === 'x64') return 'cloudflared-darwin-amd64.tgz';
|
|
100
|
-
if (arch === 'arm64') return 'cloudflared-darwin-arm64.tgz';
|
|
101
|
-
}
|
|
102
|
-
if (platform === 'win32' && arch === 'x64') {
|
|
103
|
-
return 'cloudflared-windows-amd64.exe';
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
throw new Error(`cloudflared_unsupported_platform:${platform}-${arch}`);
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
function downloadFile(url, destination, redirectsLeft = 5) {
|
|
110
|
-
return new Promise((resolve, reject) => {
|
|
111
|
-
let parsed;
|
|
112
|
-
try {
|
|
113
|
-
parsed = new URL(url);
|
|
114
|
-
} catch (err) {
|
|
115
|
-
reject(new Error('invalid_download_url'));
|
|
116
|
-
return;
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
const client = parsed.protocol === 'https:' ? https : http;
|
|
120
|
-
const req = client.get({
|
|
121
|
-
protocol: parsed.protocol,
|
|
122
|
-
hostname: parsed.hostname,
|
|
123
|
-
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
124
|
-
path: parsed.pathname + parsed.search,
|
|
125
|
-
headers: {
|
|
126
|
-
'User-Agent': 'a2acalling/quick-tunnel'
|
|
127
|
-
}
|
|
128
|
-
}, (res) => {
|
|
129
|
-
const status = res.statusCode || 0;
|
|
130
|
-
if (status >= 300 && status < 400 && res.headers.location && redirectsLeft > 0) {
|
|
131
|
-
res.resume();
|
|
132
|
-
downloadFile(res.headers.location, destination, redirectsLeft - 1).then(resolve).catch(reject);
|
|
133
|
-
return;
|
|
134
|
-
}
|
|
135
|
-
if (status < 200 || status >= 300) {
|
|
136
|
-
res.resume();
|
|
137
|
-
reject(new Error(`download_failed_status_${status}`));
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const tmp = `${destination}.download`;
|
|
142
|
-
const out = fs.createWriteStream(tmp, { mode: 0o755 });
|
|
143
|
-
res.pipe(out);
|
|
144
|
-
out.on('finish', () => {
|
|
145
|
-
out.close(() => {
|
|
146
|
-
fs.renameSync(tmp, destination);
|
|
147
|
-
resolve(destination);
|
|
148
|
-
});
|
|
149
|
-
});
|
|
150
|
-
out.on('error', (err) => {
|
|
151
|
-
reject(err);
|
|
152
|
-
});
|
|
153
|
-
});
|
|
154
|
-
|
|
155
|
-
req.on('error', reject);
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function extractTarGz(archivePath, outputDir) {
|
|
160
|
-
return new Promise((resolve, reject) => {
|
|
161
|
-
const child = spawn('tar', ['-xzf', archivePath, '-C', outputDir], {
|
|
162
|
-
stdio: ['ignore', 'pipe', 'pipe']
|
|
163
|
-
});
|
|
164
|
-
let stderr = '';
|
|
165
|
-
child.stderr.on('data', chunk => { stderr += chunk.toString('utf8'); });
|
|
166
|
-
child.on('error', reject);
|
|
167
|
-
child.on('close', (code) => {
|
|
168
|
-
if (code === 0) resolve();
|
|
169
|
-
else reject(new Error(`tar_extract_failed:${stderr || code}`));
|
|
170
|
-
});
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
async function ensureCloudflaredBinary() {
|
|
175
|
-
if (process.env.A2A_CLOUDFLARED_BIN && isExecutable(process.env.A2A_CLOUDFLARED_BIN)) {
|
|
176
|
-
return { path: process.env.A2A_CLOUDFLARED_BIN, source: 'env' };
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
const systemPath = commandExists(process.platform === 'win32' ? 'cloudflared.exe' : 'cloudflared');
|
|
180
|
-
if (systemPath) {
|
|
181
|
-
return { path: systemPath, source: 'system' };
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
if (fs.existsSync(CLOUDFLARED_BIN) && isExecutable(CLOUDFLARED_BIN)) {
|
|
185
|
-
return { path: CLOUDFLARED_BIN, source: 'cached' };
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
ensureDir(BIN_DIR);
|
|
189
|
-
const asset = resolveCloudflaredAssetName();
|
|
190
|
-
const url = `https://github.com/cloudflare/cloudflared/releases/latest/download/${asset}`;
|
|
191
|
-
const downloadedPath = path.join(BIN_DIR, asset);
|
|
192
|
-
|
|
193
|
-
await downloadFile(url, downloadedPath);
|
|
194
|
-
|
|
195
|
-
if (asset.endsWith('.tgz')) {
|
|
196
|
-
await extractTarGz(downloadedPath, BIN_DIR);
|
|
197
|
-
try {
|
|
198
|
-
fs.unlinkSync(downloadedPath);
|
|
199
|
-
} catch (err) {
|
|
200
|
-
// Best effort.
|
|
201
|
-
}
|
|
202
|
-
} else if (downloadedPath !== CLOUDFLARED_BIN) {
|
|
203
|
-
fs.renameSync(downloadedPath, CLOUDFLARED_BIN);
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
if (!fs.existsSync(CLOUDFLARED_BIN)) {
|
|
207
|
-
throw new Error('cloudflared_install_failed');
|
|
208
|
-
}
|
|
209
|
-
try {
|
|
210
|
-
fs.chmodSync(CLOUDFLARED_BIN, 0o755);
|
|
211
|
-
} catch (err) {
|
|
212
|
-
// Best effort.
|
|
213
|
-
}
|
|
214
|
-
|
|
215
|
-
return { path: CLOUDFLARED_BIN, source: 'downloaded' };
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
function parseTryCloudflareUrl(line) {
|
|
219
|
-
const match = String(line || '').match(/https:\/\/[a-z0-9-]+\.trycloudflare\.com/i);
|
|
220
|
-
return match ? match[0] : null;
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
function readQuickTunnelState() {
|
|
224
|
-
return readJson(TUNNEL_STATE_FILE);
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
function normalizeHostFromUrl(urlText) {
|
|
228
|
-
try {
|
|
229
|
-
const parsed = new URL(urlText);
|
|
230
|
-
return parsed.host;
|
|
231
|
-
} catch (err) {
|
|
232
|
-
return '';
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
|
|
236
|
-
async function startQuickTunnel(options = {}) {
|
|
237
|
-
const localPort = Number.parseInt(String(options.localPort || 3001), 10) || 3001;
|
|
238
|
-
const targetUrl = options.targetUrl || `http://127.0.0.1:${localPort}`;
|
|
239
|
-
const timeoutMs = Number.parseInt(String(options.timeoutMs || 25000), 10) || 25000;
|
|
240
|
-
const binary = options.binaryPath || (await ensureCloudflaredBinary()).path;
|
|
241
|
-
|
|
242
|
-
const args = [
|
|
243
|
-
'tunnel',
|
|
244
|
-
'--url',
|
|
245
|
-
targetUrl,
|
|
246
|
-
'--no-autoupdate',
|
|
247
|
-
'--protocol',
|
|
248
|
-
'http2'
|
|
249
|
-
];
|
|
250
|
-
|
|
251
|
-
const child = spawn(binary, args, {
|
|
252
|
-
detached: true,
|
|
253
|
-
stdio: ['ignore', 'pipe', 'pipe']
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
return new Promise((resolve, reject) => {
|
|
257
|
-
let finished = false;
|
|
258
|
-
const timer = setTimeout(() => {
|
|
259
|
-
if (finished) return;
|
|
260
|
-
finished = true;
|
|
261
|
-
try { process.kill(child.pid); } catch (err) {}
|
|
262
|
-
reject(new Error('quick_tunnel_timeout'));
|
|
263
|
-
}, timeoutMs);
|
|
264
|
-
|
|
265
|
-
function handleLine(line) {
|
|
266
|
-
const url = parseTryCloudflareUrl(line);
|
|
267
|
-
if (!url || finished) return;
|
|
268
|
-
finished = true;
|
|
269
|
-
clearTimeout(timer);
|
|
270
|
-
|
|
271
|
-
const host = normalizeHostFromUrl(url);
|
|
272
|
-
const state = {
|
|
273
|
-
pid: child.pid,
|
|
274
|
-
url,
|
|
275
|
-
host,
|
|
276
|
-
local_port: localPort,
|
|
277
|
-
target_url: targetUrl,
|
|
278
|
-
started_at: new Date().toISOString(),
|
|
279
|
-
binary
|
|
280
|
-
};
|
|
281
|
-
writeJson(TUNNEL_STATE_FILE, state);
|
|
282
|
-
|
|
283
|
-
// Keep tunnel alive independently from caller process.
|
|
284
|
-
child.unref();
|
|
285
|
-
resolve({
|
|
286
|
-
url,
|
|
287
|
-
host,
|
|
288
|
-
pid: child.pid,
|
|
289
|
-
localPort,
|
|
290
|
-
source: 'started'
|
|
291
|
-
});
|
|
292
|
-
}
|
|
293
|
-
|
|
294
|
-
child.stdout.on('data', (chunk) => handleLine(chunk.toString('utf8')));
|
|
295
|
-
child.stderr.on('data', (chunk) => handleLine(chunk.toString('utf8')));
|
|
296
|
-
child.on('error', (err) => {
|
|
297
|
-
if (finished) return;
|
|
298
|
-
finished = true;
|
|
299
|
-
clearTimeout(timer);
|
|
300
|
-
reject(err);
|
|
301
|
-
});
|
|
302
|
-
child.on('exit', (code) => {
|
|
303
|
-
if (finished) return;
|
|
304
|
-
finished = true;
|
|
305
|
-
clearTimeout(timer);
|
|
306
|
-
reject(new Error(`quick_tunnel_exit_${code}`));
|
|
307
|
-
});
|
|
308
|
-
});
|
|
309
|
-
}
|
|
310
|
-
|
|
311
|
-
async function ensureQuickTunnel(options = {}) {
|
|
312
|
-
const localPort = Number.parseInt(String(options.localPort || 3001), 10) || 3001;
|
|
313
|
-
const state = readQuickTunnelState();
|
|
314
|
-
if (state && state.url && state.host && state.local_port === localPort && isProcessAlive(state.pid)) {
|
|
315
|
-
return {
|
|
316
|
-
url: state.url,
|
|
317
|
-
host: state.host,
|
|
318
|
-
pid: state.pid,
|
|
319
|
-
localPort,
|
|
320
|
-
source: 'state'
|
|
321
|
-
};
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
return startQuickTunnel({ ...options, localPort });
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
module.exports = {
|
|
328
|
-
TUNNEL_STATE_FILE,
|
|
329
|
-
ensureCloudflaredBinary,
|
|
330
|
-
ensureQuickTunnel,
|
|
331
|
-
readQuickTunnelState
|
|
332
|
-
};
|