@tyevco/homelab-lxc-agent 1.0.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.
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ require("../dist/index.js");
package/dist/index.js ADDED
@@ -0,0 +1,33 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ const server_1 = require("./server");
4
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
5
+ const pkg = require("../package.json");
6
+ const port = parseInt(process.env.HOMELAB_AGENT_PORT ?? "5002", 10);
7
+ const hostname = process.env.HOMELAB_AGENT_HOSTNAME ?? "0.0.0.0";
8
+ const username = process.env.HOMELAB_AGENT_USERNAME ?? "admin";
9
+ const password = process.env.HOMELAB_AGENT_PASSWORD ?? "";
10
+ if (!password) {
11
+ console.error("Error: HOMELAB_AGENT_PASSWORD is required.");
12
+ console.error("");
13
+ console.error("Set it via environment variable:");
14
+ console.error(" HOMELAB_AGENT_PASSWORD=secret homelab-lxc-agent");
15
+ console.error("");
16
+ console.error("Optional variables:");
17
+ console.error(" HOMELAB_AGENT_PORT (default: 5002)");
18
+ console.error(" HOMELAB_AGENT_HOSTNAME (default: 0.0.0.0)");
19
+ console.error(" HOMELAB_AGENT_USERNAME (default: admin)");
20
+ process.exit(1);
21
+ }
22
+ const { httpServer } = (0, server_1.createAgentServer)({ username,
23
+ password,
24
+ version: pkg.version });
25
+ httpServer.listen(port, hostname, () => {
26
+ console.log(`Homelab LXC Agent v${pkg.version}`);
27
+ console.log(`Listening on ${hostname}:${port}`);
28
+ console.log(`Username: ${username}`);
29
+ console.log("");
30
+ console.log("Add this agent in the Homelab UI:");
31
+ console.log(` URL: http://<this-host>:${port}`);
32
+ console.log(` Username: ${username}`);
33
+ });
package/dist/lxc.js ADDED
@@ -0,0 +1,288 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.joinExecTerminal = exports.getDistributions = exports.createContainer = exports.saveConfig = exports.deleteContainer = exports.unfreezeContainer = exports.freezeContainer = exports.restartContainer = exports.stopContainer = exports.startContainer = exports.getContainer = exports.getContainerList = exports.STACK_TYPE_LXC = exports.FROZEN = exports.EXITED = exports.RUNNING = exports.UNKNOWN = void 0;
27
+ const promisify_child_process_1 = require("promisify-child-process");
28
+ const fs = __importStar(require("fs"));
29
+ const path = __importStar(require("path"));
30
+ const terminal_1 = require("./terminal");
31
+ const LXC_PATH = "/var/lib/lxc";
32
+ exports.UNKNOWN = 0;
33
+ exports.RUNNING = 3;
34
+ exports.EXITED = 4;
35
+ exports.FROZEN = 5;
36
+ exports.STACK_TYPE_LXC = "lxc";
37
+ function getLxcTerminalName(endpoint, name) {
38
+ return `lxc-${endpoint}-${name}`;
39
+ }
40
+ function getLxcExecTerminalName(endpoint, name, index) {
41
+ return `lxc-exec-${endpoint}-${name}-${index}`;
42
+ }
43
+ function statusConvert(state) {
44
+ switch (state.toUpperCase().trim()) {
45
+ case "RUNNING": return exports.RUNNING;
46
+ case "STOPPED": return exports.EXITED;
47
+ case "FROZEN": return exports.FROZEN;
48
+ default: return exports.UNKNOWN;
49
+ }
50
+ }
51
+ function parseLxcLsOutput(stdout) {
52
+ const lines = stdout.trim().split("\n");
53
+ if (lines.length < 2) {
54
+ return [];
55
+ }
56
+ const headerLine = lines[0];
57
+ const headers = [];
58
+ const positions = [];
59
+ const headerMatch = headerLine.match(/\S+/g);
60
+ if (!headerMatch) {
61
+ return [];
62
+ }
63
+ for (const header of headerMatch) {
64
+ const searchFrom = positions.length > 0
65
+ ? positions[positions.length - 1] + headers[headers.length - 1].length
66
+ : 0;
67
+ const idx = headerLine.indexOf(header, searchFrom);
68
+ headers.push(header.toLowerCase());
69
+ positions.push(idx);
70
+ }
71
+ const results = [];
72
+ for (let i = 1; i < lines.length; i++) {
73
+ const line = lines[i];
74
+ if (!line.trim() || line.startsWith("-")) {
75
+ continue;
76
+ }
77
+ const row = {};
78
+ for (let j = 0; j < headers.length; j++) {
79
+ const start = positions[j];
80
+ const end = j + 1 < positions.length ? positions[j + 1] : line.length;
81
+ row[headers[j]] = line.substring(start, end).trim();
82
+ }
83
+ results.push(row);
84
+ }
85
+ return results;
86
+ }
87
+ function readConfig(name) {
88
+ try {
89
+ return fs.readFileSync(path.join(LXC_PATH, name, "config"), "utf-8");
90
+ }
91
+ catch {
92
+ return "";
93
+ }
94
+ }
95
+ async function getContainerList(endpoint) {
96
+ const result = {};
97
+ try {
98
+ const res = await (0, promisify_child_process_1.spawn)("lxc-ls", ["-f", "-F", "name,state"], { encoding: "utf-8" });
99
+ if (!res.stdout) {
100
+ return result;
101
+ }
102
+ const rows = parseLxcLsOutput(res.stdout.toString());
103
+ for (const row of rows) {
104
+ const name = row["name"];
105
+ if (!name) {
106
+ continue;
107
+ }
108
+ result[name] = {
109
+ name,
110
+ status: statusConvert(row["state"] || ""),
111
+ type: exports.STACK_TYPE_LXC,
112
+ tags: [],
113
+ endpoint,
114
+ isManagedByHomelab: true,
115
+ };
116
+ }
117
+ }
118
+ catch (e) {
119
+ console.error("[lxc] Failed to get container list:", e instanceof Error ? e.message : e);
120
+ }
121
+ return result;
122
+ }
123
+ exports.getContainerList = getContainerList;
124
+ async function getContainer(name, endpoint) {
125
+ if (!/^[a-z0-9_.-]+$/.test(name)) {
126
+ throw new Error("Invalid LXC container name");
127
+ }
128
+ const res = await (0, promisify_child_process_1.spawn)("lxc-info", ["-n", name], { encoding: "utf-8" });
129
+ if (!res.stdout) {
130
+ throw new Error("LXC container not found");
131
+ }
132
+ const output = res.stdout.toString();
133
+ let status = exports.UNKNOWN;
134
+ let ip = "";
135
+ let pid = 0;
136
+ let memory = "";
137
+ const stateMatch = output.match(/^State:\s+(.+)$/m);
138
+ if (stateMatch) {
139
+ status = statusConvert(stateMatch[1]);
140
+ }
141
+ const ipMatch = output.match(/^IP:\s+(.+)$/m);
142
+ if (ipMatch) {
143
+ ip = ipMatch[1].trim();
144
+ }
145
+ const pidMatch = output.match(/^PID:\s+(.+)$/m);
146
+ if (pidMatch) {
147
+ pid = parseInt(pidMatch[1].trim(), 10) || 0;
148
+ }
149
+ const memMatch = output.match(/^Memory use:\s+(.+)$/m);
150
+ if (memMatch) {
151
+ memory = memMatch[1].trim();
152
+ }
153
+ return {
154
+ name,
155
+ status,
156
+ type: exports.STACK_TYPE_LXC,
157
+ tags: [],
158
+ endpoint,
159
+ ip,
160
+ autostart: false,
161
+ pid,
162
+ memory,
163
+ config: readConfig(name),
164
+ isManagedByHomelab: true,
165
+ };
166
+ }
167
+ exports.getContainer = getContainer;
168
+ async function startContainer(socket, endpoint, name) {
169
+ const code = await terminal_1.AgentTerminal.exec(socket, getLxcTerminalName(endpoint, name), "lxc-start", ["-n", name], LXC_PATH);
170
+ if (code !== 0) {
171
+ throw new Error("Failed to start LXC container");
172
+ }
173
+ }
174
+ exports.startContainer = startContainer;
175
+ async function stopContainer(socket, endpoint, name) {
176
+ const code = await terminal_1.AgentTerminal.exec(socket, getLxcTerminalName(endpoint, name), "lxc-stop", ["-n", name], LXC_PATH);
177
+ if (code !== 0) {
178
+ throw new Error("Failed to stop LXC container");
179
+ }
180
+ }
181
+ exports.stopContainer = stopContainer;
182
+ async function restartContainer(socket, endpoint, name) {
183
+ const termName = getLxcTerminalName(endpoint, name);
184
+ const stopCode = await terminal_1.AgentTerminal.exec(socket, termName, "lxc-stop", ["-n", name], LXC_PATH);
185
+ if (stopCode !== 0) {
186
+ throw new Error("Failed to stop LXC container for restart");
187
+ }
188
+ const startCode = await terminal_1.AgentTerminal.exec(socket, termName, "lxc-start", ["-n", name], LXC_PATH);
189
+ if (startCode !== 0) {
190
+ throw new Error("Failed to start LXC container for restart");
191
+ }
192
+ }
193
+ exports.restartContainer = restartContainer;
194
+ async function freezeContainer(socket, endpoint, name) {
195
+ const code = await terminal_1.AgentTerminal.exec(socket, getLxcTerminalName(endpoint, name), "lxc-freeze", ["-n", name], LXC_PATH);
196
+ if (code !== 0) {
197
+ throw new Error("Failed to freeze LXC container");
198
+ }
199
+ }
200
+ exports.freezeContainer = freezeContainer;
201
+ async function unfreezeContainer(socket, endpoint, name) {
202
+ const code = await terminal_1.AgentTerminal.exec(socket, getLxcTerminalName(endpoint, name), "lxc-unfreeze", ["-n", name], LXC_PATH);
203
+ if (code !== 0) {
204
+ throw new Error("Failed to unfreeze LXC container");
205
+ }
206
+ }
207
+ exports.unfreezeContainer = unfreezeContainer;
208
+ async function deleteContainer(socket, endpoint, name, status) {
209
+ const termName = getLxcTerminalName(endpoint, name);
210
+ if (status === exports.RUNNING || status === exports.FROZEN) {
211
+ await terminal_1.AgentTerminal.exec(socket, termName, "lxc-stop", ["-n", name], LXC_PATH);
212
+ }
213
+ const code = await terminal_1.AgentTerminal.exec(socket, termName, "lxc-destroy", ["-n", name], LXC_PATH);
214
+ if (code !== 0) {
215
+ throw new Error("Failed to destroy LXC container");
216
+ }
217
+ }
218
+ exports.deleteContainer = deleteContainer;
219
+ async function saveConfig(name, config) {
220
+ const containerPath = path.join(LXC_PATH, name);
221
+ if (!fs.existsSync(containerPath)) {
222
+ throw new Error("LXC container not found");
223
+ }
224
+ await fs.promises.writeFile(path.join(containerPath, "config"), config);
225
+ }
226
+ exports.saveConfig = saveConfig;
227
+ async function createContainer(socket, endpoint, name, dist, release, arch) {
228
+ if (!/^[a-z0-9_.-]+$/.test(name)) {
229
+ throw new Error("Invalid container name");
230
+ }
231
+ if (!/^[a-zA-Z0-9_.-]+$/.test(dist)) {
232
+ throw new Error("Invalid distribution name");
233
+ }
234
+ if (!/^[a-zA-Z0-9_.-]+$/.test(release)) {
235
+ throw new Error("Invalid release name");
236
+ }
237
+ if (!/^[a-zA-Z0-9_]+$/.test(arch)) {
238
+ throw new Error("Invalid architecture");
239
+ }
240
+ const code = await terminal_1.AgentTerminal.exec(socket, getLxcTerminalName(endpoint, name), "lxc-create", ["-n", name, "-t", "download", "--", "--dist", dist, "--release", release, "--arch", arch], LXC_PATH);
241
+ if (code !== 0) {
242
+ throw new Error("Failed to create LXC container");
243
+ }
244
+ }
245
+ exports.createContainer = createContainer;
246
+ async function getDistributions() {
247
+ const distributions = [];
248
+ try {
249
+ const res = await (0, promisify_child_process_1.spawn)("lxc-create", ["-t", "download", "--", "--list"], {
250
+ encoding: "utf-8",
251
+ timeout: 30000,
252
+ });
253
+ if (!res.stdout) {
254
+ return distributions;
255
+ }
256
+ const lines = res.stdout.toString().trim().split("\n");
257
+ for (const line of lines) {
258
+ const parts = line.trim().split(/\s+/);
259
+ if (parts.length >= 3 && parts[0] !== "DIST" && !line.startsWith("-")) {
260
+ distributions.push({
261
+ dist: parts[0],
262
+ release: parts[1],
263
+ arch: parts[2],
264
+ variant: parts[3] || "default",
265
+ });
266
+ }
267
+ }
268
+ }
269
+ catch (e) {
270
+ console.error("[lxc] Failed to get distributions:", e instanceof Error ? e.message : e);
271
+ }
272
+ return distributions;
273
+ }
274
+ exports.getDistributions = getDistributions;
275
+ function joinExecTerminal(socket, endpoint, name, shell) {
276
+ const terminalName = getLxcExecTerminalName(endpoint, name, 0);
277
+ let terminal = terminal_1.AgentTerminal.getTerminal(terminalName);
278
+ if (!terminal) {
279
+ terminal = new terminal_1.AgentTerminal(socket, terminalName, "lxc-attach", ["-n", name, "--", shell], LXC_PATH);
280
+ terminal.rows = terminal_1.TERMINAL_ROWS;
281
+ }
282
+ else {
283
+ // Update socket reference so output goes to the current connection
284
+ terminal.socket = socket;
285
+ }
286
+ terminal.start();
287
+ }
288
+ exports.joinExecTerminal = joinExecTerminal;
package/dist/server.js ADDED
@@ -0,0 +1,270 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.createAgentServer = void 0;
27
+ const http_1 = require("http");
28
+ const socket_io_1 = require("socket.io");
29
+ const lxc = __importStar(require("./lxc"));
30
+ const terminal_1 = require("./terminal");
31
+ function createAgentServer(config) {
32
+ const httpServer = (0, http_1.createServer)();
33
+ const io = new socket_io_1.Server(httpServer);
34
+ io.on("connection", (socket) => {
35
+ // The main server sends its own endpoint string in the header so we can
36
+ // echo it back in push events (lxcContainerList, terminalWrite, etc.)
37
+ const endpoint = socket.handshake.headers["endpoint"] || "";
38
+ let loggedIn = false;
39
+ console.log(`[agent] Main server connected (endpoint: ${endpoint || "<none>"})`);
40
+ // Announce ourselves — main server checks version >= 1.4.0
41
+ socket.emit("info", {
42
+ version: config.version,
43
+ lxcAvailable: true,
44
+ });
45
+ socket.on("disconnect", () => {
46
+ console.log("[agent] Main server disconnected");
47
+ // Leave interactive terminals open (lxc-attach) across reconnects;
48
+ // only clean up non-interactive progress terminals which are already
49
+ // gone by the time the process exits.
50
+ });
51
+ socket.on("login", (data, callback) => {
52
+ const cb = typeof callback === "function" ? callback : null;
53
+ if (typeof data !== "object" || data === null) {
54
+ cb?.({ ok: false,
55
+ msg: "Invalid login data" });
56
+ return;
57
+ }
58
+ const { username, password } = data;
59
+ if (username === config.username && password === config.password) {
60
+ loggedIn = true;
61
+ console.log(`[agent] Authenticated as ${username}`);
62
+ cb?.({ ok: true });
63
+ }
64
+ else {
65
+ console.warn(`[agent] Login failed for ${username}`);
66
+ cb?.({ ok: false,
67
+ msg: "Invalid credentials" });
68
+ }
69
+ });
70
+ // The main server proxies browser requests as:
71
+ // socket.emit("agent", targetEndpoint, eventName, ...args)
72
+ socket.on("agent", async (targetEndpoint, eventName, ...args) => {
73
+ if (!loggedIn) {
74
+ return;
75
+ }
76
+ if (typeof targetEndpoint !== "string" || typeof eventName !== "string") {
77
+ return;
78
+ }
79
+ // Only handle events addressed to this endpoint (or broadcast)
80
+ if (targetEndpoint !== endpoint && targetEndpoint !== "") {
81
+ return;
82
+ }
83
+ await dispatch(socket, endpoint, eventName, args);
84
+ });
85
+ });
86
+ return { io,
87
+ httpServer };
88
+ }
89
+ exports.createAgentServer = createAgentServer;
90
+ async function dispatch(socket, endpoint, eventName, args) {
91
+ // Pop ack callback if the last argument is a function
92
+ const callback = typeof args[args.length - 1] === "function"
93
+ ? args.pop()
94
+ : null;
95
+ const ok = (msg, extra) => callback?.({ ok: true,
96
+ ...(msg ? { msg,
97
+ msgi18n: true } : {}),
98
+ ...extra });
99
+ const fail = (e) => callback?.({ ok: false,
100
+ msg: e instanceof Error ? e.message : String(e) });
101
+ const pushList = async () => {
102
+ const list = await lxc.getContainerList(endpoint);
103
+ socket.emit("agent", "lxcContainerList", { ok: true,
104
+ lxcContainerList: list,
105
+ endpoint });
106
+ };
107
+ try {
108
+ switch (eventName) {
109
+ case "requestLxcContainerList": {
110
+ await pushList();
111
+ ok("Updated");
112
+ break;
113
+ }
114
+ case "getLxcContainer": {
115
+ const [name] = args;
116
+ if (typeof name !== "string") {
117
+ throw new Error("Name must be a string");
118
+ }
119
+ const container = await lxc.getContainer(name, endpoint);
120
+ callback?.({ ok: true,
121
+ container });
122
+ break;
123
+ }
124
+ case "startLxcContainer": {
125
+ const [name] = args;
126
+ if (typeof name !== "string") {
127
+ throw new Error("Name must be a string");
128
+ }
129
+ await lxc.startContainer(socket, endpoint, name);
130
+ ok("Started");
131
+ await pushList();
132
+ break;
133
+ }
134
+ case "stopLxcContainer": {
135
+ const [name] = args;
136
+ if (typeof name !== "string") {
137
+ throw new Error("Name must be a string");
138
+ }
139
+ await lxc.stopContainer(socket, endpoint, name);
140
+ ok("Stopped");
141
+ await pushList();
142
+ break;
143
+ }
144
+ case "restartLxcContainer": {
145
+ const [name] = args;
146
+ if (typeof name !== "string") {
147
+ throw new Error("Name must be a string");
148
+ }
149
+ await lxc.restartContainer(socket, endpoint, name);
150
+ ok("Restarted");
151
+ await pushList();
152
+ break;
153
+ }
154
+ case "freezeLxcContainer": {
155
+ const [name] = args;
156
+ if (typeof name !== "string") {
157
+ throw new Error("Name must be a string");
158
+ }
159
+ await lxc.freezeContainer(socket, endpoint, name);
160
+ ok("Frozen");
161
+ await pushList();
162
+ break;
163
+ }
164
+ case "unfreezeLxcContainer": {
165
+ const [name] = args;
166
+ if (typeof name !== "string") {
167
+ throw new Error("Name must be a string");
168
+ }
169
+ await lxc.unfreezeContainer(socket, endpoint, name);
170
+ ok("Unfrozen");
171
+ await pushList();
172
+ break;
173
+ }
174
+ case "deleteLxcContainer": {
175
+ const [name] = args;
176
+ if (typeof name !== "string") {
177
+ throw new Error("Name must be a string");
178
+ }
179
+ const container = await lxc.getContainer(name, endpoint);
180
+ await lxc.deleteContainer(socket, endpoint, name, container.status);
181
+ await pushList();
182
+ ok("Destroyed");
183
+ break;
184
+ }
185
+ case "saveLxcConfig": {
186
+ const [name, configContent] = args;
187
+ if (typeof name !== "string") {
188
+ throw new Error("Name must be a string");
189
+ }
190
+ if (typeof configContent !== "string") {
191
+ throw new Error("Config must be a string");
192
+ }
193
+ await lxc.saveConfig(name, configContent);
194
+ ok("Saved");
195
+ break;
196
+ }
197
+ case "createLxcContainer": {
198
+ const [name, dist, release, arch] = args;
199
+ await lxc.createContainer(socket, endpoint, name, dist, release, arch);
200
+ await pushList();
201
+ ok("Created");
202
+ break;
203
+ }
204
+ case "getLxcDistributions": {
205
+ const distributions = await lxc.getDistributions();
206
+ callback?.({ ok: true,
207
+ distributions });
208
+ break;
209
+ }
210
+ case "lxcExecTerminal": {
211
+ const [name, shell] = args;
212
+ if (typeof name !== "string") {
213
+ throw new Error("Name must be a string");
214
+ }
215
+ if (typeof shell !== "string") {
216
+ throw new Error("Shell must be a string");
217
+ }
218
+ lxc.joinExecTerminal(socket, endpoint, name, shell);
219
+ ok();
220
+ break;
221
+ }
222
+ case "terminalInput": {
223
+ const [terminalName, cmd] = args;
224
+ if (typeof terminalName !== "string") {
225
+ throw new Error("Terminal name must be a string");
226
+ }
227
+ if (typeof cmd !== "string") {
228
+ throw new Error("Command must be a string");
229
+ }
230
+ const terminal = terminal_1.AgentTerminal.getTerminal(terminalName);
231
+ if (!terminal) {
232
+ throw new Error("Terminal not found");
233
+ }
234
+ terminal.write(cmd);
235
+ break;
236
+ }
237
+ case "terminalJoin": {
238
+ const [terminalName] = args;
239
+ if (typeof terminalName !== "string") {
240
+ throw new Error("Terminal name must be a string");
241
+ }
242
+ const buffer = terminal_1.AgentTerminal.getTerminal(terminalName)?.getBuffer() ?? "";
243
+ callback?.({ ok: true,
244
+ buffer });
245
+ break;
246
+ }
247
+ case "terminalResize": {
248
+ const [terminalName, rows, cols] = args;
249
+ if (typeof terminalName !== "string") {
250
+ break;
251
+ }
252
+ const terminal = terminal_1.AgentTerminal.getTerminal(terminalName);
253
+ if (terminal) {
254
+ if (typeof rows === "number") {
255
+ terminal.rows = rows;
256
+ }
257
+ if (typeof cols === "number") {
258
+ terminal.cols = cols;
259
+ }
260
+ }
261
+ break;
262
+ }
263
+ default:
264
+ console.warn(`[agent] Unknown event: ${eventName}`);
265
+ }
266
+ }
267
+ catch (e) {
268
+ fail(e);
269
+ }
270
+ }
@@ -0,0 +1,144 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || function (mod) {
19
+ if (mod && mod.__esModule) return mod;
20
+ var result = {};
21
+ if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
22
+ __setModuleDefault(result, mod);
23
+ return result;
24
+ };
25
+ Object.defineProperty(exports, "__esModule", { value: true });
26
+ exports.AgentTerminal = exports.PROGRESS_TERMINAL_ROWS = exports.TERMINAL_ROWS = exports.TERMINAL_COLS = void 0;
27
+ const pty = __importStar(require("@homebridge/node-pty-prebuilt-multiarch"));
28
+ exports.TERMINAL_COLS = 105;
29
+ exports.TERMINAL_ROWS = 10;
30
+ exports.PROGRESS_TERMINAL_ROWS = 8;
31
+ const BUFFER_SIZE = 100;
32
+ class AgentTerminal {
33
+ name;
34
+ file;
35
+ args;
36
+ cwd;
37
+ static terminalMap = new Map();
38
+ _ptyProcess;
39
+ buffer = [];
40
+ _rows = exports.TERMINAL_ROWS;
41
+ _cols = exports.TERMINAL_COLS;
42
+ exitCallback;
43
+ /** Exposed so joinExecTerminal can update the socket on reconnect */
44
+ socket;
45
+ constructor(socket, name, file, args, cwd) {
46
+ this.name = name;
47
+ this.file = file;
48
+ this.args = args;
49
+ this.cwd = cwd;
50
+ this.socket = socket;
51
+ AgentTerminal.terminalMap.set(name, this);
52
+ }
53
+ get rows() {
54
+ return this._rows;
55
+ }
56
+ set rows(v) {
57
+ this._rows = v;
58
+ try {
59
+ this._ptyProcess?.resize(this._cols, v);
60
+ }
61
+ catch { /* ignore */ }
62
+ }
63
+ get cols() {
64
+ return this._cols;
65
+ }
66
+ set cols(v) {
67
+ this._cols = v;
68
+ try {
69
+ this._ptyProcess?.resize(v, this._rows);
70
+ }
71
+ catch { /* ignore */ }
72
+ }
73
+ start() {
74
+ if (this._ptyProcess) {
75
+ return;
76
+ }
77
+ try {
78
+ this._ptyProcess = pty.spawn(this.file, this.args, {
79
+ name: this.name,
80
+ cwd: this.cwd,
81
+ cols: this._cols,
82
+ rows: this._rows,
83
+ });
84
+ this._ptyProcess.onData((data) => {
85
+ if (this.buffer.length >= BUFFER_SIZE) {
86
+ this.buffer.shift();
87
+ }
88
+ this.buffer.push(data);
89
+ this.socket.emit("agent", "terminalWrite", this.name, data);
90
+ });
91
+ this._ptyProcess.onExit(({ exitCode }) => {
92
+ this.socket.emit("agent", "terminalExit", this.name, exitCode);
93
+ AgentTerminal.terminalMap.delete(this.name);
94
+ this.exitCallback?.(exitCode);
95
+ });
96
+ }
97
+ catch (error) {
98
+ const msg = error instanceof Error ? error.message : String(error);
99
+ const exitCode = Number(msg.split(" ").pop()) || 1;
100
+ this.socket.emit("agent", "terminalExit", this.name, exitCode);
101
+ AgentTerminal.terminalMap.delete(this.name);
102
+ this.exitCallback?.(exitCode);
103
+ }
104
+ }
105
+ write(input) {
106
+ this._ptyProcess?.write(input);
107
+ }
108
+ close() {
109
+ this._ptyProcess?.write("\x03");
110
+ }
111
+ getBuffer() {
112
+ return this.buffer.join("");
113
+ }
114
+ onExit(cb) {
115
+ this.exitCallback = cb;
116
+ }
117
+ static getTerminal(name) {
118
+ return AgentTerminal.terminalMap.get(name);
119
+ }
120
+ /**
121
+ * Spawn a non-interactive terminal for a command, resolve with its exit code.
122
+ * Output is streamed back to the main server via `socket`.
123
+ */
124
+ static exec(socket, terminalName, file, args, cwd) {
125
+ return new Promise((resolve, reject) => {
126
+ if (AgentTerminal.terminalMap.has(terminalName)) {
127
+ reject(new Error("Another operation is already running, please try again later."));
128
+ return;
129
+ }
130
+ const terminal = new AgentTerminal(socket, terminalName, file, args, cwd);
131
+ terminal.rows = exports.PROGRESS_TERMINAL_ROWS;
132
+ terminal.onExit(resolve);
133
+ terminal.start();
134
+ });
135
+ }
136
+ /** Remove all terminals — called on socket disconnect to free PTYs */
137
+ static closeAll() {
138
+ for (const terminal of AgentTerminal.terminalMap.values()) {
139
+ terminal.close();
140
+ }
141
+ AgentTerminal.terminalMap.clear();
142
+ }
143
+ }
144
+ exports.AgentTerminal = AgentTerminal;
package/package.json ADDED
@@ -0,0 +1,31 @@
1
+ {
2
+ "name": "@tyevco/homelab-lxc-agent",
3
+ "version": "1.0.0",
4
+ "description": "Lightweight LXC agent for Homelab",
5
+ "bin": {
6
+ "homelab-lxc-agent": "bin/homelab-lxc-agent.js"
7
+ },
8
+ "scripts": {
9
+ "build": "tsc",
10
+ "prepare": "tsc",
11
+ "start": "node dist/index.js",
12
+ "dev": "ts-node src/index.ts"
13
+ },
14
+ "files": [
15
+ "dist/",
16
+ "bin/"
17
+ ],
18
+ "dependencies": {
19
+ "@homebridge/node-pty-prebuilt-multiarch": "0.11.14",
20
+ "promisify-child-process": "^4.1.2",
21
+ "socket.io": "~4.8.1"
22
+ },
23
+ "devDependencies": {
24
+ "@types/node": "^20.0.0",
25
+ "ts-node": "^10.9.1",
26
+ "typescript": "~5.2.2"
27
+ },
28
+ "engines": {
29
+ "node": ">=18"
30
+ }
31
+ }