openclaw-bridge-hub 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +6 -0
- package/ecosystem.config.js +15 -0
- package/package.json +29 -0
- package/src/cleanup.ts +31 -0
- package/src/cli.ts +209 -0
- package/src/config.ts +10 -0
- package/src/db.ts +48 -0
- package/src/index.ts +36 -0
- package/src/middleware/auth.ts +13 -0
- package/src/routes/commands.ts +93 -0
- package/src/routes/files.ts +103 -0
- package/src/routes/health.ts +41 -0
- package/src/routes/registry.ts +87 -0
- package/src/types.ts +23 -0
- package/tsconfig.json +14 -0
package/.env.example
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export default {
|
|
2
|
+
apps: [
|
|
3
|
+
{
|
|
4
|
+
name: "openclaw-bridge-hub",
|
|
5
|
+
script: "src/index.ts",
|
|
6
|
+
interpreter: "node",
|
|
7
|
+
interpreter_args: "--import tsx",
|
|
8
|
+
env: {
|
|
9
|
+
NODE_ENV: "production",
|
|
10
|
+
},
|
|
11
|
+
max_memory_restart: "256M",
|
|
12
|
+
log_date_format: "YYYY-MM-DD HH:mm:ss",
|
|
13
|
+
},
|
|
14
|
+
],
|
|
15
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "openclaw-bridge-hub",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenClaw Bridge Hub — gateway registry, file relay, and command queue for distributed OpenClaw instances",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"bin": {
|
|
8
|
+
"openclaw-bridge-hub": "src/cli.ts"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"start": "tsx src/index.ts",
|
|
12
|
+
"build": "tsc",
|
|
13
|
+
"dev": "tsx watch src/index.ts"
|
|
14
|
+
},
|
|
15
|
+
"dependencies": {
|
|
16
|
+
"fastify": "^5.0.0",
|
|
17
|
+
"better-sqlite3": "^11.0.0",
|
|
18
|
+
"uuid": "^11.0.0",
|
|
19
|
+
"dotenv": "^16.4.0",
|
|
20
|
+
"tsx": "^4.19.0"
|
|
21
|
+
},
|
|
22
|
+
"devDependencies": {
|
|
23
|
+
"@types/better-sqlite3": "^7.6.0",
|
|
24
|
+
"@types/uuid": "^10.0.0",
|
|
25
|
+
"typescript": "^5.7.0"
|
|
26
|
+
},
|
|
27
|
+
"keywords": ["openclaw", "bridge", "filerelay", "gateway", "registry"],
|
|
28
|
+
"license": "MIT"
|
|
29
|
+
}
|
package/src/cleanup.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { unlinkSync } from "node:fs";
|
|
2
|
+
import { db } from "./db.js";
|
|
3
|
+
import { config } from "./config.js";
|
|
4
|
+
|
|
5
|
+
export function startCleanup(): ReturnType<typeof setInterval> {
|
|
6
|
+
return setInterval(() => {
|
|
7
|
+
const now = Date.now();
|
|
8
|
+
|
|
9
|
+
const oldFiles = db
|
|
10
|
+
.prepare(
|
|
11
|
+
`SELECT id, diskPath FROM files
|
|
12
|
+
WHERE status = 'acknowledged'
|
|
13
|
+
OR (createdAt < ? AND status = 'pending')`,
|
|
14
|
+
)
|
|
15
|
+
.all(new Date(now - config.fileTtlMs).toISOString()) as Array<{
|
|
16
|
+
id: string;
|
|
17
|
+
diskPath: string;
|
|
18
|
+
}>;
|
|
19
|
+
|
|
20
|
+
for (const f of oldFiles) {
|
|
21
|
+
try { unlinkSync(f.diskPath); } catch {}
|
|
22
|
+
db.prepare(`DELETE FROM files WHERE id = ?`).run(f.id);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
db.prepare(
|
|
26
|
+
`DELETE FROM commands
|
|
27
|
+
WHERE (status IN ('responded', 'error'))
|
|
28
|
+
OR (createdAt < ? AND status = 'queued')`,
|
|
29
|
+
).run(new Date(now - config.commandTtlMs).toISOString());
|
|
30
|
+
}, config.cleanupIntervalMs);
|
|
31
|
+
}
|
package/src/cli.ts
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
#!/usr/bin/env -S npx tsx
|
|
2
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { execSync } from "node:child_process";
|
|
6
|
+
|
|
7
|
+
const DATA_DIR = process.env.BRIDGE_FILERELAY_DATA || join(process.env.HOME || "/root", ".openclaw-bridge-hub");
|
|
8
|
+
const ENV_FILE = join(DATA_DIR, ".env");
|
|
9
|
+
const IS_LINUX = process.platform === "linux";
|
|
10
|
+
|
|
11
|
+
const command = process.argv[2];
|
|
12
|
+
|
|
13
|
+
function printHelp() {
|
|
14
|
+
console.log(`
|
|
15
|
+
openclaw-bridge-hub — OpenClaw Bridge FileRelay Server
|
|
16
|
+
|
|
17
|
+
Commands:
|
|
18
|
+
init Initialize config (generates API key, creates data dir)
|
|
19
|
+
start Start the server
|
|
20
|
+
status Check if server is running
|
|
21
|
+
install-service Install as systemd service (Linux, auto-start on boot)
|
|
22
|
+
uninstall-service Remove systemd service
|
|
23
|
+
help Show this help
|
|
24
|
+
|
|
25
|
+
Environment:
|
|
26
|
+
BRIDGE_FILERELAY_DATA Data directory (default: ~/.openclaw-bridge-hub)
|
|
27
|
+
PORT Server port (default: 3080)
|
|
28
|
+
API_KEY API key for authentication
|
|
29
|
+
|
|
30
|
+
Example:
|
|
31
|
+
npm install -g openclaw-bridge-hub
|
|
32
|
+
openclaw-bridge-hub init
|
|
33
|
+
openclaw-bridge-hub start
|
|
34
|
+
openclaw-bridge-hub install-service # auto-start on reboot
|
|
35
|
+
`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function init() {
|
|
39
|
+
mkdirSync(DATA_DIR, { recursive: true });
|
|
40
|
+
|
|
41
|
+
if (existsSync(ENV_FILE)) {
|
|
42
|
+
console.log(`Config already exists at ${ENV_FILE}`);
|
|
43
|
+
console.log("Edit it to change settings, then run: openclaw-bridge-hub start");
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const apiKey = randomBytes(32).toString("hex");
|
|
48
|
+
const envContent = `# Bridge FileRelay Configuration
|
|
49
|
+
PORT=3080
|
|
50
|
+
API_KEY=${apiKey}
|
|
51
|
+
DATA_DIR=${DATA_DIR}/data
|
|
52
|
+
CLEANUP_INTERVAL_MS=3600000
|
|
53
|
+
FILE_TTL_MS=86400000
|
|
54
|
+
COMMAND_TTL_MS=3600000
|
|
55
|
+
`;
|
|
56
|
+
|
|
57
|
+
writeFileSync(ENV_FILE, envContent);
|
|
58
|
+
mkdirSync(join(DATA_DIR, "data", "files"), { recursive: true });
|
|
59
|
+
|
|
60
|
+
console.log(`
|
|
61
|
+
Initialized openclaw-bridge-hub at ${DATA_DIR}
|
|
62
|
+
|
|
63
|
+
Your API key (save this — clients need it to connect):
|
|
64
|
+
|
|
65
|
+
${apiKey}
|
|
66
|
+
|
|
67
|
+
Config file: ${ENV_FILE}
|
|
68
|
+
Data dir: ${DATA_DIR}/data
|
|
69
|
+
|
|
70
|
+
Next steps:
|
|
71
|
+
openclaw-bridge-hub start # start the server
|
|
72
|
+
openclaw-bridge-hub install-service # auto-start on boot (Linux)
|
|
73
|
+
`);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function start() {
|
|
77
|
+
if (existsSync(ENV_FILE)) {
|
|
78
|
+
// Load env from config file
|
|
79
|
+
const content = readFileSync(ENV_FILE, "utf-8");
|
|
80
|
+
for (const line of content.split("\n")) {
|
|
81
|
+
const trimmed = line.trim();
|
|
82
|
+
if (!trimmed || trimmed.startsWith("#")) continue;
|
|
83
|
+
const eqIdx = trimmed.indexOf("=");
|
|
84
|
+
if (eqIdx > 0) {
|
|
85
|
+
const key = trimmed.slice(0, eqIdx).trim();
|
|
86
|
+
const val = trimmed.slice(eqIdx + 1).trim();
|
|
87
|
+
if (!process.env[key]) process.env[key] = val;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
} else {
|
|
91
|
+
console.log("No config found. Run 'openclaw-bridge-hub init' first.");
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// Import and start the server
|
|
96
|
+
import("./index.js");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function status() {
|
|
100
|
+
if (!existsSync(ENV_FILE)) {
|
|
101
|
+
console.log("Not initialized. Run: openclaw-bridge-hub init");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const content = readFileSync(ENV_FILE, "utf-8");
|
|
106
|
+
const portMatch = content.match(/^PORT=(\d+)/m);
|
|
107
|
+
const port = portMatch ? portMatch[1] : "3080";
|
|
108
|
+
|
|
109
|
+
try {
|
|
110
|
+
execSync(`curl -sf http://localhost:${port}/api/v1/health`, { timeout: 5000 });
|
|
111
|
+
console.log(`FileRelay is running on port ${port}`);
|
|
112
|
+
} catch {
|
|
113
|
+
console.log(`FileRelay is NOT running (port ${port})`);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function installService() {
|
|
118
|
+
if (!IS_LINUX) {
|
|
119
|
+
console.log("systemd service is only supported on Linux.");
|
|
120
|
+
console.log("On other platforms, use pm2 or your system's service manager.");
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Find the actual path of openclaw-bridge-hub bin
|
|
125
|
+
let binPath: string;
|
|
126
|
+
try {
|
|
127
|
+
binPath = execSync("which openclaw-bridge-hub", { encoding: "utf-8" }).trim();
|
|
128
|
+
} catch {
|
|
129
|
+
binPath = "/usr/local/bin/openclaw-bridge-hub";
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const user = process.env.USER || "root";
|
|
133
|
+
|
|
134
|
+
const serviceContent = `[Unit]
|
|
135
|
+
Description=OpenClaw Bridge FileRelay
|
|
136
|
+
After=network.target
|
|
137
|
+
|
|
138
|
+
[Service]
|
|
139
|
+
Type=simple
|
|
140
|
+
User=${user}
|
|
141
|
+
Environment=BRIDGE_FILERELAY_DATA=${DATA_DIR}
|
|
142
|
+
ExecStart=${binPath} start
|
|
143
|
+
Restart=always
|
|
144
|
+
RestartSec=5
|
|
145
|
+
|
|
146
|
+
[Install]
|
|
147
|
+
WantedBy=multi-user.target
|
|
148
|
+
`;
|
|
149
|
+
|
|
150
|
+
const servicePath = "/etc/systemd/system/openclaw-bridge-hub.service";
|
|
151
|
+
try {
|
|
152
|
+
writeFileSync(servicePath, serviceContent);
|
|
153
|
+
execSync("systemctl daemon-reload");
|
|
154
|
+
execSync("systemctl enable openclaw-bridge-hub");
|
|
155
|
+
execSync("systemctl start openclaw-bridge-hub");
|
|
156
|
+
console.log(`
|
|
157
|
+
Service installed and started!
|
|
158
|
+
|
|
159
|
+
systemctl status openclaw-bridge-hub # check status
|
|
160
|
+
systemctl restart openclaw-bridge-hub # restart
|
|
161
|
+
journalctl -u openclaw-bridge-hub -f # view logs
|
|
162
|
+
`);
|
|
163
|
+
} catch (err) {
|
|
164
|
+
console.log(`Failed to install service. You may need sudo:\n sudo openclaw-bridge-hub install-service`);
|
|
165
|
+
// Write service file to data dir as fallback
|
|
166
|
+
const fallbackPath = join(DATA_DIR, "openclaw-bridge-hub.service");
|
|
167
|
+
writeFileSync(fallbackPath, serviceContent);
|
|
168
|
+
console.log(`\nService file saved to: ${fallbackPath}`);
|
|
169
|
+
console.log("Install manually:");
|
|
170
|
+
console.log(` sudo cp ${fallbackPath} /etc/systemd/system/`);
|
|
171
|
+
console.log(" sudo systemctl daemon-reload && sudo systemctl enable --now openclaw-bridge-hub");
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function uninstallService() {
|
|
176
|
+
if (!IS_LINUX) {
|
|
177
|
+
console.log("systemd service is only supported on Linux.");
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
try {
|
|
181
|
+
execSync("systemctl stop openclaw-bridge-hub");
|
|
182
|
+
execSync("systemctl disable openclaw-bridge-hub");
|
|
183
|
+
execSync("rm /etc/systemd/system/openclaw-bridge-hub.service");
|
|
184
|
+
execSync("systemctl daemon-reload");
|
|
185
|
+
console.log("Service removed.");
|
|
186
|
+
} catch {
|
|
187
|
+
console.log("Failed to remove service. Try with sudo.");
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
switch (command) {
|
|
192
|
+
case "init":
|
|
193
|
+
init();
|
|
194
|
+
break;
|
|
195
|
+
case "start":
|
|
196
|
+
start();
|
|
197
|
+
break;
|
|
198
|
+
case "status":
|
|
199
|
+
status();
|
|
200
|
+
break;
|
|
201
|
+
case "install-service":
|
|
202
|
+
installService();
|
|
203
|
+
break;
|
|
204
|
+
case "uninstall-service":
|
|
205
|
+
uninstallService();
|
|
206
|
+
break;
|
|
207
|
+
default:
|
|
208
|
+
printHelp();
|
|
209
|
+
}
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import "dotenv/config";
|
|
2
|
+
|
|
3
|
+
export const config = {
|
|
4
|
+
port: parseInt(process.env.PORT ?? "3080", 10),
|
|
5
|
+
apiKey: process.env.API_KEY ?? "",
|
|
6
|
+
dataDir: process.env.DATA_DIR ?? "./data",
|
|
7
|
+
cleanupIntervalMs: parseInt(process.env.CLEANUP_INTERVAL_MS ?? "3600000", 10),
|
|
8
|
+
fileTtlMs: parseInt(process.env.FILE_TTL_MS ?? "86400000", 10),
|
|
9
|
+
commandTtlMs: parseInt(process.env.COMMAND_TTL_MS ?? "3600000", 10),
|
|
10
|
+
};
|
package/src/db.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import Database from "better-sqlite3";
|
|
2
|
+
import { mkdirSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { config } from "./config.js";
|
|
5
|
+
|
|
6
|
+
mkdirSync(config.dataDir, { recursive: true });
|
|
7
|
+
mkdirSync(join(config.dataDir, "files"), { recursive: true });
|
|
8
|
+
|
|
9
|
+
const dbPath = join(config.dataDir, "relay.db");
|
|
10
|
+
export const db = new Database(dbPath);
|
|
11
|
+
|
|
12
|
+
db.pragma("journal_mode = WAL");
|
|
13
|
+
|
|
14
|
+
db.exec(`
|
|
15
|
+
CREATE TABLE IF NOT EXISTS files (
|
|
16
|
+
id TEXT PRIMARY KEY,
|
|
17
|
+
fromAgent TEXT NOT NULL,
|
|
18
|
+
toAgent TEXT NOT NULL,
|
|
19
|
+
filename TEXT NOT NULL,
|
|
20
|
+
diskPath TEXT NOT NULL,
|
|
21
|
+
size INTEGER NOT NULL DEFAULT 0,
|
|
22
|
+
metadata TEXT NOT NULL DEFAULT '{}',
|
|
23
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
24
|
+
createdAt TEXT NOT NULL
|
|
25
|
+
);
|
|
26
|
+
|
|
27
|
+
CREATE TABLE IF NOT EXISTS commands (
|
|
28
|
+
id TEXT PRIMARY KEY,
|
|
29
|
+
fromAgent TEXT NOT NULL,
|
|
30
|
+
toAgent TEXT NOT NULL,
|
|
31
|
+
type TEXT NOT NULL,
|
|
32
|
+
payload TEXT NOT NULL DEFAULT '{}',
|
|
33
|
+
status TEXT NOT NULL DEFAULT 'queued',
|
|
34
|
+
response TEXT,
|
|
35
|
+
createdAt TEXT NOT NULL,
|
|
36
|
+
respondedAt TEXT
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
CREATE INDEX IF NOT EXISTS idx_files_toAgent_status ON files(toAgent, status);
|
|
40
|
+
CREATE INDEX IF NOT EXISTS idx_commands_toAgent_status ON commands(toAgent, status);
|
|
41
|
+
|
|
42
|
+
CREATE TABLE IF NOT EXISTS registry (
|
|
43
|
+
agentId TEXT PRIMARY KEY,
|
|
44
|
+
data TEXT NOT NULL DEFAULT '{}',
|
|
45
|
+
lastHeartbeat TEXT NOT NULL,
|
|
46
|
+
createdAt TEXT NOT NULL
|
|
47
|
+
);
|
|
48
|
+
`);
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import Fastify from "fastify";
|
|
2
|
+
import { config } from "./config.js";
|
|
3
|
+
import "./db.js";
|
|
4
|
+
import { registerFileRoutes } from "./routes/files.js";
|
|
5
|
+
import { registerCommandRoutes } from "./routes/commands.js";
|
|
6
|
+
import { registerHealthRoutes } from "./routes/health.js";
|
|
7
|
+
import { registerRegistryRoutes } from "./routes/registry.js";
|
|
8
|
+
import { authMiddleware } from "./middleware/auth.js";
|
|
9
|
+
import { startCleanup } from "./cleanup.js";
|
|
10
|
+
|
|
11
|
+
const app = Fastify({ logger: true });
|
|
12
|
+
|
|
13
|
+
app.addHook("onRequest", authMiddleware);
|
|
14
|
+
|
|
15
|
+
registerFileRoutes(app);
|
|
16
|
+
registerCommandRoutes(app);
|
|
17
|
+
registerHealthRoutes(app);
|
|
18
|
+
registerRegistryRoutes(app);
|
|
19
|
+
|
|
20
|
+
const cleanupTimer = startCleanup();
|
|
21
|
+
|
|
22
|
+
const shutdown = async () => {
|
|
23
|
+
clearInterval(cleanupTimer);
|
|
24
|
+
await app.close();
|
|
25
|
+
process.exit(0);
|
|
26
|
+
};
|
|
27
|
+
process.on("SIGINT", shutdown);
|
|
28
|
+
process.on("SIGTERM", shutdown);
|
|
29
|
+
|
|
30
|
+
app.listen({ port: config.port, host: "0.0.0.0" }, (err, address) => {
|
|
31
|
+
if (err) {
|
|
32
|
+
console.error("Failed to start FileRelay:", err);
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
console.log(`Bridge FileRelay listening on ${address}`);
|
|
36
|
+
});
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { FastifyRequest, FastifyReply } from "fastify";
|
|
2
|
+
import { config } from "../config.js";
|
|
3
|
+
|
|
4
|
+
export async function authMiddleware(
|
|
5
|
+
request: FastifyRequest,
|
|
6
|
+
reply: FastifyReply,
|
|
7
|
+
): Promise<void> {
|
|
8
|
+
if (!config.apiKey) return;
|
|
9
|
+
const key = request.headers["x-api-key"];
|
|
10
|
+
if (key !== config.apiKey) {
|
|
11
|
+
reply.code(401).send({ error: "Invalid API key" });
|
|
12
|
+
}
|
|
13
|
+
}
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { FastifyInstance } from "fastify";
|
|
2
|
+
import { v4 as uuid } from "uuid";
|
|
3
|
+
import { db } from "../db.js";
|
|
4
|
+
|
|
5
|
+
export function registerCommandRoutes(app: FastifyInstance): void {
|
|
6
|
+
app.post("/api/v1/commands/enqueue", async (request) => {
|
|
7
|
+
const body = request.body as {
|
|
8
|
+
fromAgent: string;
|
|
9
|
+
toAgent: string;
|
|
10
|
+
type: string;
|
|
11
|
+
payload: Record<string, unknown>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
const id = uuid();
|
|
15
|
+
const createdAt = new Date().toISOString();
|
|
16
|
+
db.prepare(
|
|
17
|
+
`INSERT INTO commands (id, fromAgent, toAgent, type, payload, status, createdAt)
|
|
18
|
+
VALUES (?, ?, ?, ?, ?, 'queued', ?)`,
|
|
19
|
+
).run(id, body.fromAgent, body.toAgent, body.type, JSON.stringify(body.payload), createdAt);
|
|
20
|
+
|
|
21
|
+
return { id, status: "queued" };
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
app.get("/api/v1/commands/pending", async (request) => {
|
|
25
|
+
const { agent } = request.query as { agent: string };
|
|
26
|
+
const rows = db
|
|
27
|
+
.prepare(
|
|
28
|
+
`SELECT id, fromAgent, type, payload, createdAt
|
|
29
|
+
FROM commands WHERE toAgent = ? AND status = 'queued'
|
|
30
|
+
ORDER BY createdAt ASC`,
|
|
31
|
+
)
|
|
32
|
+
.all(agent) as Array<{
|
|
33
|
+
id: string;
|
|
34
|
+
fromAgent: string;
|
|
35
|
+
type: string;
|
|
36
|
+
payload: string;
|
|
37
|
+
createdAt: string;
|
|
38
|
+
}>;
|
|
39
|
+
|
|
40
|
+
return {
|
|
41
|
+
commands: rows.map((r) => ({
|
|
42
|
+
...r,
|
|
43
|
+
payload: JSON.parse(r.payload),
|
|
44
|
+
})),
|
|
45
|
+
};
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
app.post("/api/v1/commands/respond/:id", async (request, reply) => {
|
|
49
|
+
const { id } = request.params as { id: string };
|
|
50
|
+
const body = request.body as {
|
|
51
|
+
status: "ok" | "error";
|
|
52
|
+
payload: Record<string, unknown>;
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
const existing = db.prepare(`SELECT id FROM commands WHERE id = ?`).get(id);
|
|
56
|
+
if (!existing) {
|
|
57
|
+
reply.code(404).send({ error: "Command not found" });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
db.prepare(
|
|
62
|
+
`UPDATE commands SET status = ?, response = ?, respondedAt = ? WHERE id = ?`,
|
|
63
|
+
).run(
|
|
64
|
+
body.status === "ok" ? "responded" : "error",
|
|
65
|
+
JSON.stringify(body.payload),
|
|
66
|
+
new Date().toISOString(),
|
|
67
|
+
id,
|
|
68
|
+
);
|
|
69
|
+
|
|
70
|
+
return { status: "updated" };
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
app.get("/api/v1/commands/result/:id", async (request, reply) => {
|
|
74
|
+
const { id } = request.params as { id: string };
|
|
75
|
+
const row = db.prepare(`SELECT status, response FROM commands WHERE id = ?`).get(id) as
|
|
76
|
+
| { status: string; response: string | null }
|
|
77
|
+
| undefined;
|
|
78
|
+
|
|
79
|
+
if (!row) {
|
|
80
|
+
reply.code(404).send({ error: "Command not found" });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
if (row.status === "queued") {
|
|
85
|
+
return { status: "queued" };
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return {
|
|
89
|
+
status: row.status === "responded" ? "ok" : "error",
|
|
90
|
+
payload: row.response ? JSON.parse(row.response) : null,
|
|
91
|
+
};
|
|
92
|
+
});
|
|
93
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import type { FastifyInstance } from "fastify";
|
|
2
|
+
import { writeFileSync, readFileSync, unlinkSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { v4 as uuid } from "uuid";
|
|
5
|
+
import { db } from "../db.js";
|
|
6
|
+
import { config } from "../config.js";
|
|
7
|
+
|
|
8
|
+
export function registerFileRoutes(app: FastifyInstance): void {
|
|
9
|
+
app.post("/api/v1/files/upload", async (request) => {
|
|
10
|
+
const body = request.body as {
|
|
11
|
+
fromAgent: string;
|
|
12
|
+
toAgent: string;
|
|
13
|
+
filename: string;
|
|
14
|
+
content: string;
|
|
15
|
+
metadata?: Record<string, unknown>;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const id = uuid();
|
|
19
|
+
const diskPath = join(config.dataDir, "files", `${id}.bin`);
|
|
20
|
+
const buffer = Buffer.from(body.content, "base64");
|
|
21
|
+
writeFileSync(diskPath, buffer);
|
|
22
|
+
|
|
23
|
+
const createdAt = new Date().toISOString();
|
|
24
|
+
db.prepare(
|
|
25
|
+
`INSERT INTO files (id, fromAgent, toAgent, filename, diskPath, size, metadata, status, createdAt)
|
|
26
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, 'pending', ?)`,
|
|
27
|
+
).run(
|
|
28
|
+
id,
|
|
29
|
+
body.fromAgent,
|
|
30
|
+
body.toAgent,
|
|
31
|
+
body.filename,
|
|
32
|
+
diskPath,
|
|
33
|
+
buffer.length,
|
|
34
|
+
JSON.stringify(body.metadata ?? {}),
|
|
35
|
+
createdAt,
|
|
36
|
+
);
|
|
37
|
+
|
|
38
|
+
return { id, status: "pending", createdAt };
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
app.get("/api/v1/files/pending", async (request) => {
|
|
42
|
+
const { agent } = request.query as { agent: string };
|
|
43
|
+
const rows = db
|
|
44
|
+
.prepare(
|
|
45
|
+
`SELECT id, fromAgent, filename, size, metadata, createdAt
|
|
46
|
+
FROM files WHERE toAgent = ? AND status = 'pending'
|
|
47
|
+
ORDER BY createdAt ASC`,
|
|
48
|
+
)
|
|
49
|
+
.all(agent) as Array<{
|
|
50
|
+
id: string;
|
|
51
|
+
fromAgent: string;
|
|
52
|
+
filename: string;
|
|
53
|
+
size: number;
|
|
54
|
+
metadata: string;
|
|
55
|
+
createdAt: string;
|
|
56
|
+
}>;
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
files: rows.map((r) => ({
|
|
60
|
+
...r,
|
|
61
|
+
metadata: JSON.parse(r.metadata),
|
|
62
|
+
})),
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
app.get("/api/v1/files/download/:id", async (request, reply) => {
|
|
67
|
+
const { id } = request.params as { id: string };
|
|
68
|
+
const row = db.prepare(`SELECT diskPath FROM files WHERE id = ?`).get(id) as
|
|
69
|
+
| { diskPath: string }
|
|
70
|
+
| undefined;
|
|
71
|
+
|
|
72
|
+
if (!row) {
|
|
73
|
+
reply.code(404).send({ error: "File not found" });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
try {
|
|
78
|
+
const content = readFileSync(row.diskPath);
|
|
79
|
+
return { content: content.toString("base64") };
|
|
80
|
+
} catch {
|
|
81
|
+
reply.code(404).send({ error: "File data not found on disk" });
|
|
82
|
+
}
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
app.post("/api/v1/files/ack/:id", async (request, reply) => {
|
|
86
|
+
const { id } = request.params as { id: string };
|
|
87
|
+
const row = db.prepare(`SELECT diskPath FROM files WHERE id = ?`).get(id) as
|
|
88
|
+
| { diskPath: string }
|
|
89
|
+
| undefined;
|
|
90
|
+
|
|
91
|
+
if (!row) {
|
|
92
|
+
reply.code(404).send({ error: "File not found" });
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
try {
|
|
97
|
+
unlinkSync(row.diskPath);
|
|
98
|
+
} catch {}
|
|
99
|
+
db.prepare(`UPDATE files SET status = 'acknowledged' WHERE id = ?`).run(id);
|
|
100
|
+
|
|
101
|
+
return { status: "acknowledged" };
|
|
102
|
+
});
|
|
103
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { FastifyInstance } from "fastify";
|
|
2
|
+
import { db } from "../db.js";
|
|
3
|
+
import { statSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { config } from "../config.js";
|
|
6
|
+
|
|
7
|
+
export function registerHealthRoutes(app: FastifyInstance): void {
|
|
8
|
+
app.get("/api/v1/health", async () => {
|
|
9
|
+
return { status: "ok", timestamp: new Date().toISOString() };
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
app.get("/api/v1/stats", async () => {
|
|
13
|
+
const pendingFiles = (
|
|
14
|
+
db.prepare(`SELECT COUNT(*) as count FROM files WHERE status = 'pending'`).get() as {
|
|
15
|
+
count: number;
|
|
16
|
+
}
|
|
17
|
+
).count;
|
|
18
|
+
const queuedCommands = (
|
|
19
|
+
db.prepare(`SELECT COUNT(*) as count FROM commands WHERE status = 'queued'`).get() as {
|
|
20
|
+
count: number;
|
|
21
|
+
}
|
|
22
|
+
).count;
|
|
23
|
+
|
|
24
|
+
let diskUsageBytes = 0;
|
|
25
|
+
try {
|
|
26
|
+
const dbFile = join(config.dataDir, "relay.db");
|
|
27
|
+
try { diskUsageBytes += statSync(dbFile).size; } catch {}
|
|
28
|
+
const rows = db.prepare(`SELECT diskPath FROM files WHERE status = 'pending'`).all() as Array<{ diskPath: string }>;
|
|
29
|
+
for (const r of rows) {
|
|
30
|
+
try { diskUsageBytes += statSync(r.diskPath).size; } catch {}
|
|
31
|
+
}
|
|
32
|
+
} catch {}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
pendingFiles,
|
|
36
|
+
queuedCommands,
|
|
37
|
+
diskUsageBytes,
|
|
38
|
+
diskUsageMB: Math.round(diskUsageBytes / 1024 / 1024 * 100) / 100,
|
|
39
|
+
};
|
|
40
|
+
});
|
|
41
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import type { FastifyInstance } from "fastify";
|
|
2
|
+
import { db } from "../db.js";
|
|
3
|
+
|
|
4
|
+
export function registerRegistryRoutes(app: FastifyInstance): void {
|
|
5
|
+
// Register or update a gateway entry
|
|
6
|
+
app.post("/api/v1/registry/register", async (request) => {
|
|
7
|
+
const body = request.body as Record<string, unknown>;
|
|
8
|
+
const agentId = body.agentId as string;
|
|
9
|
+
if (!agentId) return { error: "agentId is required" };
|
|
10
|
+
|
|
11
|
+
const now = new Date().toISOString();
|
|
12
|
+
const data = JSON.stringify(body);
|
|
13
|
+
|
|
14
|
+
db.prepare(
|
|
15
|
+
`INSERT INTO registry (agentId, data, lastHeartbeat, createdAt)
|
|
16
|
+
VALUES (?, ?, ?, ?)
|
|
17
|
+
ON CONFLICT(agentId) DO UPDATE SET data = ?, lastHeartbeat = ?`,
|
|
18
|
+
).run(agentId, data, now, now, data, now);
|
|
19
|
+
|
|
20
|
+
return { status: "ok", agentId };
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// Heartbeat — update lastHeartbeat and optionally update data
|
|
24
|
+
app.post("/api/v1/registry/heartbeat", async (request) => {
|
|
25
|
+
const body = request.body as Record<string, unknown>;
|
|
26
|
+
const agentId = body.agentId as string;
|
|
27
|
+
if (!agentId) return { error: "agentId is required" };
|
|
28
|
+
|
|
29
|
+
const now = new Date().toISOString();
|
|
30
|
+
|
|
31
|
+
if (Object.keys(body).length > 1) {
|
|
32
|
+
// Full data update
|
|
33
|
+
const data = JSON.stringify(body);
|
|
34
|
+
db.prepare(
|
|
35
|
+
`UPDATE registry SET data = ?, lastHeartbeat = ? WHERE agentId = ?`,
|
|
36
|
+
).run(data, now, agentId);
|
|
37
|
+
} else {
|
|
38
|
+
// Heartbeat only
|
|
39
|
+
db.prepare(
|
|
40
|
+
`UPDATE registry SET lastHeartbeat = ? WHERE agentId = ?`,
|
|
41
|
+
).run(now, agentId);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return { status: "ok", agentId, lastHeartbeat: now };
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// Discover — list all registered gateways
|
|
48
|
+
app.get("/api/v1/registry/discover", async () => {
|
|
49
|
+
const rows = db
|
|
50
|
+
.prepare(`SELECT agentId, data, lastHeartbeat, createdAt FROM registry ORDER BY agentId`)
|
|
51
|
+
.all() as Array<{
|
|
52
|
+
agentId: string;
|
|
53
|
+
data: string;
|
|
54
|
+
lastHeartbeat: string;
|
|
55
|
+
createdAt: string;
|
|
56
|
+
}>;
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
agents: rows.map((r) => ({
|
|
60
|
+
...JSON.parse(r.data),
|
|
61
|
+
lastHeartbeat: r.lastHeartbeat,
|
|
62
|
+
})),
|
|
63
|
+
};
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
// Whois — get a specific gateway
|
|
67
|
+
app.get("/api/v1/registry/whois/:agentId", async (request, reply) => {
|
|
68
|
+
const { agentId } = request.params as { agentId: string };
|
|
69
|
+
const row = db
|
|
70
|
+
.prepare(`SELECT data, lastHeartbeat FROM registry WHERE agentId = ?`)
|
|
71
|
+
.get(agentId) as { data: string; lastHeartbeat: string } | undefined;
|
|
72
|
+
|
|
73
|
+
if (!row) {
|
|
74
|
+
reply.code(404).send({ error: `Agent "${agentId}" not found` });
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { ...JSON.parse(row.data), lastHeartbeat: row.lastHeartbeat };
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// Deregister — remove a gateway
|
|
82
|
+
app.delete("/api/v1/registry/:agentId", async (request) => {
|
|
83
|
+
const { agentId } = request.params as { agentId: string };
|
|
84
|
+
db.prepare(`DELETE FROM registry WHERE agentId = ?`).run(agentId);
|
|
85
|
+
return { status: "ok", agentId };
|
|
86
|
+
});
|
|
87
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export interface FileRecord {
|
|
2
|
+
id: string;
|
|
3
|
+
fromAgent: string;
|
|
4
|
+
toAgent: string;
|
|
5
|
+
filename: string;
|
|
6
|
+
diskPath: string;
|
|
7
|
+
size: number;
|
|
8
|
+
metadata: string;
|
|
9
|
+
status: "pending" | "acknowledged";
|
|
10
|
+
createdAt: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export interface CommandRecord {
|
|
14
|
+
id: string;
|
|
15
|
+
fromAgent: string;
|
|
16
|
+
toAgent: string;
|
|
17
|
+
type: string;
|
|
18
|
+
payload: string;
|
|
19
|
+
status: "queued" | "responded" | "error";
|
|
20
|
+
response: string | null;
|
|
21
|
+
createdAt: string;
|
|
22
|
+
respondedAt: string | null;
|
|
23
|
+
}
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"target": "ES2022",
|
|
4
|
+
"module": "ES2022",
|
|
5
|
+
"moduleResolution": "bundler",
|
|
6
|
+
"strict": true,
|
|
7
|
+
"esModuleInterop": true,
|
|
8
|
+
"skipLibCheck": true,
|
|
9
|
+
"outDir": "dist",
|
|
10
|
+
"rootDir": "src",
|
|
11
|
+
"declaration": true
|
|
12
|
+
},
|
|
13
|
+
"include": ["src"]
|
|
14
|
+
}
|