@naisys/hub 3.0.0-beta.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/naisys-hub +2 -0
- package/dist/handlers/hubAccessKeyService.js +28 -0
- package/dist/handlers/hubAgentService.js +238 -0
- package/dist/handlers/hubAttachmentService.js +227 -0
- package/dist/handlers/hubConfigService.js +85 -0
- package/dist/handlers/hubCostService.js +279 -0
- package/dist/handlers/hubHeartbeatService.js +132 -0
- package/dist/handlers/hubHostService.js +35 -0
- package/dist/handlers/hubLogService.js +121 -0
- package/dist/handlers/hubMailService.js +397 -0
- package/dist/handlers/hubModelsService.js +106 -0
- package/dist/handlers/hubRunService.js +96 -0
- package/dist/handlers/hubSendMailService.js +126 -0
- package/dist/handlers/hubUserService.js +62 -0
- package/dist/naisysHub.js +129 -0
- package/dist/services/agentRegistrar.js +53 -0
- package/dist/services/certService.js +78 -0
- package/dist/services/hostRegistrar.js +82 -0
- package/dist/services/hubServerLog.js +47 -0
- package/dist/services/naisysConnection.js +45 -0
- package/dist/services/naisysServer.js +164 -0
- package/package.json +64 -0
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { HubEvents, } from "@naisys/hub-protocol";
|
|
2
|
+
/** Pure send-mail service with no auto-start logic, breaking the circular dependency */
|
|
3
|
+
export function createHubSendMailService(naisysServer, { hubDb }, heartbeatService) {
|
|
4
|
+
/** Send a mail message directly by user IDs */
|
|
5
|
+
async function sendMail(params) {
|
|
6
|
+
const now = new Date();
|
|
7
|
+
// Build participants string from usernames (sorted alphabetically)
|
|
8
|
+
const allUserIds = [
|
|
9
|
+
...new Set([params.fromUserId, ...params.recipientUserIds]),
|
|
10
|
+
];
|
|
11
|
+
const users = await hubDb.users.findMany({
|
|
12
|
+
where: { id: { in: allUserIds } },
|
|
13
|
+
select: { username: true },
|
|
14
|
+
});
|
|
15
|
+
const participants = users
|
|
16
|
+
.map((u) => u.username)
|
|
17
|
+
.sort()
|
|
18
|
+
.join(",");
|
|
19
|
+
// Atomic transaction: create message, link attachments, add recipients, update notifications
|
|
20
|
+
const message = await hubDb.$transaction(async (hubTx) => {
|
|
21
|
+
const msg = await hubTx.mail_messages.create({
|
|
22
|
+
data: {
|
|
23
|
+
from_user_id: params.fromUserId,
|
|
24
|
+
host_id: params.hostId,
|
|
25
|
+
kind: params.kind,
|
|
26
|
+
participants,
|
|
27
|
+
subject: params.subject,
|
|
28
|
+
body: params.body,
|
|
29
|
+
created_at: now,
|
|
30
|
+
},
|
|
31
|
+
});
|
|
32
|
+
// Link uploaded attachments to the new message via junction table
|
|
33
|
+
if (params.attachmentIds?.length) {
|
|
34
|
+
// Verify all attachment IDs exist
|
|
35
|
+
const found = await hubTx.attachments.findMany({
|
|
36
|
+
where: { id: { in: params.attachmentIds } },
|
|
37
|
+
select: { id: true },
|
|
38
|
+
});
|
|
39
|
+
if (found.length !== params.attachmentIds.length) {
|
|
40
|
+
const foundIds = new Set(found.map((a) => a.id));
|
|
41
|
+
const missing = params.attachmentIds.filter((id) => !foundIds.has(id));
|
|
42
|
+
throw new Error(`Attachments not found: ${missing.join(", ")}`);
|
|
43
|
+
}
|
|
44
|
+
await hubTx.mail_attachments.createMany({
|
|
45
|
+
data: params.attachmentIds.map((attId) => ({
|
|
46
|
+
message_id: msg.id,
|
|
47
|
+
attachment_id: attId,
|
|
48
|
+
})),
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
await hubTx.mail_recipients.createMany({
|
|
52
|
+
data: params.recipientUserIds.map((userId) => ({
|
|
53
|
+
message_id: msg.id,
|
|
54
|
+
user_id: userId,
|
|
55
|
+
type: "to",
|
|
56
|
+
created_at: now,
|
|
57
|
+
})),
|
|
58
|
+
});
|
|
59
|
+
// Add sender as 'from' recipient for archive tracking (pre-read since they wrote it)
|
|
60
|
+
if (!params.recipientUserIds.includes(params.fromUserId)) {
|
|
61
|
+
await hubTx.mail_recipients.create({
|
|
62
|
+
data: {
|
|
63
|
+
message_id: msg.id,
|
|
64
|
+
user_id: params.fromUserId,
|
|
65
|
+
type: "from",
|
|
66
|
+
read_at: now,
|
|
67
|
+
created_at: now,
|
|
68
|
+
},
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const notificationField = params.kind === "chat" ? "latest_chat_id" : "latest_mail_id";
|
|
72
|
+
await hubTx.user_notifications.updateMany({
|
|
73
|
+
where: { user_id: { in: params.recipientUserIds } },
|
|
74
|
+
data: { [notificationField]: msg.id },
|
|
75
|
+
});
|
|
76
|
+
return msg;
|
|
77
|
+
});
|
|
78
|
+
const heartbeatField = params.kind === "chat" ? "latestChatId" : "latestMailId";
|
|
79
|
+
for (const userId of params.recipientUserIds) {
|
|
80
|
+
heartbeatService.updateAgentNotification(userId, heartbeatField, message.id);
|
|
81
|
+
}
|
|
82
|
+
heartbeatService.throttledPushAgentsStatus();
|
|
83
|
+
const targetHostIds = new Set();
|
|
84
|
+
for (const userId of params.recipientUserIds) {
|
|
85
|
+
for (const hId of heartbeatService.findHostsForAgent(userId)) {
|
|
86
|
+
targetHostIds.add(hId);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
if (targetHostIds.size > 0) {
|
|
90
|
+
const payload = {
|
|
91
|
+
recipientUserIds: params.recipientUserIds,
|
|
92
|
+
kind: params.kind,
|
|
93
|
+
};
|
|
94
|
+
for (const targetHostId of targetHostIds) {
|
|
95
|
+
naisysServer.sendMessage(targetHostId, HubEvents.MAIL_RECEIVED, payload);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
// Query attachment metadata for the push if there are attachments
|
|
99
|
+
let attachments;
|
|
100
|
+
if (params.attachmentIds?.length) {
|
|
101
|
+
const rows = await hubDb.attachments.findMany({
|
|
102
|
+
where: { id: { in: params.attachmentIds } },
|
|
103
|
+
select: { public_id: true, filename: true, file_size: true },
|
|
104
|
+
});
|
|
105
|
+
attachments = rows.map((r) => ({
|
|
106
|
+
id: r.public_id,
|
|
107
|
+
filename: r.filename,
|
|
108
|
+
fileSize: r.file_size,
|
|
109
|
+
}));
|
|
110
|
+
}
|
|
111
|
+
// Push full message data to supervisor connections
|
|
112
|
+
naisysServer.broadcastToSupervisors(HubEvents.MAIL_PUSH, {
|
|
113
|
+
recipientUserIds: params.recipientUserIds,
|
|
114
|
+
fromUserId: params.fromUserId,
|
|
115
|
+
kind: params.kind,
|
|
116
|
+
messageId: message.id,
|
|
117
|
+
subject: params.subject,
|
|
118
|
+
body: params.body,
|
|
119
|
+
createdAt: now.toISOString(),
|
|
120
|
+
participants,
|
|
121
|
+
attachments,
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
return { sendMail };
|
|
125
|
+
}
|
|
126
|
+
//# sourceMappingURL=hubSendMailService.js.map
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { HubEvents } from "@naisys/hub-protocol";
|
|
2
|
+
/** Pushes the user list to NAISYS instances when they connect or when users change */
|
|
3
|
+
export function createHubUserService(naisysServer, { hubDb }, logService) {
|
|
4
|
+
async function buildUserListPayload() {
|
|
5
|
+
const dbUsers = await hubDb.users.findMany({
|
|
6
|
+
where: { archived: false },
|
|
7
|
+
select: {
|
|
8
|
+
id: true,
|
|
9
|
+
username: true,
|
|
10
|
+
enabled: true,
|
|
11
|
+
config: true,
|
|
12
|
+
lead_user_id: true,
|
|
13
|
+
api_key: true,
|
|
14
|
+
user_hosts: {
|
|
15
|
+
select: { host_id: true },
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
});
|
|
19
|
+
const users = dbUsers.map((u) => ({
|
|
20
|
+
userId: u.id,
|
|
21
|
+
username: u.username,
|
|
22
|
+
enabled: u.enabled,
|
|
23
|
+
leadUserId: u.lead_user_id || undefined,
|
|
24
|
+
config: JSON.parse(u.config),
|
|
25
|
+
assignedHostIds: u.user_hosts.length > 0
|
|
26
|
+
? u.user_hosts.map((uh) => uh.host_id)
|
|
27
|
+
: undefined,
|
|
28
|
+
apiKey: u.api_key || undefined,
|
|
29
|
+
}));
|
|
30
|
+
return { success: true, users };
|
|
31
|
+
}
|
|
32
|
+
async function broadcastUserList() {
|
|
33
|
+
try {
|
|
34
|
+
const payload = await buildUserListPayload();
|
|
35
|
+
logService.log(`[Hub:Users] Broadcasting ${payload.users?.length ?? 0} users to all clients`);
|
|
36
|
+
naisysServer.broadcastToAll(HubEvents.USERS_UPDATED, payload);
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
logService.error(`[Hub:Users] Error broadcasting user list: ${error}`);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
// Push user list to newly connected clients
|
|
43
|
+
naisysServer.registerEvent(HubEvents.CLIENT_CONNECTED, async (hostId, connection) => {
|
|
44
|
+
try {
|
|
45
|
+
const payload = await buildUserListPayload();
|
|
46
|
+
logService.log(`[Hub:Users] Pushing ${payload.users?.length ?? 0} users to instance ${hostId}`);
|
|
47
|
+
connection.sendMessage(HubEvents.USERS_UPDATED, payload);
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
logService.error(`[Hub:Users] Error querying users for instance ${hostId}: ${error}`);
|
|
51
|
+
connection.sendMessage(HubEvents.USERS_UPDATED, {
|
|
52
|
+
success: false,
|
|
53
|
+
error: String(error),
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
});
|
|
57
|
+
// Broadcast user list to all clients when users are created/edited
|
|
58
|
+
naisysServer.registerEvent(HubEvents.USERS_CHANGED, async () => {
|
|
59
|
+
await broadcastUserList();
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
//# sourceMappingURL=hubUserService.js.map
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { expandNaisysFolder } from "@naisys/common-node";
|
|
2
|
+
import { createHubDatabaseService } from "@naisys/hub-database";
|
|
3
|
+
import { program } from "commander";
|
|
4
|
+
import dotenv from "dotenv";
|
|
5
|
+
import https from "https";
|
|
6
|
+
import { Server } from "socket.io";
|
|
7
|
+
import { fileURLToPath } from "url";
|
|
8
|
+
import { createHubAccessKeyService } from "./handlers/hubAccessKeyService.js";
|
|
9
|
+
import { createHubAgentService } from "./handlers/hubAgentService.js";
|
|
10
|
+
import { createHubAttachmentService } from "./handlers/hubAttachmentService.js";
|
|
11
|
+
import { createHubConfigService } from "./handlers/hubConfigService.js";
|
|
12
|
+
import { createHubCostService } from "./handlers/hubCostService.js";
|
|
13
|
+
import { createHubHeartbeatService } from "./handlers/hubHeartbeatService.js";
|
|
14
|
+
import { createHubHostService } from "./handlers/hubHostService.js";
|
|
15
|
+
import { createHubLogService } from "./handlers/hubLogService.js";
|
|
16
|
+
import { createHubMailService } from "./handlers/hubMailService.js";
|
|
17
|
+
import { createHubModelsService } from "./handlers/hubModelsService.js";
|
|
18
|
+
import { createHubRunService } from "./handlers/hubRunService.js";
|
|
19
|
+
import { createHubSendMailService } from "./handlers/hubSendMailService.js";
|
|
20
|
+
import { createHubUserService } from "./handlers/hubUserService.js";
|
|
21
|
+
import { seedAgentConfigs } from "./services/agentRegistrar.js";
|
|
22
|
+
import { loadOrCreateCert } from "./services/certService.js";
|
|
23
|
+
import { createHostRegistrar } from "./services/hostRegistrar.js";
|
|
24
|
+
import { createHubServerLog } from "./services/hubServerLog.js";
|
|
25
|
+
import { createNaisysServer } from "./services/naisysServer.js";
|
|
26
|
+
/**
|
|
27
|
+
* Starts the Hub server with sync service.
|
|
28
|
+
* Can be called standalone or inline from naisys with --integrated-hub flag.
|
|
29
|
+
*/
|
|
30
|
+
export const startHub = async (startupType, startSupervisor, plugins, startupAgentPath) => {
|
|
31
|
+
try {
|
|
32
|
+
// Create log service first
|
|
33
|
+
const logService = createHubServerLog(startupType);
|
|
34
|
+
logService.log(`[Hub] Starting Hub server in ${startupType} mode...`);
|
|
35
|
+
const hubPort = Number(process.env.HUB_PORT) || 3101;
|
|
36
|
+
// Load or generate self-signed TLS cert and access key
|
|
37
|
+
const certInfo = await loadOrCreateCert();
|
|
38
|
+
const naisysFolder = process.env.NAISYS_FOLDER || "";
|
|
39
|
+
logService.log(`[Hub] Hub access key located at: ${naisysFolder}/cert/hub-access-key`);
|
|
40
|
+
// Schema version for sync protocol - should match NAISYS instance
|
|
41
|
+
const hubDatabaseService = await createHubDatabaseService();
|
|
42
|
+
// Seed database with agent configs from yaml files (one-time, skips if non-empty)
|
|
43
|
+
await seedAgentConfigs(hubDatabaseService, logService, startupAgentPath);
|
|
44
|
+
// Create host registrar for tracking NAISYS instance connections
|
|
45
|
+
const hostRegistrar = await createHostRegistrar(hubDatabaseService);
|
|
46
|
+
// Create shared HTTPS server and Socket.IO instance
|
|
47
|
+
const httpsServer = https.createServer({
|
|
48
|
+
key: certInfo.key,
|
|
49
|
+
cert: certInfo.cert,
|
|
50
|
+
});
|
|
51
|
+
// Register HTTP attachment upload/download handler before Socket.IO
|
|
52
|
+
createHubAttachmentService(httpsServer, hubDatabaseService, logService);
|
|
53
|
+
const io = new Server(httpsServer, {
|
|
54
|
+
cors: {
|
|
55
|
+
origin: "*", // In production, restrict this
|
|
56
|
+
methods: ["GET", "POST"],
|
|
57
|
+
},
|
|
58
|
+
});
|
|
59
|
+
// Create NAISYS server on /naisys namespace
|
|
60
|
+
const naisysServer = createNaisysServer(io.of("/naisys"), certInfo.hubAccessKey, logService, hostRegistrar);
|
|
61
|
+
// Register hub access key rotation handler
|
|
62
|
+
createHubAccessKeyService(naisysServer, logService, certInfo.cert);
|
|
63
|
+
// Register hub config service for config_get requests from NAISYS instances
|
|
64
|
+
const configService = await createHubConfigService(naisysServer, hubDatabaseService, logService);
|
|
65
|
+
// Register hub user service for user_list requests from NAISYS instances
|
|
66
|
+
createHubUserService(naisysServer, hubDatabaseService, logService);
|
|
67
|
+
// Register hub models service for seeding and broadcasting models
|
|
68
|
+
await createHubModelsService(naisysServer, hubDatabaseService, logService);
|
|
69
|
+
// Register hub host service for broadcasting connected host list
|
|
70
|
+
createHubHostService(naisysServer, hostRegistrar, logService);
|
|
71
|
+
// Register hub run service for session_create/session_increment requests
|
|
72
|
+
createHubRunService(naisysServer, hubDatabaseService, logService);
|
|
73
|
+
// Register hub heartbeat service for NAISYS instance heartbeat tracking
|
|
74
|
+
const heartbeatService = createHubHeartbeatService(naisysServer, hubDatabaseService, logService);
|
|
75
|
+
// Register hub log service for log_write events from NAISYS instances
|
|
76
|
+
createHubLogService(naisysServer, hubDatabaseService, logService, heartbeatService);
|
|
77
|
+
// Register hub cost service for cost_write events from NAISYS instances
|
|
78
|
+
const costService = createHubCostService(naisysServer, hubDatabaseService, logService, heartbeatService, configService);
|
|
79
|
+
// Register hub send mail service (pure mail sending, no auto-start logic)
|
|
80
|
+
const sendMailService = createHubSendMailService(naisysServer, hubDatabaseService, heartbeatService);
|
|
81
|
+
// Register hub agent service for agent_start requests routed to target hosts
|
|
82
|
+
const agentService = createHubAgentService(naisysServer, hubDatabaseService, logService, heartbeatService, sendMailService, hostRegistrar);
|
|
83
|
+
// Register hub mail service for mail events from NAISYS instances
|
|
84
|
+
createHubMailService(naisysServer, hubDatabaseService, logService, heartbeatService, sendMailService, agentService, costService, configService);
|
|
85
|
+
// Start listening
|
|
86
|
+
await new Promise((resolve, reject) => {
|
|
87
|
+
httpsServer.once("error", reject);
|
|
88
|
+
httpsServer.listen(hubPort, () => {
|
|
89
|
+
httpsServer.removeListener("error", reject);
|
|
90
|
+
logService.log(`[Hub] Server listening on port ${hubPort}`);
|
|
91
|
+
resolve();
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
logService.log(`[Hub] Running on wss://localhost:${hubPort}, logs written to file`);
|
|
95
|
+
logService.disableConsole();
|
|
96
|
+
/**
|
|
97
|
+
* There should be no dependency between supervisor and hub
|
|
98
|
+
* Sharing the same process space is to save 150 mb of node.js runtime memory on small servers
|
|
99
|
+
*/
|
|
100
|
+
let supervisorPort;
|
|
101
|
+
if (startSupervisor) {
|
|
102
|
+
// Don't import the whole fastify web server module tree unless needed
|
|
103
|
+
// Use variable to avoid compile-time type dependency on @naisys/supervisor (allows parallel builds)
|
|
104
|
+
const supervisorModule = "@naisys/supervisor";
|
|
105
|
+
const { startServer } = (await import(supervisorModule));
|
|
106
|
+
supervisorPort = await startServer("hosted", plugins, hubPort);
|
|
107
|
+
}
|
|
108
|
+
return { hubPort, supervisorPort };
|
|
109
|
+
}
|
|
110
|
+
catch (err) {
|
|
111
|
+
console.error("[Hub] Failed to start hub server:", err);
|
|
112
|
+
process.exit(1);
|
|
113
|
+
}
|
|
114
|
+
};
|
|
115
|
+
// Start server if this file is run directly
|
|
116
|
+
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
117
|
+
dotenv.config({ quiet: true });
|
|
118
|
+
expandNaisysFolder();
|
|
119
|
+
program
|
|
120
|
+
.argument("[agent-path]", "Path to agent configuration file to seed the database (optional)")
|
|
121
|
+
.option("--supervisor", "Start Supervisor web server")
|
|
122
|
+
.option("--erp", "Start ERP web app (requires --supervisor)")
|
|
123
|
+
.parse();
|
|
124
|
+
const plugins = [];
|
|
125
|
+
if (program.opts().erp)
|
|
126
|
+
plugins.push("erp");
|
|
127
|
+
void startHub("standalone", program.opts().supervisor, plugins, program.args[0]);
|
|
128
|
+
}
|
|
129
|
+
//# sourceMappingURL=naisysHub.js.map
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { toUrlSafeKey } from "@naisys/common";
|
|
2
|
+
import { loadAgentConfigs } from "@naisys/common-node";
|
|
3
|
+
import { randomBytes, randomUUID } from "crypto";
|
|
4
|
+
/** Seeds agent configs from YAML files into an empty database. Skips if users already exist. */
|
|
5
|
+
export async function seedAgentConfigs({ hubDb }, logService, startupAgentPath) {
|
|
6
|
+
// Check if users table already has rows (seed-once pattern)
|
|
7
|
+
const count = await hubDb.users.count();
|
|
8
|
+
const hasUsers = count > 0;
|
|
9
|
+
if (hasUsers) {
|
|
10
|
+
logService.log("[Hub:AgentRegistrar] Agents already seeded");
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
// Default to CWD when no path specified (matches standalone hub behavior)
|
|
14
|
+
const users = loadAgentConfigs(startupAgentPath || "");
|
|
15
|
+
await seedUsersToDatabase(hubDb, logService, users);
|
|
16
|
+
}
|
|
17
|
+
async function seedUsersToDatabase(hubDb, logService, users) {
|
|
18
|
+
// First pass: create all users, build loader userId → DB id map
|
|
19
|
+
const loaderIdToDbId = new Map();
|
|
20
|
+
for (const user of users.values()) {
|
|
21
|
+
const safeUsername = toUrlSafeKey(user.username);
|
|
22
|
+
const dbUser = await hubDb.users.create({
|
|
23
|
+
data: {
|
|
24
|
+
uuid: randomUUID(),
|
|
25
|
+
username: safeUsername,
|
|
26
|
+
title: user.config.title,
|
|
27
|
+
config: JSON.stringify({ ...user.config, username: safeUsername }),
|
|
28
|
+
api_key: randomBytes(32).toString("hex"),
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
loaderIdToDbId.set(user.userId, dbUser.id);
|
|
32
|
+
await hubDb.user_notifications.create({
|
|
33
|
+
data: {
|
|
34
|
+
user_id: dbUser.id,
|
|
35
|
+
},
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
// Second pass: update lead_user_id relationships
|
|
39
|
+
for (const user of users.values()) {
|
|
40
|
+
if (user.leadUserId !== undefined) {
|
|
41
|
+
const dbId = loaderIdToDbId.get(user.userId);
|
|
42
|
+
const leadDbId = loaderIdToDbId.get(user.leadUserId);
|
|
43
|
+
if (dbId !== undefined && leadDbId !== undefined) {
|
|
44
|
+
await hubDb.users.update({
|
|
45
|
+
where: { id: dbId },
|
|
46
|
+
data: { lead_user_id: leadDbId },
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
logService.log(`[Hub:AgentRegistrar] Seeded ${users.size} users into database`);
|
|
52
|
+
}
|
|
53
|
+
//# sourceMappingURL=agentRegistrar.js.map
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { computeCertFingerprint } from "@naisys/common-node";
|
|
2
|
+
import { randomBytes } from "crypto";
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
import { generate } from "selfsigned";
|
|
6
|
+
/**
|
|
7
|
+
* Loads existing TLS cert/key from NAISYS_FOLDER/cert/ or generates a new
|
|
8
|
+
* self-signed pair plus a random access key. Returns the PEM strings and
|
|
9
|
+
* the hubAccessKey (fingerprint_prefix+secret) used for client auth.
|
|
10
|
+
*/
|
|
11
|
+
export async function loadOrCreateCert() {
|
|
12
|
+
const naisysFolder = process.env.NAISYS_FOLDER || "";
|
|
13
|
+
const certDir = join(naisysFolder, "cert");
|
|
14
|
+
const keyPath = join(certDir, "hub-key.pem");
|
|
15
|
+
const certPath = join(certDir, "hub-cert.pem");
|
|
16
|
+
const accessKeyPath = join(certDir, "hub-access-key");
|
|
17
|
+
let key;
|
|
18
|
+
let cert;
|
|
19
|
+
if (existsSync(keyPath) && existsSync(certPath)) {
|
|
20
|
+
key = readFileSync(keyPath, "utf-8");
|
|
21
|
+
cert = readFileSync(certPath, "utf-8");
|
|
22
|
+
}
|
|
23
|
+
else {
|
|
24
|
+
mkdirSync(certDir, { recursive: true });
|
|
25
|
+
const attrs = [{ name: "commonName", value: "NAISYS Hub" }];
|
|
26
|
+
const notAfterDate = new Date();
|
|
27
|
+
notAfterDate.setFullYear(notAfterDate.getFullYear() + 10);
|
|
28
|
+
const pems = await generate(attrs, {
|
|
29
|
+
keySize: 2048,
|
|
30
|
+
algorithm: "sha256",
|
|
31
|
+
notAfterDate,
|
|
32
|
+
});
|
|
33
|
+
key = pems.private;
|
|
34
|
+
cert = pems.cert;
|
|
35
|
+
writeFileSync(keyPath, key, { mode: 0o600 });
|
|
36
|
+
writeFileSync(certPath, cert);
|
|
37
|
+
}
|
|
38
|
+
// Load or generate the secret access key
|
|
39
|
+
let hubAccessKey;
|
|
40
|
+
if (existsSync(accessKeyPath)) {
|
|
41
|
+
hubAccessKey = readFileSync(accessKeyPath, "utf-8").trim();
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
const secretKey = randomBytes(8).toString("hex"); // 16 hex chars
|
|
45
|
+
// Compute SHA-256 fingerprint from DER-encoded cert (first 16 hex chars)
|
|
46
|
+
const derMatch = cert.match(/-----BEGIN CERTIFICATE-----\s*([\s\S]+?)\s*-----END CERTIFICATE-----/);
|
|
47
|
+
if (!derMatch) {
|
|
48
|
+
throw new Error("Failed to parse PEM certificate");
|
|
49
|
+
}
|
|
50
|
+
const der = Buffer.from(derMatch[1].replace(/\s/g, ""), "base64");
|
|
51
|
+
const fingerprint = computeCertFingerprint(der);
|
|
52
|
+
const fingerprintPrefix = fingerprint.substring(0, 16);
|
|
53
|
+
hubAccessKey = `${fingerprintPrefix}+${secretKey}`;
|
|
54
|
+
writeFileSync(accessKeyPath, hubAccessKey, { mode: 0o600 });
|
|
55
|
+
}
|
|
56
|
+
return { key, cert, hubAccessKey };
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Rotates the hub access key by generating a new random secret while
|
|
60
|
+
* keeping the same certificate fingerprint prefix. Writes the new key
|
|
61
|
+
* to disk and returns it.
|
|
62
|
+
*/
|
|
63
|
+
export function rotateAccessKey(cert) {
|
|
64
|
+
const naisysFolder = process.env.NAISYS_FOLDER || "";
|
|
65
|
+
const accessKeyPath = join(naisysFolder, "cert", "hub-access-key");
|
|
66
|
+
const secretKey = randomBytes(8).toString("hex"); // 16 hex chars
|
|
67
|
+
const derMatch = cert.match(/-----BEGIN CERTIFICATE-----\s*([\s\S]+?)\s*-----END CERTIFICATE-----/);
|
|
68
|
+
if (!derMatch) {
|
|
69
|
+
throw new Error("Failed to parse PEM certificate");
|
|
70
|
+
}
|
|
71
|
+
const der = Buffer.from(derMatch[1].replace(/\s/g, ""), "base64");
|
|
72
|
+
const fingerprint = computeCertFingerprint(der);
|
|
73
|
+
const fingerprintPrefix = fingerprint.substring(0, 16);
|
|
74
|
+
const newAccessKey = `${fingerprintPrefix}+${secretKey}`;
|
|
75
|
+
writeFileSync(accessKeyPath, newAccessKey, { mode: 0o600 });
|
|
76
|
+
return newAccessKey;
|
|
77
|
+
}
|
|
78
|
+
//# sourceMappingURL=certService.js.map
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { toUrlSafeKey } from "@naisys/common";
|
|
2
|
+
export async function createHostRegistrar({ hubDb }) {
|
|
3
|
+
/** Cache of all known hosts keyed by id */
|
|
4
|
+
const hostsById = new Map();
|
|
5
|
+
// Seed the cache from the database
|
|
6
|
+
const rows = await hubDb.hosts.findMany({
|
|
7
|
+
select: { id: true, name: true, restricted: true, host_type: true },
|
|
8
|
+
});
|
|
9
|
+
for (const row of rows) {
|
|
10
|
+
hostsById.set(row.id, {
|
|
11
|
+
hostName: row.name,
|
|
12
|
+
restricted: row.restricted,
|
|
13
|
+
hostType: row.host_type,
|
|
14
|
+
});
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Register a NAISYS instance by name. Creates a new record if not found,
|
|
18
|
+
* updates last_active on every call.
|
|
19
|
+
* @returns The host's autoincrement id
|
|
20
|
+
*/
|
|
21
|
+
async function registerHost(hostName, hostType, lastIp) {
|
|
22
|
+
hostName = toUrlSafeKey(hostName);
|
|
23
|
+
const existing = await hubDb.hosts.findUnique({
|
|
24
|
+
where: { name: hostName },
|
|
25
|
+
});
|
|
26
|
+
if (existing) {
|
|
27
|
+
await hubDb.hosts.update({
|
|
28
|
+
where: { id: existing.id },
|
|
29
|
+
data: {
|
|
30
|
+
last_active: new Date().toISOString(),
|
|
31
|
+
host_type: hostType,
|
|
32
|
+
last_ip: lastIp,
|
|
33
|
+
},
|
|
34
|
+
});
|
|
35
|
+
hostsById.set(existing.id, {
|
|
36
|
+
hostName,
|
|
37
|
+
restricted: existing.restricted,
|
|
38
|
+
hostType,
|
|
39
|
+
});
|
|
40
|
+
return existing.id;
|
|
41
|
+
}
|
|
42
|
+
const created = await hubDb.hosts.create({
|
|
43
|
+
data: {
|
|
44
|
+
name: hostName,
|
|
45
|
+
host_type: hostType,
|
|
46
|
+
last_ip: lastIp,
|
|
47
|
+
last_active: new Date().toISOString(),
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
hostsById.set(created.id, { hostName, restricted: false, hostType });
|
|
51
|
+
return created.id;
|
|
52
|
+
}
|
|
53
|
+
/** Returns all known hosts (from DB + any newly registered) */
|
|
54
|
+
function getAllHosts() {
|
|
55
|
+
return Array.from(hostsById, ([hostId, entry]) => ({
|
|
56
|
+
hostId,
|
|
57
|
+
hostName: entry.hostName,
|
|
58
|
+
restricted: entry.restricted,
|
|
59
|
+
hostType: entry.hostType,
|
|
60
|
+
}));
|
|
61
|
+
}
|
|
62
|
+
/** Re-read all hosts from DB and replace the in-memory cache */
|
|
63
|
+
async function refreshHosts() {
|
|
64
|
+
const rows = await hubDb.hosts.findMany({
|
|
65
|
+
select: { id: true, name: true, restricted: true, host_type: true },
|
|
66
|
+
});
|
|
67
|
+
hostsById.clear();
|
|
68
|
+
for (const row of rows) {
|
|
69
|
+
hostsById.set(row.id, {
|
|
70
|
+
hostName: row.name,
|
|
71
|
+
restricted: row.restricted,
|
|
72
|
+
hostType: row.host_type,
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
registerHost,
|
|
78
|
+
getAllHosts,
|
|
79
|
+
refreshHosts,
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
//# sourceMappingURL=hostRegistrar.js.map
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import pino from "pino";
|
|
3
|
+
/**
|
|
4
|
+
* Creates a log service for the hub.
|
|
5
|
+
* In hosted mode, logs to a file using pino.
|
|
6
|
+
* In standalone mode, uses console.log.
|
|
7
|
+
*/
|
|
8
|
+
export function createHubServerLog(startupType) {
|
|
9
|
+
if (startupType === "hosted") {
|
|
10
|
+
const logPath = path.join(process.env.NAISYS_FOLDER || "", "logs", "hub-server.log");
|
|
11
|
+
const logger = pino({
|
|
12
|
+
level: "info",
|
|
13
|
+
transport: {
|
|
14
|
+
target: "pino/file",
|
|
15
|
+
options: {
|
|
16
|
+
destination: logPath,
|
|
17
|
+
mkdir: true,
|
|
18
|
+
},
|
|
19
|
+
},
|
|
20
|
+
});
|
|
21
|
+
let consoleEnabled = true;
|
|
22
|
+
return {
|
|
23
|
+
log: (message) => {
|
|
24
|
+
logger.info(message);
|
|
25
|
+
if (consoleEnabled) {
|
|
26
|
+
console.log(message);
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
error: (message) => {
|
|
30
|
+
logger.error(message);
|
|
31
|
+
if (consoleEnabled) {
|
|
32
|
+
console.error(message);
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
disableConsole: () => {
|
|
36
|
+
consoleEnabled = false;
|
|
37
|
+
},
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
// Standalone mode - use console
|
|
41
|
+
return {
|
|
42
|
+
log: (message) => console.log(message),
|
|
43
|
+
error: (message) => console.error(message),
|
|
44
|
+
disableConsole: () => { },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
//# sourceMappingURL=hubServerLog.js.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Handles the lifecycle of a single NAISYS instance connection to the hub.
|
|
3
|
+
* Each connected NAISYS instance gets its own NaisysConnection instance.
|
|
4
|
+
*/
|
|
5
|
+
export function createNaisysConnection(socket, connectionInfo, raiseEvent, logService) {
|
|
6
|
+
const { hostId, hostName, connectedAt, hostType } = connectionInfo;
|
|
7
|
+
logService.log(`[Hub:Connection] NAISYS instance connected: ${hostName} (${hostId})`);
|
|
8
|
+
// Forward all socket events to hub's emit function
|
|
9
|
+
// Note: Socket.IO passes (eventName, ...args) where last arg may be an ack callback
|
|
10
|
+
socket.onAny((eventName, ...args) => {
|
|
11
|
+
logService.log(`[Hub:Connection] Received ${eventName} from ${hostName}`);
|
|
12
|
+
// Pass all args including any ack callback (usually data and optional ack)
|
|
13
|
+
raiseEvent(eventName, hostId, ...args);
|
|
14
|
+
});
|
|
15
|
+
// Handle disconnect
|
|
16
|
+
socket.on("disconnect", (reason) => {
|
|
17
|
+
logService.log(`[Hub:Connection] NAISYS instance disconnected: ${hostName} (${hostId}) - ${reason}`);
|
|
18
|
+
});
|
|
19
|
+
/**
|
|
20
|
+
* Send a message to this client's socket.
|
|
21
|
+
* If ack callback is provided, waits for client acknowledgement.
|
|
22
|
+
*/
|
|
23
|
+
function sendMessage(event, payload, ack) {
|
|
24
|
+
if (ack) {
|
|
25
|
+
socket.emit(event, payload, ack);
|
|
26
|
+
}
|
|
27
|
+
else {
|
|
28
|
+
socket.emit(event, payload);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/** Forcefully disconnect this client */
|
|
32
|
+
function disconnect() {
|
|
33
|
+
socket.disconnect(true);
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
sendMessage,
|
|
37
|
+
disconnect,
|
|
38
|
+
getHostId: () => hostId,
|
|
39
|
+
getHostName: () => hostName,
|
|
40
|
+
getConnectedAt: () => connectedAt,
|
|
41
|
+
getSocketId: () => socket.id,
|
|
42
|
+
getHostType: () => hostType,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
//# sourceMappingURL=naisysConnection.js.map
|