@rine-network/cli 0.10.1 → 0.11.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/dist/main.js +434 -63
- package/package.json +2 -2
package/dist/main.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { createRequire } from "node:module";
|
|
2
2
|
import { Command } from "commander";
|
|
3
|
-
import { HttpClient, RineApiError, UUID_RE, addMlsGroupMember, agentKeysExist, cacheToken, decryptGroupMessage, decryptMessage, encryptGroupMessage, encryptMessage, encryptMlsGroupMessage, externalJoinMlsGroup, fetchAgents, fetchAndIngestPendingSKDistributions, fetchOAuthToken, fetchRecipientKeys, formatError, fromBase64Url, generateAgentKeys, generatePqKeyPair, getAgentPublicKeys, getCredentialEntry, getOrCreateSenderKey, getOrRefreshToken, ingestSenderKeyDistribution, initMlsGroup, isBareAgentName, loadAgentKeys, loadCredentials, loadMlsState, loadTokenCache, normalizeHandle, performAgentCreation, performRegistration, processMlsWelcomes, resolveAgent, resolveApiUrl, resolveConfigDir, resolveHandleViaWebFinger, resolveToUuid, saveAgentKeys, saveCredentials, savePqEncryptionKey, saveTokenCache, syncMlsGroup, toBase64Url, validateEncryptionKey, validateSigningKey, validateSlug } from "@rine-network/core";
|
|
3
|
+
import { HttpClient, RineApiError, UUID_RE, addMlsGroupMember, agentKeysExist, buildIssueCertDeps, cacheToken, connectTunnel, decryptGroupMessage, decryptMessage, deleteHookSecret, encryptGroupMessage, encryptMessage, encryptMlsGroupMessage, ensureCert, externalJoinMlsGroup, fetchAgents, fetchAndIngestPendingSKDistributions, fetchOAuthToken, fetchRecipientKeys, formatError, fromBase64Url, generateAgentKeys, generateHookSecret, generatePqKeyPair, getAgentPublicKeys, getCredentialEntry, getOrCreateSenderKey, getOrRefreshToken, handleWebhookRequest, ingestSenderKeyDistribution, initMlsGroup, isBareAgentName, isValidHookName, listMyInvites, loadAgentKeys, loadCredentials, loadHookSecret, loadMlsState, loadTokenCache, normalizeHandle, performAgentCreation, performRegistration, processMlsWelcomes, resolveAgent, resolveApiUrl, resolveConfigDir, resolveHandleViaWebFinger, resolveToUuid, revokeAndWipeCache, saveAgentKeys, saveCredentials, saveHookSecret, savePqEncryptionKey, saveTokenCache, sendGroupInviteNotification, startTlsListener, syncMlsGroup, toBase64Url, validateEncryptionKey, validateSigningKey, validateSlug } from "@rine-network/core";
|
|
4
4
|
import readline from "node:readline";
|
|
5
5
|
import * as fs from "node:fs";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
6
7
|
import { join } from "node:path";
|
|
7
8
|
import { createEventSource } from "eventsource-client";
|
|
8
9
|
//#region src/output.ts
|
|
@@ -711,6 +712,18 @@ async function addMemberToMls(client, configDir, apiUrl, groupId, memberAgentId,
|
|
|
711
712
|
if (!json) printText(`Warning: MLS add-member failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
712
713
|
}
|
|
713
714
|
}
|
|
715
|
+
async function sendInviteNotification(client, configDir, apiUrl, groupId, inviteeAgentId, message, as, json) {
|
|
716
|
+
try {
|
|
717
|
+
const group = await client.get(`/groups/${groupId}`);
|
|
718
|
+
await sendGroupInviteNotification(client, configDir, await resolveAgent(apiUrl, await fetchAgents(client), void 0, as), inviteeAgentId, {
|
|
719
|
+
group_id: groupId,
|
|
720
|
+
group_handle: group.handle,
|
|
721
|
+
message: message ?? null
|
|
722
|
+
});
|
|
723
|
+
} catch (err) {
|
|
724
|
+
if (!json) printText(`Warning: invite notification failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
714
727
|
function groupRow(g) {
|
|
715
728
|
return {
|
|
716
729
|
ID: g.id,
|
|
@@ -754,6 +767,15 @@ function requestRow(r) {
|
|
|
754
767
|
Created: r.created_at
|
|
755
768
|
};
|
|
756
769
|
}
|
|
770
|
+
function inviteRow(r) {
|
|
771
|
+
return {
|
|
772
|
+
Group: r.group_handle ?? r.group_name ?? "",
|
|
773
|
+
"Group ID": r.group_id,
|
|
774
|
+
"Invited By": r.invited_by ?? "",
|
|
775
|
+
Message: r.message ?? "",
|
|
776
|
+
Created: r.created_at
|
|
777
|
+
};
|
|
778
|
+
}
|
|
757
779
|
function registerGroup(program) {
|
|
758
780
|
const group = program.command("group").description("Group management");
|
|
759
781
|
group.command("create").description("Create a new group").requiredOption("--name <name>", "Group name (DNS-safe slug)").option("--enrollment <policy>", "Enrollment policy (open|closed|majority|unanimity)").option("--visibility <vis>", "Visibility (public|private)").option("--isolated", "Isolate group communication").option("--vote-duration <hours>", "Vote duration in hours (1-72)").action(withClient(program, async ({ client, gOpts, configDir, apiUrl }, opts) => {
|
|
@@ -852,6 +874,7 @@ function registerGroup(program) {
|
|
|
852
874
|
if (opts.message) body.message = opts.message;
|
|
853
875
|
const data = await client.post(`/groups/${groupId}/invite`, body);
|
|
854
876
|
await addMemberToMls(client, configDir, apiUrl, groupId, agentId, gOpts.as, gOpts.json);
|
|
877
|
+
await sendInviteNotification(client, configDir, apiUrl, groupId, agentId, opts.message, gOpts.as, gOpts.json);
|
|
855
878
|
if (gOpts.json) printJson(data);
|
|
856
879
|
else {
|
|
857
880
|
printText("Invitation sent");
|
|
@@ -863,6 +886,11 @@ function registerGroup(program) {
|
|
|
863
886
|
if (gOpts.json) printJson(data);
|
|
864
887
|
else printTable(data.items.map(requestRow));
|
|
865
888
|
}));
|
|
889
|
+
group.command("invites").description("List group invitations addressed to you").action(withClient(program, async ({ client, gOpts, extraHeaders }) => {
|
|
890
|
+
const items = await listMyInvites(client, extraHeaders);
|
|
891
|
+
if (gOpts.json) printJson(items);
|
|
892
|
+
else printTable(items.map(inviteRow));
|
|
893
|
+
}));
|
|
866
894
|
group.command("vote").description("Vote on a join request").argument("<group-id>", "Group ID").argument("<request-id>", "Join request ID").requiredOption("--vote <vote>", "Vote: approve or deny").action(withClient(program, async ({ client, gOpts, configDir, apiUrl }, groupId, requestId, opts) => {
|
|
867
895
|
if (opts.vote !== "approve" && opts.vote !== "deny") {
|
|
868
896
|
printError("--vote must be 'approve' or 'deny'");
|
|
@@ -878,6 +906,106 @@ function registerGroup(program) {
|
|
|
878
906
|
}));
|
|
879
907
|
}
|
|
880
908
|
//#endregion
|
|
909
|
+
//#region src/commands/hook.ts
|
|
910
|
+
const SIGNATURE_HEADER = "X-Hub-Signature-256";
|
|
911
|
+
const CONTENT_TYPE = "application/json";
|
|
912
|
+
function registerHook(program) {
|
|
913
|
+
const hook = program.command("hook").description("Manage inbound webhook funnel hooks");
|
|
914
|
+
hook.command("create").description("Allocate a funnel hook and print its GitHub setup (secret shown once)").option("--agent <id>", "Agent ID (defaults to your agent)").option("--name <name>", "Hook name (default: default)", "default").action(withClient(program, async ({ client, gOpts, apiUrl, configDir }, opts) => {
|
|
915
|
+
if (!isValidHookName(opts.name)) {
|
|
916
|
+
printError(`Invalid hook name '${opts.name}': use lowercase letters, digits and hyphens (max 32).`);
|
|
917
|
+
process.exitCode = 2;
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
const agentId = await resolveAgent(apiUrl, await fetchAgents(client), opts.agent, gOpts.as);
|
|
921
|
+
const secret = generateHookSecret();
|
|
922
|
+
saveHookSecret(configDir, agentId, opts.name, secret);
|
|
923
|
+
let created;
|
|
924
|
+
try {
|
|
925
|
+
created = await client.post(`/agents/${agentId}/funnel/hooks`, { hook_name: opts.name });
|
|
926
|
+
} catch (err) {
|
|
927
|
+
deleteHookSecret(configDir, agentId, opts.name);
|
|
928
|
+
throw err;
|
|
929
|
+
}
|
|
930
|
+
printCreated(created, opts.name, secret, gOpts.json);
|
|
931
|
+
}));
|
|
932
|
+
hook.command("list").description("List your agent's funnel hooks").option("--agent <id>", "Agent ID (defaults to your agent)").action(withClient(program, async ({ client, gOpts, apiUrl }, opts) => {
|
|
933
|
+
const agentId = await resolveAgent(apiUrl, await fetchAgents(client), opts.agent, gOpts.as);
|
|
934
|
+
const data = await client.get(`/agents/${agentId}/funnel/hooks`, {});
|
|
935
|
+
if (gOpts.json) printJson(data);
|
|
936
|
+
else printTable(data.items.map((h) => ({
|
|
937
|
+
Name: h.hook_name,
|
|
938
|
+
Hostname: h.hostname,
|
|
939
|
+
Active: h.active,
|
|
940
|
+
Created: h.created_at
|
|
941
|
+
})));
|
|
942
|
+
}));
|
|
943
|
+
hook.command("delete").description("Delete a funnel hook and purge its local secret").option("--agent <id>", "Agent ID (defaults to your agent)").requiredOption("--name <name>", "Hook name to delete").option("--yes", "Skip confirmation prompt").action(withClient(program, async ({ client, gOpts, apiUrl, configDir }, opts) => {
|
|
944
|
+
const agentId = await resolveAgent(apiUrl, await fetchAgents(client), opts.agent, gOpts.as);
|
|
945
|
+
if (!opts.yes && !gOpts.json) {
|
|
946
|
+
if (!await promptConfirm(`Delete funnel hook '${opts.name}'?`)) return;
|
|
947
|
+
}
|
|
948
|
+
try {
|
|
949
|
+
await client.delete(`/agents/${agentId}/funnel/hooks/${opts.name}`);
|
|
950
|
+
} catch (err) {
|
|
951
|
+
if (opts.yes && err instanceof RineApiError && err.status === 404) {
|
|
952
|
+
deleteHookSecret(configDir, agentId, opts.name);
|
|
953
|
+
printMutationOk("Funnel hook already absent", gOpts.json);
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
throw err;
|
|
957
|
+
}
|
|
958
|
+
deleteHookSecret(configDir, agentId, opts.name);
|
|
959
|
+
printMutationOk("Funnel hook deleted", gOpts.json);
|
|
960
|
+
}));
|
|
961
|
+
}
|
|
962
|
+
/** Print the print-once GitHub setup block (the secret is shown exactly once). */
|
|
963
|
+
function printCreated(created, name, secret, json) {
|
|
964
|
+
const publicUrl = `https://${created.hostname}/${name}`;
|
|
965
|
+
if (json) {
|
|
966
|
+
printJson({
|
|
967
|
+
hook: {
|
|
968
|
+
name: created.hook_name,
|
|
969
|
+
hostname: created.hostname,
|
|
970
|
+
agent_id: created.agent_id
|
|
971
|
+
},
|
|
972
|
+
public_url: publicUrl,
|
|
973
|
+
secret,
|
|
974
|
+
signature_header: SIGNATURE_HEADER,
|
|
975
|
+
content_type: CONTENT_TYPE
|
|
976
|
+
});
|
|
977
|
+
return;
|
|
978
|
+
}
|
|
979
|
+
printTable([
|
|
980
|
+
{
|
|
981
|
+
Field: "Hook",
|
|
982
|
+
Value: created.hook_name
|
|
983
|
+
},
|
|
984
|
+
{
|
|
985
|
+
Field: "Hostname",
|
|
986
|
+
Value: created.hostname
|
|
987
|
+
},
|
|
988
|
+
{
|
|
989
|
+
Field: "Payload URL",
|
|
990
|
+
Value: publicUrl
|
|
991
|
+
},
|
|
992
|
+
{
|
|
993
|
+
Field: "Content type",
|
|
994
|
+
Value: CONTENT_TYPE
|
|
995
|
+
},
|
|
996
|
+
{
|
|
997
|
+
Field: "Signature header",
|
|
998
|
+
Value: SIGNATURE_HEADER
|
|
999
|
+
},
|
|
1000
|
+
{
|
|
1001
|
+
Field: "Secret",
|
|
1002
|
+
Value: secret
|
|
1003
|
+
}
|
|
1004
|
+
]);
|
|
1005
|
+
console.log(`\nGitHub: Settings → Webhooks → Payload URL = ${publicUrl}, Content type = ${CONTENT_TYPE}, Secret = the secret above.`);
|
|
1006
|
+
console.log("Save this secret now — it is not stored on rine and cannot be retrieved.");
|
|
1007
|
+
}
|
|
1008
|
+
//#endregion
|
|
881
1009
|
//#region src/commands/keys.ts
|
|
882
1010
|
function getKeysDir(configDir, agentId) {
|
|
883
1011
|
return join(configDir, "keys", agentId);
|
|
@@ -1325,68 +1453,6 @@ function registerMessages(program) {
|
|
|
1325
1453
|
addMessageCommands(program.command("message").description("Message operations (aliases: send/read/inbox/reply)"), program);
|
|
1326
1454
|
}
|
|
1327
1455
|
//#endregion
|
|
1328
|
-
//#region src/commands/poll-token.ts
|
|
1329
|
-
function registerPollToken(program) {
|
|
1330
|
-
program.command("poll-token").description("Generate or revoke a poll token for inbox monitoring").option("--agent <id>", "Agent ID (auto-resolved for single-agent orgs)").option("--revoke", "Revoke the poll token").action(withClient(program, async ({ client, gOpts, profileName, configDir, apiUrl }, opts) => {
|
|
1331
|
-
const agentId = await resolveAgent(apiUrl, await fetchAgents(client), opts.agent, gOpts.as);
|
|
1332
|
-
if (opts.revoke) {
|
|
1333
|
-
await client.delete(`/agents/${agentId}/poll-token`);
|
|
1334
|
-
const creds = loadCredentials(configDir);
|
|
1335
|
-
if (creds[profileName]) {
|
|
1336
|
-
delete creds[profileName].poll_url;
|
|
1337
|
-
saveCredentials(configDir, creds);
|
|
1338
|
-
}
|
|
1339
|
-
printMutationOk("Poll token revoked", gOpts.json);
|
|
1340
|
-
return;
|
|
1341
|
-
}
|
|
1342
|
-
const data = await client.post(`/agents/${agentId}/poll-token`, {});
|
|
1343
|
-
const creds = loadCredentials(configDir);
|
|
1344
|
-
if (creds[profileName]) {
|
|
1345
|
-
creds[profileName].poll_url = data.poll_url;
|
|
1346
|
-
saveCredentials(configDir, creds);
|
|
1347
|
-
}
|
|
1348
|
-
if (gOpts.json) printJson(data);
|
|
1349
|
-
else {
|
|
1350
|
-
console.log(`Poll URL: ${data.poll_url}`);
|
|
1351
|
-
console.log("Credentials updated.");
|
|
1352
|
-
}
|
|
1353
|
-
}));
|
|
1354
|
-
}
|
|
1355
|
-
//#endregion
|
|
1356
|
-
//#region src/commands/org.ts
|
|
1357
|
-
function renderOrg(data, opts) {
|
|
1358
|
-
if (opts.json) {
|
|
1359
|
-
printJson(data);
|
|
1360
|
-
return;
|
|
1361
|
-
}
|
|
1362
|
-
printTable([{
|
|
1363
|
-
ID: data.id,
|
|
1364
|
-
Name: data.name,
|
|
1365
|
-
"Contact Email": data.contact_email ?? "",
|
|
1366
|
-
Country: data.country_code ?? "",
|
|
1367
|
-
Created: data.created_at
|
|
1368
|
-
}]);
|
|
1369
|
-
}
|
|
1370
|
-
function registerOrg(program) {
|
|
1371
|
-
const org = program.command("org").description("Organization management");
|
|
1372
|
-
org.command("get").description("Get organization details").action(withClient(program, async ({ client, gOpts }) => {
|
|
1373
|
-
renderOrg(await client.get("/org"), gOpts);
|
|
1374
|
-
}));
|
|
1375
|
-
org.command("update").description("Update organization details").option("--name <name>", "Organization name").option("--contact-email <email>", "Contact email address").option("--country-code <cc>", "ISO country code (e.g. DE)").action(withClient(program, async ({ client, gOpts }, opts) => {
|
|
1376
|
-
const body = {};
|
|
1377
|
-
if (opts.name) body.name = opts.name;
|
|
1378
|
-
if (opts.contactEmail) body.contact_email = opts.contactEmail;
|
|
1379
|
-
if (opts.countryCode) body.country_code = opts.countryCode;
|
|
1380
|
-
if (Object.keys(body).length === 0) {
|
|
1381
|
-
printError("At least one of --name, --contact-email, --country-code is required");
|
|
1382
|
-
process.exitCode = 2;
|
|
1383
|
-
return;
|
|
1384
|
-
}
|
|
1385
|
-
await client.patch("/org", body);
|
|
1386
|
-
printMutationOk("Org updated", gOpts.json);
|
|
1387
|
-
}));
|
|
1388
|
-
}
|
|
1389
|
-
//#endregion
|
|
1390
1456
|
//#region src/commands/onboard.ts
|
|
1391
1457
|
function registerOnboard(program) {
|
|
1392
1458
|
program.command("onboard").description("Register a new organization and create your first agent").requiredOption("--email <email>", "Email address").requiredOption("--name <name>", "Organization name").requiredOption("--slug <slug>", "Organization slug").requiredOption("--agent <agent-name>", "Name for the first agent").option("--[no-]human-oversight", "Require human oversight (default: true)").option("--unlisted", "Mark agent as unlisted (not in public directory)").action(withErrorHandler(program, async (gOpts, opts) => {
|
|
@@ -1452,6 +1518,68 @@ function registerOnboard(program) {
|
|
|
1452
1518
|
}));
|
|
1453
1519
|
}
|
|
1454
1520
|
//#endregion
|
|
1521
|
+
//#region src/commands/org.ts
|
|
1522
|
+
function renderOrg(data, opts) {
|
|
1523
|
+
if (opts.json) {
|
|
1524
|
+
printJson(data);
|
|
1525
|
+
return;
|
|
1526
|
+
}
|
|
1527
|
+
printTable([{
|
|
1528
|
+
ID: data.id,
|
|
1529
|
+
Name: data.name,
|
|
1530
|
+
"Contact Email": data.contact_email ?? "",
|
|
1531
|
+
Country: data.country_code ?? "",
|
|
1532
|
+
Created: data.created_at
|
|
1533
|
+
}]);
|
|
1534
|
+
}
|
|
1535
|
+
function registerOrg(program) {
|
|
1536
|
+
const org = program.command("org").description("Organization management");
|
|
1537
|
+
org.command("get").description("Get organization details").action(withClient(program, async ({ client, gOpts }) => {
|
|
1538
|
+
renderOrg(await client.get("/org"), gOpts);
|
|
1539
|
+
}));
|
|
1540
|
+
org.command("update").description("Update organization details").option("--name <name>", "Organization name").option("--contact-email <email>", "Contact email address").option("--country-code <cc>", "ISO country code (e.g. DE)").action(withClient(program, async ({ client, gOpts }, opts) => {
|
|
1541
|
+
const body = {};
|
|
1542
|
+
if (opts.name) body.name = opts.name;
|
|
1543
|
+
if (opts.contactEmail) body.contact_email = opts.contactEmail;
|
|
1544
|
+
if (opts.countryCode) body.country_code = opts.countryCode;
|
|
1545
|
+
if (Object.keys(body).length === 0) {
|
|
1546
|
+
printError("At least one of --name, --contact-email, --country-code is required");
|
|
1547
|
+
process.exitCode = 2;
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
1550
|
+
await client.patch("/org", body);
|
|
1551
|
+
printMutationOk("Org updated", gOpts.json);
|
|
1552
|
+
}));
|
|
1553
|
+
}
|
|
1554
|
+
//#endregion
|
|
1555
|
+
//#region src/commands/poll-token.ts
|
|
1556
|
+
function registerPollToken(program) {
|
|
1557
|
+
program.command("poll-token").description("Generate or revoke a poll token for inbox monitoring").option("--agent <id>", "Agent ID (auto-resolved for single-agent orgs)").option("--revoke", "Revoke the poll token").action(withClient(program, async ({ client, gOpts, profileName, configDir, apiUrl }, opts) => {
|
|
1558
|
+
const agentId = await resolveAgent(apiUrl, await fetchAgents(client), opts.agent, gOpts.as);
|
|
1559
|
+
if (opts.revoke) {
|
|
1560
|
+
await client.delete(`/agents/${agentId}/poll-token`);
|
|
1561
|
+
const creds = loadCredentials(configDir);
|
|
1562
|
+
if (creds[profileName]) {
|
|
1563
|
+
delete creds[profileName].poll_url;
|
|
1564
|
+
saveCredentials(configDir, creds);
|
|
1565
|
+
}
|
|
1566
|
+
printMutationOk("Poll token revoked", gOpts.json);
|
|
1567
|
+
return;
|
|
1568
|
+
}
|
|
1569
|
+
const data = await client.post(`/agents/${agentId}/poll-token`, {});
|
|
1570
|
+
const creds = loadCredentials(configDir);
|
|
1571
|
+
if (creds[profileName]) {
|
|
1572
|
+
creds[profileName].poll_url = data.poll_url;
|
|
1573
|
+
saveCredentials(configDir, creds);
|
|
1574
|
+
}
|
|
1575
|
+
if (gOpts.json) printJson(data);
|
|
1576
|
+
else {
|
|
1577
|
+
console.log(`Poll URL: ${data.poll_url}`);
|
|
1578
|
+
console.log("Credentials updated.");
|
|
1579
|
+
}
|
|
1580
|
+
}));
|
|
1581
|
+
}
|
|
1582
|
+
//#endregion
|
|
1455
1583
|
//#region src/commands/register.ts
|
|
1456
1584
|
function registerRegister(program) {
|
|
1457
1585
|
program.command("register").description("Register a new organization (two-step PoW flow)").requiredOption("--email <email>", "Email address").requiredOption("--name <name>", "Organization name").requiredOption("--slug <slug>", "Organization slug").action(withErrorHandler(program, async (gOpts, opts) => {
|
|
@@ -1477,6 +1605,247 @@ function registerRegister(program) {
|
|
|
1477
1605
|
}));
|
|
1478
1606
|
}
|
|
1479
1607
|
//#endregion
|
|
1608
|
+
//#region src/commands/relay.ts
|
|
1609
|
+
function emitLifecycle$1(json, data) {
|
|
1610
|
+
if (json) console.log(JSON.stringify({
|
|
1611
|
+
event: "lifecycle",
|
|
1612
|
+
data
|
|
1613
|
+
}));
|
|
1614
|
+
}
|
|
1615
|
+
function jitteredDelayMs$1(backoffSeconds) {
|
|
1616
|
+
return Math.round(backoffSeconds * (.5 + Math.random()) * 1e3);
|
|
1617
|
+
}
|
|
1618
|
+
function sleep(ms) {
|
|
1619
|
+
return new Promise((r) => setTimeout(r, ms));
|
|
1620
|
+
}
|
|
1621
|
+
/**
|
|
1622
|
+
* Parse --port into a valid TCP port. `0` is accepted as "OS-assign an ephemeral
|
|
1623
|
+
* port" (it is NOT coerced to the default — that was the L3 footgun); a
|
|
1624
|
+
* non-integer or out-of-range value throws a clear error rather than silently
|
|
1625
|
+
* falling back. The default flag value "8443" parses straight through.
|
|
1626
|
+
*/
|
|
1627
|
+
function parsePort(raw) {
|
|
1628
|
+
const port = Number(raw);
|
|
1629
|
+
if (!Number.isInteger(port) || port < 0 || port > 65535) throw new Error(`Invalid --port '${raw}': expected an integer 0-65535.`);
|
|
1630
|
+
return port;
|
|
1631
|
+
}
|
|
1632
|
+
function isRevoked(err) {
|
|
1633
|
+
return typeof err === "object" && err !== null && err.revoked === true;
|
|
1634
|
+
}
|
|
1635
|
+
/**
|
|
1636
|
+
* Resolve the HMAC secret: --secret-env > --secret-file > the persisted file.
|
|
1637
|
+
* Returns "" (and warns, naming the three sources tried) when none is found — an
|
|
1638
|
+
* empty secret can never match GitHub's real signature, so every request drops
|
|
1639
|
+
* safely rather than relaying an unverified body.
|
|
1640
|
+
*/
|
|
1641
|
+
function resolveSecret(opts, configDir, agentId) {
|
|
1642
|
+
if (opts.secretEnv) {
|
|
1643
|
+
const v = process.env[opts.secretEnv];
|
|
1644
|
+
if (v) return v.trim();
|
|
1645
|
+
}
|
|
1646
|
+
if (opts.secretFile) return readFileSync(opts.secretFile, "utf-8").trim();
|
|
1647
|
+
const stored = loadHookSecret(configDir, agentId, opts.hook);
|
|
1648
|
+
if (stored) return stored;
|
|
1649
|
+
process.stderr.write(`No hook secret found (tried --secret-env, --secret-file, and ${configDir}/funnel/${agentId}/${opts.hook}.secret) — all inbound requests will fail verification until one is provided.\n`);
|
|
1650
|
+
return "";
|
|
1651
|
+
}
|
|
1652
|
+
function registerRelay(program) {
|
|
1653
|
+
program.command("relay").description("Run the inbound webhook funnel relay (long-lived foreground daemon)").option("--agent <id>", "Agent ID (defaults to your agent)").option("--hook <name>", "Hook name to relay", "default").option("--port <n>", "Local TLS listener port", "8443").option("--secret-file <path>", "Read the HMAC secret from a file").option("--secret-env <VAR>", "Read the HMAC secret from an env var").option("--staging", "Use the Let's Encrypt staging CA (dev)").option("--verbose", "Verbose reconnect logging").action(async (opts) => {
|
|
1654
|
+
try {
|
|
1655
|
+
const gOpts = program.opts();
|
|
1656
|
+
const configDir = resolveConfigDir();
|
|
1657
|
+
const apiUrl = resolveApiUrl();
|
|
1658
|
+
const { client, profileName, entry } = await createClient(configDir, apiUrl, gOpts.profile);
|
|
1659
|
+
const agentId = await resolveAgent(apiUrl, await fetchAgents(client), opts.agent, gOpts.as);
|
|
1660
|
+
if (!agentKeysExist(configDir, agentId)) {
|
|
1661
|
+
printError(`Agent identity keys not found under ${configDir}/keys/${agentId}/ — register first.`);
|
|
1662
|
+
process.exitCode = 2;
|
|
1663
|
+
return;
|
|
1664
|
+
}
|
|
1665
|
+
await runRelayLoop({
|
|
1666
|
+
opts,
|
|
1667
|
+
gOpts,
|
|
1668
|
+
configDir,
|
|
1669
|
+
apiUrl,
|
|
1670
|
+
agentId,
|
|
1671
|
+
client,
|
|
1672
|
+
profileName,
|
|
1673
|
+
entry
|
|
1674
|
+
});
|
|
1675
|
+
} catch (err) {
|
|
1676
|
+
printError(err instanceof Error ? err.message : String(err));
|
|
1677
|
+
process.exitCode = 1;
|
|
1678
|
+
}
|
|
1679
|
+
});
|
|
1680
|
+
}
|
|
1681
|
+
async function runRelayLoop(ctx) {
|
|
1682
|
+
const { opts, gOpts, configDir, apiUrl, agentId, client, profileName, entry } = ctx;
|
|
1683
|
+
const json = gOpts.json;
|
|
1684
|
+
const port = parsePort(opts.port);
|
|
1685
|
+
const secret = resolveSecret(opts, configDir, agentId);
|
|
1686
|
+
const options = { staging: opts.staging };
|
|
1687
|
+
let backoff = 1;
|
|
1688
|
+
let attempt = 0;
|
|
1689
|
+
let stopped = false;
|
|
1690
|
+
let lastDeps;
|
|
1691
|
+
let certHandle;
|
|
1692
|
+
process.on("SIGINT", () => {
|
|
1693
|
+
stopped = true;
|
|
1694
|
+
process.exitCode = 0;
|
|
1695
|
+
});
|
|
1696
|
+
while (!stopped) {
|
|
1697
|
+
attempt++;
|
|
1698
|
+
let listener;
|
|
1699
|
+
try {
|
|
1700
|
+
const token = await getOrRefreshToken(configDir, apiUrl, entry, profileName, {
|
|
1701
|
+
force: false,
|
|
1702
|
+
envToken: process.env.RINE_TOKEN
|
|
1703
|
+
});
|
|
1704
|
+
const { hostname, control_ws_url: controlWsUrl } = await resolveHook(client, agentId, opts.hook);
|
|
1705
|
+
certHandle = hookLabel(hostname);
|
|
1706
|
+
lastDeps = await buildIssueCertDeps({
|
|
1707
|
+
configDir,
|
|
1708
|
+
options,
|
|
1709
|
+
http: client,
|
|
1710
|
+
agentId
|
|
1711
|
+
});
|
|
1712
|
+
const cert = await ensureCert({
|
|
1713
|
+
handle: certHandle,
|
|
1714
|
+
agentId,
|
|
1715
|
+
delegationId: certHandle,
|
|
1716
|
+
configDir,
|
|
1717
|
+
options,
|
|
1718
|
+
deps: lastDeps
|
|
1719
|
+
});
|
|
1720
|
+
emitLifecycle$1(json, {
|
|
1721
|
+
state: "cert_ready",
|
|
1722
|
+
hostname
|
|
1723
|
+
});
|
|
1724
|
+
const ref = {};
|
|
1725
|
+
const tunnel = await connectTunnel({
|
|
1726
|
+
controlWsUrl,
|
|
1727
|
+
token,
|
|
1728
|
+
agentId,
|
|
1729
|
+
hostname,
|
|
1730
|
+
openLocalPipe: (frame) => {
|
|
1731
|
+
if (!ref.live) throw new Error("listener not ready");
|
|
1732
|
+
return ref.live.openLocalPipe(frame);
|
|
1733
|
+
}
|
|
1734
|
+
});
|
|
1735
|
+
emitLifecycle$1(json, { state: "tunnel_connected" });
|
|
1736
|
+
const live = await startTlsListener({
|
|
1737
|
+
port,
|
|
1738
|
+
certPem: cert.certPem,
|
|
1739
|
+
keyPem: cert.keyPem,
|
|
1740
|
+
onRequest: (req) => handleWebhookRequest({
|
|
1741
|
+
configDir,
|
|
1742
|
+
agentId,
|
|
1743
|
+
hookName: opts.hook,
|
|
1744
|
+
secret,
|
|
1745
|
+
client,
|
|
1746
|
+
emitLifecycle: (ev) => json && console.log(JSON.stringify(ev))
|
|
1747
|
+
}, req).then(() => void 0)
|
|
1748
|
+
});
|
|
1749
|
+
listener = live;
|
|
1750
|
+
ref.live = live;
|
|
1751
|
+
emitLifecycle$1(json, {
|
|
1752
|
+
state: "listener_ready",
|
|
1753
|
+
port: live.port
|
|
1754
|
+
});
|
|
1755
|
+
backoff = 1;
|
|
1756
|
+
const close = await waitForClose(tunnel, () => stopped);
|
|
1757
|
+
tunnel.close();
|
|
1758
|
+
live.close();
|
|
1759
|
+
if (stopped) break;
|
|
1760
|
+
if (close?.clean !== false) {
|
|
1761
|
+
emitLifecycle$1(json, {
|
|
1762
|
+
state: "tunnel_reconnecting",
|
|
1763
|
+
attempt: attempt + 1,
|
|
1764
|
+
backoff_ms: 0,
|
|
1765
|
+
reason: "server_close"
|
|
1766
|
+
});
|
|
1767
|
+
backoff = 1;
|
|
1768
|
+
} else {
|
|
1769
|
+
const delay = jitteredDelayMs$1(backoff);
|
|
1770
|
+
emitLifecycle$1(json, {
|
|
1771
|
+
state: "tunnel_reconnecting",
|
|
1772
|
+
attempt: attempt + 1,
|
|
1773
|
+
backoff_ms: delay,
|
|
1774
|
+
reason: "error"
|
|
1775
|
+
});
|
|
1776
|
+
await sleep(delay);
|
|
1777
|
+
backoff = Math.min(backoff * 2, 30);
|
|
1778
|
+
}
|
|
1779
|
+
} catch (err) {
|
|
1780
|
+
listener?.close();
|
|
1781
|
+
if (stopped) break;
|
|
1782
|
+
if (isRevoked(err)) {
|
|
1783
|
+
emitLifecycle$1(json, { state: "tunnel_revoked" });
|
|
1784
|
+
if (lastDeps && certHandle) await revokeAndWipeCache({
|
|
1785
|
+
handle: certHandle,
|
|
1786
|
+
configDir,
|
|
1787
|
+
options,
|
|
1788
|
+
deps: lastDeps
|
|
1789
|
+
}).catch(() => {});
|
|
1790
|
+
process.exitCode = 1;
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
const delay = jitteredDelayMs$1(backoff);
|
|
1794
|
+
emitLifecycle$1(json, {
|
|
1795
|
+
state: "tunnel_reconnecting",
|
|
1796
|
+
attempt: attempt + 1,
|
|
1797
|
+
backoff_ms: delay,
|
|
1798
|
+
reason: "error"
|
|
1799
|
+
});
|
|
1800
|
+
if (opts.verbose) process.stderr.write(`Reconnecting (attempt ${attempt + 1}, error): ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`);
|
|
1801
|
+
await sleep(delay);
|
|
1802
|
+
backoff = Math.min(backoff * 2, 30);
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
emitLifecycle$1(json, {
|
|
1806
|
+
state: "stopped",
|
|
1807
|
+
reason: "signal"
|
|
1808
|
+
});
|
|
1809
|
+
}
|
|
1810
|
+
/**
|
|
1811
|
+
* Resolve when the tunnel WS closes (carrying the classified close so the caller
|
|
1812
|
+
* can pick clean-vs-error reconnect) OR a SIGINT arrives (resolves `undefined`;
|
|
1813
|
+
* the caller's `stopped` flag short-circuits before the reconnect branch).
|
|
1814
|
+
*/
|
|
1815
|
+
function waitForClose(tunnel, isStopped) {
|
|
1816
|
+
return new Promise((resolve) => {
|
|
1817
|
+
if (isStopped()) return resolve(void 0);
|
|
1818
|
+
tunnel.onClose((close) => resolve(close));
|
|
1819
|
+
process.once("SIGINT", () => {
|
|
1820
|
+
tunnel.close();
|
|
1821
|
+
resolve(void 0);
|
|
1822
|
+
});
|
|
1823
|
+
});
|
|
1824
|
+
}
|
|
1825
|
+
/**
|
|
1826
|
+
* The server hook row — the SINGLE source of truth, used VERBATIM. Its
|
|
1827
|
+
* `hostname` (the `{hook}--{slug}.hook.rine.network` form) goes into the BIND and
|
|
1828
|
+
* (via its label) the cert CN/SAN; its `control_ws_url` is the broker control-WS
|
|
1829
|
+
* endpoint the relay dials. NEITHER is reconstructed client-side: a fabricated
|
|
1830
|
+
* `<uuid>.hook.rine.network` matches no funnel_hook row (BIND would NAK, the cert
|
|
1831
|
+
* would never match GitHub's TLS), and the API base resolves to the primary IP /
|
|
1832
|
+
* Caddy, which has no funnel route. On a missing row or a transient lookup
|
|
1833
|
+
* failure we THROW so the loop backs off and retries.
|
|
1834
|
+
*/
|
|
1835
|
+
async function resolveHook(client, agentId, hookName) {
|
|
1836
|
+
const hook = (await client.get(`/agents/${agentId}/funnel/hooks`, {}))?.items?.find((h) => h.hook_name === hookName);
|
|
1837
|
+
if (!hook?.hostname || !hook.control_ws_url) throw new Error(`No funnel hook '${hookName}' found for agent ${agentId} — run \`rine hook create --name ${hookName}\` first.`);
|
|
1838
|
+
return hook;
|
|
1839
|
+
}
|
|
1840
|
+
/**
|
|
1841
|
+
* The DNS LABEL of a hook hostname — everything before `.hook.rine.network`. The
|
|
1842
|
+
* cert is issued for this label via hookFqdn(label), so the leaf's CN/SAN equals
|
|
1843
|
+
* the public hostname GitHub connects to (no name mismatch on the TLS handshake).
|
|
1844
|
+
*/
|
|
1845
|
+
function hookLabel(hostname) {
|
|
1846
|
+
return hostname.endsWith(".hook.rine.network") ? hostname.slice(0, -18) : hostname;
|
|
1847
|
+
}
|
|
1848
|
+
//#endregion
|
|
1480
1849
|
//#region src/commands/stream.ts
|
|
1481
1850
|
function emitLifecycle(gOpts, monitor, data) {
|
|
1482
1851
|
if (gOpts.json) {
|
|
@@ -1762,8 +2131,10 @@ registerMessages(program);
|
|
|
1762
2131
|
registerGroup(program);
|
|
1763
2132
|
registerDiscover(program);
|
|
1764
2133
|
registerWebhook(program);
|
|
2134
|
+
registerHook(program);
|
|
1765
2135
|
registerKeys(program);
|
|
1766
2136
|
registerStream(program);
|
|
2137
|
+
registerRelay(program);
|
|
1767
2138
|
registerPollToken(program);
|
|
1768
2139
|
program.parse();
|
|
1769
2140
|
//#endregion
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@rine-network/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.11.0",
|
|
4
4
|
"description": "CLI client for rine.network \u2014 EU-first messaging infrastructure for AI agents",
|
|
5
5
|
"author": "mmmbs <mmmbs@proton.me>",
|
|
6
6
|
"license": "EUPL-1.2",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"dev": "tsx src/main.ts"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@rine-network/core": "^0.
|
|
31
|
+
"@rine-network/core": "^0.6.0",
|
|
32
32
|
"commander": "^12.0.0",
|
|
33
33
|
"eventsource-client": "^1.1.0"
|
|
34
34
|
},
|