geniejars 0.2.7
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/COMMON-ISSUES.md +55 -0
- package/LICENSE.md +9 -0
- package/README.md +218 -0
- package/geniejars.config.example.json +21 -0
- package/package.json +22 -0
- package/script/geniejars.mjs +667 -0
- package/script/setup-geniejars.mjs +208 -0
- package/script/uninstall-geniejars.mjs +122 -0
- package/src/agent.mjs +194 -0
- package/src/allocate.mjs +96 -0
- package/src/index.mjs +5 -0
- package/src/normalize.mjs +26 -0
- package/src/pool.mjs +158 -0
- package/src/prepare.mjs +219 -0
- package/src/run.mjs +162 -0
- package/src/server.mjs +262 -0
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// One-time system setup — must be run as root (sudo node script/setup-geniejars.mjs)
|
|
3
|
+
//
|
|
4
|
+
// Creates geniejars, groups, ACLs, sudoers rule, and socket directory.
|
|
5
|
+
// Idempotent — safe to run again after changing pool size.
|
|
6
|
+
|
|
7
|
+
import { execFile as _execFile } from 'child_process'
|
|
8
|
+
import { promisify } from 'util'
|
|
9
|
+
import fs from 'fs/promises'
|
|
10
|
+
import path from 'path'
|
|
11
|
+
import { fileURLToPath } from 'url'
|
|
12
|
+
import { loadConfig, allNames } from '../src/pool.mjs'
|
|
13
|
+
|
|
14
|
+
const execFile = promisify(_execFile)
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
|
16
|
+
const agentScript = path.resolve(__dirname, '../src/agent.mjs')
|
|
17
|
+
|
|
18
|
+
// Assert running as root
|
|
19
|
+
if (process.getuid() !== 0) {
|
|
20
|
+
console.error('Error: setup must be run as root. Use: sudo node script/setup-geniejars.mjs')
|
|
21
|
+
process.exit(1)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const config = await loadConfig()
|
|
25
|
+
const { managerUser, managerGroup, homeBase, socketDir, nodePath } = config
|
|
26
|
+
const commonUser = config.commonUser ?? 'subcommon'
|
|
27
|
+
const commonDir = config.commonDir ?? path.join(homeBase, commonUser)
|
|
28
|
+
const names = allNames(config)
|
|
29
|
+
|
|
30
|
+
async function run(cmd, args) {
|
|
31
|
+
try {
|
|
32
|
+
await execFile(cmd, args)
|
|
33
|
+
} catch (err) {
|
|
34
|
+
// surface stderr but let caller decide if it's fatal
|
|
35
|
+
throw new Error(`${cmd} ${args.join(' ')}: ${err.stderr?.trim() ?? err.message}`)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function groupExists(group) {
|
|
40
|
+
try { await execFile('getent', ['group', group]); return true } catch { return false }
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
async function userExists(user) {
|
|
44
|
+
try { await execFile('id', [user]); return true } catch { return false }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function userInGroup(user, group) {
|
|
48
|
+
try {
|
|
49
|
+
const { stdout } = await execFile('id', ['-Gn', user])
|
|
50
|
+
return stdout.trim().split(/\s+/).includes(group)
|
|
51
|
+
} catch { return false }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
console.log('=== geniejars system setup ===\n')
|
|
55
|
+
|
|
56
|
+
// 1. Create manager group
|
|
57
|
+
if (await groupExists(managerGroup)) {
|
|
58
|
+
console.log(`group ${managerGroup}: already exists`)
|
|
59
|
+
} else {
|
|
60
|
+
await run('groupadd', [managerGroup])
|
|
61
|
+
console.log(`group ${managerGroup}: created`)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// 2. Add manager user to manager group
|
|
65
|
+
if (await userInGroup(managerUser, managerGroup)) {
|
|
66
|
+
console.log(`${managerUser} in ${managerGroup}: already set`)
|
|
67
|
+
} else {
|
|
68
|
+
await run('usermod', ['-aG', managerGroup, managerUser])
|
|
69
|
+
console.log(`${managerUser} in ${managerGroup}: added`)
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// 3. Create each geniejars
|
|
73
|
+
for (const name of names) {
|
|
74
|
+
const home = path.join(homeBase, name)
|
|
75
|
+
|
|
76
|
+
// Create group for this geniejars
|
|
77
|
+
if (await groupExists(name)) {
|
|
78
|
+
console.log(`group ${name}: already exists`)
|
|
79
|
+
} else {
|
|
80
|
+
await run('groupadd', ['--system', name])
|
|
81
|
+
console.log(`group ${name}: created`)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Create user
|
|
85
|
+
if (await userExists(name)) {
|
|
86
|
+
console.log(`user ${name}: already exists`)
|
|
87
|
+
} else {
|
|
88
|
+
await run('useradd', [
|
|
89
|
+
'--system',
|
|
90
|
+
'--create-home',
|
|
91
|
+
'--home-dir', home,
|
|
92
|
+
'--shell', '/usr/bin/nologin',
|
|
93
|
+
'--gid', name,
|
|
94
|
+
name,
|
|
95
|
+
])
|
|
96
|
+
console.log(`user ${name}: created`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Add manager user to this geniejars's group (so manager can read/write home)
|
|
100
|
+
if (await userInGroup(managerUser, name)) {
|
|
101
|
+
console.log(`${managerUser} in group ${name}: already set`)
|
|
102
|
+
} else {
|
|
103
|
+
await run('usermod', ['-aG', name, managerUser])
|
|
104
|
+
console.log(`${managerUser} in group ${name}: added`)
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Set home dir: owned by geniejars:geniejars, setgid (2770)
|
|
108
|
+
await run('chown', [`${name}:${name}`, home])
|
|
109
|
+
await run('chmod', ['2770', home])
|
|
110
|
+
console.log(`${name} home permissions: set`)
|
|
111
|
+
|
|
112
|
+
// Default ACLs so manager-written files are accessible by geniejars and vice versa
|
|
113
|
+
await run('setfacl', ['-d', '-m', `u:${managerUser}:rwX`, home])
|
|
114
|
+
await run('setfacl', ['-d', '-m', `u:${name}:rwX`, home])
|
|
115
|
+
console.log(`${name} default ACLs: set`)
|
|
116
|
+
|
|
117
|
+
// Apply ACLs recursively to any files already in the home dir (e.g. created by system at useradd time)
|
|
118
|
+
await run('setfacl', ['-R', '-m', `u:${managerUser}:rwX`, home])
|
|
119
|
+
console.log(`${name} recursive ACLs: set`)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// 4. Create common user and shared directory
|
|
123
|
+
// commonDir is owned by the manager (write access) with group=subcommon (read access).
|
|
124
|
+
// All geniejars are added to the subcommon group so they can traverse and read it.
|
|
125
|
+
|
|
126
|
+
if (await groupExists(commonUser)) {
|
|
127
|
+
console.log(`group ${commonUser}: already exists`)
|
|
128
|
+
} else {
|
|
129
|
+
await run('groupadd', ['--system', commonUser])
|
|
130
|
+
console.log(`group ${commonUser}: created`)
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
if (await userExists(commonUser)) {
|
|
134
|
+
console.log(`user ${commonUser}: already exists`)
|
|
135
|
+
} else {
|
|
136
|
+
await run('useradd', [
|
|
137
|
+
'--system',
|
|
138
|
+
'--create-home',
|
|
139
|
+
'--home-dir', commonDir,
|
|
140
|
+
'--shell', '/usr/bin/nologin',
|
|
141
|
+
'--gid', commonUser,
|
|
142
|
+
commonUser,
|
|
143
|
+
])
|
|
144
|
+
console.log(`user ${commonUser}: created`)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Manager owns commonDir so they can install tools freely.
|
|
148
|
+
// subcommon group (sub* users) gets r-x only. Others get nothing.
|
|
149
|
+
// setgid ensures new files/dirs created by manager inherit the subcommon group.
|
|
150
|
+
// Default ACLs enforce correct permissions on new content automatically.
|
|
151
|
+
await fs.mkdir(commonDir, { recursive: true })
|
|
152
|
+
await run('chown', [`${managerUser}:${commonUser}`, commonDir])
|
|
153
|
+
await run('chmod', ['2750', commonDir])
|
|
154
|
+
await run('setfacl', ['-d', '-m', `u:${managerUser}:rwX`, commonDir])
|
|
155
|
+
await run('setfacl', ['-d', '-m', `g:${commonUser}:rX`, commonDir])
|
|
156
|
+
await run('setfacl', ['-d', '-m', 'o::---', commonDir])
|
|
157
|
+
console.log(`${commonUser} home ${commonDir}: permissions set`)
|
|
158
|
+
|
|
159
|
+
// Add manager to subcommon group so chgrp works without sudo after re-login.
|
|
160
|
+
if (await userInGroup(managerUser, commonUser)) {
|
|
161
|
+
console.log(`${managerUser} in ${commonUser}: already set`)
|
|
162
|
+
} else {
|
|
163
|
+
await run('usermod', ['-aG', commonUser, managerUser])
|
|
164
|
+
console.log(`${managerUser} in ${commonUser}: added`)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Add each geniejars to the subcommon group so they can access commonDir.
|
|
168
|
+
for (const name of names) {
|
|
169
|
+
if (await userInGroup(name, commonUser)) {
|
|
170
|
+
console.log(`${name} in ${commonUser}: already set`)
|
|
171
|
+
} else {
|
|
172
|
+
await run('usermod', ['-aG', commonUser, name])
|
|
173
|
+
console.log(`${name} in ${commonUser}: added`)
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 5. Write sudoers rule — restrict to running the agent script only
|
|
178
|
+
const userList = names.join(', ')
|
|
179
|
+
const sudoersEntry = [
|
|
180
|
+
'# geniejars agent — allows manager to start agents as geniejars',
|
|
181
|
+
`${managerUser} ALL = (${userList}) NOPASSWD: ${nodePath} ${agentScript} *`,
|
|
182
|
+
'',
|
|
183
|
+
].join('\n')
|
|
184
|
+
|
|
185
|
+
const sudoersFile = '/etc/sudoers.d/geniejars'
|
|
186
|
+
await fs.writeFile(sudoersFile, sudoersEntry, { mode: 0o440 })
|
|
187
|
+
console.log(`sudoers rule: written to ${sudoersFile}`)
|
|
188
|
+
|
|
189
|
+
// 6. Create socket directory: sticky + world-writable (like /tmp) so each geniejars
|
|
190
|
+
// can create their own socket. Socket files are 0660 owned by the geniejars, and the
|
|
191
|
+
// manager is in each geniejars's group — so only the manager can connect to each socket.
|
|
192
|
+
// Other geniejars cannot connect to each other's sockets (not in each other's groups).
|
|
193
|
+
await fs.mkdir(socketDir, { recursive: true })
|
|
194
|
+
await run('chown', ['root:root', socketDir])
|
|
195
|
+
await run('chmod', ['1777', socketDir])
|
|
196
|
+
console.log(`socket dir ${socketDir}: ready`)
|
|
197
|
+
|
|
198
|
+
const tmpfilesConf = `/etc/tmpfiles.d/geniejars.conf`
|
|
199
|
+
await fs.writeFile(tmpfilesConf, `d ${socketDir} 1777 root root -\n`)
|
|
200
|
+
console.log(`tmpfiles.d: written to ${tmpfilesConf}`)
|
|
201
|
+
|
|
202
|
+
console.log('\n=== setup complete ===')
|
|
203
|
+
console.log(`\nIMPORTANT: group membership changes require a new login session to take effect.`)
|
|
204
|
+
console.log(`Please log out and log back in, or run: su ${managerUser}`)
|
|
205
|
+
console.log(`Note: 'su' is a temporary workaround — a full re-login is recommended.`)
|
|
206
|
+
console.log(`After that, no more sudo or su required.`)
|
|
207
|
+
console.log(`Then: geniejars sys up`)
|
|
208
|
+
console.log(`\n🧙 GenieJars ready! Happy summoning your agents!`)
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Uninstall — must be run as root (sudo geniejars uninstall)
|
|
3
|
+
//
|
|
4
|
+
// Removes all geniejars, groups, home dirs, sudoers rule, and tmp state.
|
|
5
|
+
// Does NOT remove the manager user or the manager's own home directory.
|
|
6
|
+
|
|
7
|
+
import { execFile as _execFile } from 'child_process'
|
|
8
|
+
import { promisify } from 'util'
|
|
9
|
+
import fs from 'fs/promises'
|
|
10
|
+
import path from 'path'
|
|
11
|
+
import { loadConfig, allNames } from '../src/pool.mjs'
|
|
12
|
+
|
|
13
|
+
const execFile = promisify(_execFile)
|
|
14
|
+
|
|
15
|
+
if (process.getuid() !== 0) {
|
|
16
|
+
console.error('Error: uninstall must be run as root. Use: sudo geniejars uninstall')
|
|
17
|
+
process.exit(1)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
const config = await loadConfig()
|
|
21
|
+
const { managerUser, managerGroup, homeBase, socketDir } = config
|
|
22
|
+
const commonUser = config.commonUser ?? 'subcommon'
|
|
23
|
+
const commonDir = config.commonDir ?? path.join(homeBase, commonUser)
|
|
24
|
+
const names = allNames(config)
|
|
25
|
+
|
|
26
|
+
async function run(cmd, args, { silent = false } = {}) {
|
|
27
|
+
try {
|
|
28
|
+
await execFile(cmd, args)
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (!silent) console.warn(` warning: ${cmd} ${args.join(' ')}: ${err.stderr?.trim() ?? err.message}`)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function userExists(user) {
|
|
35
|
+
try { await execFile('id', [user]); return true } catch { return false }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function groupExists(group) {
|
|
39
|
+
try { await execFile('getent', ['group', group]); return true } catch { return false }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
console.log('=== geniejars uninstall ===\n')
|
|
43
|
+
|
|
44
|
+
// 1. Stop the shell server gracefully if running
|
|
45
|
+
const pidFile = path.join(socketDir, 'server.pid')
|
|
46
|
+
try {
|
|
47
|
+
const pid = parseInt(await fs.readFile(pidFile, 'utf8'), 10)
|
|
48
|
+
if (!isNaN(pid)) {
|
|
49
|
+
try { process.kill(pid, 'SIGTERM') } catch {}
|
|
50
|
+
// Wait up to 3s for it to exit
|
|
51
|
+
for (let i = 0; i < 15; i++) {
|
|
52
|
+
await new Promise(r => setTimeout(r, 200))
|
|
53
|
+
try { process.kill(pid, 0) } catch { break }
|
|
54
|
+
}
|
|
55
|
+
try { process.kill(pid, 'SIGKILL') } catch {}
|
|
56
|
+
console.log('shell server: stopped')
|
|
57
|
+
}
|
|
58
|
+
} catch { console.log('shell server: not running') }
|
|
59
|
+
|
|
60
|
+
// 2. Remove each geniejars and their home directory
|
|
61
|
+
for (const name of names) {
|
|
62
|
+
const home = path.join(homeBase, name)
|
|
63
|
+
if (await userExists(name)) {
|
|
64
|
+
await run('userdel', [name])
|
|
65
|
+
console.log(`user ${name}: removed`)
|
|
66
|
+
} else {
|
|
67
|
+
console.log(`user ${name}: not found`)
|
|
68
|
+
}
|
|
69
|
+
await fs.rm(home, { recursive: true, force: true })
|
|
70
|
+
console.log(`home ${home}: removed`)
|
|
71
|
+
if (await groupExists(name)) {
|
|
72
|
+
await run('groupdel', [name])
|
|
73
|
+
console.log(`group ${name}: removed`)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 3. Remove common user and directory
|
|
78
|
+
if (await userExists(commonUser)) {
|
|
79
|
+
await run('userdel', [commonUser])
|
|
80
|
+
console.log(`user ${commonUser}: removed`)
|
|
81
|
+
} else {
|
|
82
|
+
console.log(`user ${commonUser}: not found`)
|
|
83
|
+
}
|
|
84
|
+
await fs.rm(commonDir, { recursive: true, force: true })
|
|
85
|
+
console.log(`commonDir ${commonDir}: removed`)
|
|
86
|
+
if (await groupExists(commonUser)) {
|
|
87
|
+
await run('groupdel', [commonUser])
|
|
88
|
+
console.log(`group ${commonUser}: removed`)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// 4. Remove manager group
|
|
92
|
+
if (await groupExists(managerGroup)) {
|
|
93
|
+
await run('groupdel', [managerGroup])
|
|
94
|
+
console.log(`group ${managerGroup}: removed`)
|
|
95
|
+
} else {
|
|
96
|
+
console.log(`group ${managerGroup}: not found`)
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// 5. Remove sudoers rule
|
|
100
|
+
await fs.unlink('/etc/sudoers.d/geniejars').catch(() => {})
|
|
101
|
+
console.log('sudoers rule: removed')
|
|
102
|
+
|
|
103
|
+
// 6. Remove tmpfiles.d config
|
|
104
|
+
await fs.unlink('/etc/tmpfiles.d/geniejars.conf').catch(() => {})
|
|
105
|
+
console.log('tmpfiles.d config: removed')
|
|
106
|
+
|
|
107
|
+
// 7. Clean up /tmp state
|
|
108
|
+
const toRemove = [
|
|
109
|
+
socketDir,
|
|
110
|
+
config.stateFile,
|
|
111
|
+
config.lockFile,
|
|
112
|
+
config.logDir,
|
|
113
|
+
]
|
|
114
|
+
for (const p of toRemove) {
|
|
115
|
+
if (!p) continue
|
|
116
|
+
await fs.rm(p, { recursive: true, force: true })
|
|
117
|
+
console.log(`${p}: removed`)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
console.log('\n=== uninstall complete ===')
|
|
121
|
+
console.log(`\nNOTE: ${managerUser} may still have residual group memberships in this session.`)
|
|
122
|
+
console.log(`Run: su - ${managerUser} to get a clean login.`)
|
package/src/agent.mjs
ADDED
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// Geniejars agent — runs as the geniejars, listens on a Unix socket.
|
|
2
|
+
// Started at boot via systemd with an absolute path to node:
|
|
3
|
+
// /usr/bin/node /path/to/src/agent.mjs <geniejars-name>
|
|
4
|
+
//
|
|
5
|
+
// Handles requests from the manager over a newline-delimited JSON protocol:
|
|
6
|
+
// run — execute a command, stream stdout/stderr, report exit
|
|
7
|
+
// start — start the one long-running main process, stream output
|
|
8
|
+
// stop — kill the main process
|
|
9
|
+
// status — report whether main process is running
|
|
10
|
+
|
|
11
|
+
import net from 'net'
|
|
12
|
+
import { spawn } from 'child_process'
|
|
13
|
+
import fs from 'fs/promises'
|
|
14
|
+
import path from 'path'
|
|
15
|
+
import { loadConfig } from './pool.mjs'
|
|
16
|
+
|
|
17
|
+
const name = process.argv[2]
|
|
18
|
+
if (!name) {
|
|
19
|
+
console.error('Usage: agent <geniejars-name>')
|
|
20
|
+
process.exit(1)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const config = await loadConfig()
|
|
24
|
+
const sockPath = path.join(config.socketDir, `${name}.sock`)
|
|
25
|
+
|
|
26
|
+
await fs.mkdir(config.socketDir, { recursive: true })
|
|
27
|
+
await fs.unlink(sockPath).catch(() => {})
|
|
28
|
+
|
|
29
|
+
let mainProc = null // the one long-running process
|
|
30
|
+
|
|
31
|
+
// --- Idle timeout ---
|
|
32
|
+
const idleTimeoutMs = (config.agentIdleTimeout ?? 300) * 1000
|
|
33
|
+
let idleTimer = null
|
|
34
|
+
|
|
35
|
+
function scheduleIdleShutdown() {
|
|
36
|
+
if (idleTimeoutMs === 0 || mainProc) return
|
|
37
|
+
if (idleTimer) clearTimeout(idleTimer)
|
|
38
|
+
idleTimer = setTimeout(async () => {
|
|
39
|
+
if (mainProc) return // became busy — skip
|
|
40
|
+
server.close()
|
|
41
|
+
await fs.unlink(sockPath).catch(() => {})
|
|
42
|
+
process.exit(0)
|
|
43
|
+
}, idleTimeoutMs)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function resetIdleTimer() {
|
|
47
|
+
if (idleTimer) { clearTimeout(idleTimer); idleTimer = null }
|
|
48
|
+
scheduleIdleShutdown()
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function send(socket, obj) {
|
|
52
|
+
if (!socket.destroyed) socket.write(JSON.stringify(obj) + '\n')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function handleConnection(socket) {
|
|
56
|
+
let buf = ''
|
|
57
|
+
|
|
58
|
+
socket.on('data', chunk => {
|
|
59
|
+
buf += chunk.toString()
|
|
60
|
+
let nl
|
|
61
|
+
while ((nl = buf.indexOf('\n')) !== -1) {
|
|
62
|
+
const line = buf.slice(0, nl).trim()
|
|
63
|
+
buf = buf.slice(nl + 1)
|
|
64
|
+
if (!line) continue
|
|
65
|
+
let req
|
|
66
|
+
try { req = JSON.parse(line) } catch {
|
|
67
|
+
send(socket, { type: 'error', message: 'Invalid JSON' })
|
|
68
|
+
socket.destroy()
|
|
69
|
+
return
|
|
70
|
+
}
|
|
71
|
+
handleRequest(req, socket)
|
|
72
|
+
}
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
socket.on('error', () => {})
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function handleRequest(req, socket) {
|
|
79
|
+
switch (req.type) {
|
|
80
|
+
|
|
81
|
+
case 'run': {
|
|
82
|
+
resetIdleTimer()
|
|
83
|
+
let child
|
|
84
|
+
try {
|
|
85
|
+
child = spawn(req.cmd, req.args ?? [], {
|
|
86
|
+
cwd: req.cwd ?? process.env.HOME,
|
|
87
|
+
env: { ...process.env, ...(req.env ?? {}) },
|
|
88
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
89
|
+
})
|
|
90
|
+
} catch (err) {
|
|
91
|
+
send(socket, { type: 'error', message: err.message })
|
|
92
|
+
socket.end()
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
child.stdout.on('data', d => send(socket, { type: 'stdout', data: d.toString() }))
|
|
96
|
+
child.stderr.on('data', d => send(socket, { type: 'stderr', data: d.toString() }))
|
|
97
|
+
child.on('error', err => { send(socket, { type: 'error', message: err.message }); socket.end() })
|
|
98
|
+
child.on('close', code => { send(socket, { type: 'exit', code: code ?? 1 }); socket.end(); resetIdleTimer() })
|
|
99
|
+
break
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
case 'start': {
|
|
103
|
+
if (mainProc) {
|
|
104
|
+
send(socket, { type: 'error', message: 'Process already running' })
|
|
105
|
+
socket.end()
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
resetIdleTimer()
|
|
109
|
+
let child
|
|
110
|
+
try {
|
|
111
|
+
child = spawn(req.cmd, req.args ?? [], {
|
|
112
|
+
cwd: req.cwd ?? process.env.HOME,
|
|
113
|
+
env: { ...process.env, ...(req.env ?? {}) },
|
|
114
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
115
|
+
})
|
|
116
|
+
} catch (err) {
|
|
117
|
+
send(socket, { type: 'error', message: err.message })
|
|
118
|
+
socket.end()
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
mainProc = child
|
|
122
|
+
send(socket, { type: 'started', pid: child.pid })
|
|
123
|
+
|
|
124
|
+
child.stdout.on('data', d => send(socket, { type: 'stdout', data: d.toString() }))
|
|
125
|
+
child.stderr.on('data', d => send(socket, { type: 'stderr', data: d.toString() }))
|
|
126
|
+
child.on('error', err => {
|
|
127
|
+
send(socket, { type: 'error', message: err.message })
|
|
128
|
+
mainProc = null
|
|
129
|
+
resetIdleTimer()
|
|
130
|
+
socket.end()
|
|
131
|
+
})
|
|
132
|
+
child.on('close', code => {
|
|
133
|
+
send(socket, { type: 'exit', code: code ?? 1 })
|
|
134
|
+
mainProc = null
|
|
135
|
+
resetIdleTimer()
|
|
136
|
+
socket.end()
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
// If manager disconnects, process keeps running — detach listeners
|
|
140
|
+
socket.on('close', () => {
|
|
141
|
+
if (mainProc) {
|
|
142
|
+
mainProc.stdout.removeAllListeners('data')
|
|
143
|
+
mainProc.stderr.removeAllListeners('data')
|
|
144
|
+
}
|
|
145
|
+
})
|
|
146
|
+
break
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
case 'stop': {
|
|
150
|
+
if (!mainProc) {
|
|
151
|
+
send(socket, { type: 'error', message: 'No process running' })
|
|
152
|
+
} else {
|
|
153
|
+
mainProc.kill('SIGTERM')
|
|
154
|
+
send(socket, { type: 'stopped' })
|
|
155
|
+
}
|
|
156
|
+
socket.end()
|
|
157
|
+
break
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
case 'status': {
|
|
161
|
+
send(socket, { type: 'status', running: mainProc !== null, pid: mainProc?.pid ?? null })
|
|
162
|
+
socket.end()
|
|
163
|
+
break
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
default: {
|
|
167
|
+
send(socket, { type: 'error', message: `Unknown request type: ${req.type}` })
|
|
168
|
+
socket.end()
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const server = net.createServer(handleConnection)
|
|
174
|
+
|
|
175
|
+
server.listen(sockPath, async () => {
|
|
176
|
+
// Override any setgid inheritance from the directory — socket must be owned by
|
|
177
|
+
// this process's own uid:gid so the manager (in the geniejars's group) can connect.
|
|
178
|
+
await fs.chown(sockPath, process.getuid(), process.getgid())
|
|
179
|
+
await fs.chmod(sockPath, 0o660)
|
|
180
|
+
console.log(`[geniejars-agent] ${name} listening on ${sockPath}`)
|
|
181
|
+
scheduleIdleShutdown()
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
server.on('error', err => {
|
|
185
|
+
console.error('[geniejars-agent] Error:', err.message)
|
|
186
|
+
process.exit(1)
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
process.on('SIGTERM', async () => {
|
|
190
|
+
if (mainProc) mainProc.kill('SIGTERM')
|
|
191
|
+
server.close()
|
|
192
|
+
await fs.unlink(sockPath).catch(() => {})
|
|
193
|
+
process.exit(0)
|
|
194
|
+
})
|
package/src/allocate.mjs
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import fs from 'fs/promises'
|
|
2
|
+
import path from 'path'
|
|
3
|
+
import { execFile } from 'child_process'
|
|
4
|
+
import { promisify } from 'util'
|
|
5
|
+
import { acquireLock, readState, writeState, freeNames, homeDir } from './pool.mjs'
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile)
|
|
8
|
+
|
|
9
|
+
// Allocate a free geniejars. Returns the geniejars name.
|
|
10
|
+
// Options:
|
|
11
|
+
// backup: boolean (default false) - tar home before clearing
|
|
12
|
+
// clear: boolean (default true) - clear home dir contents
|
|
13
|
+
// tag: string (default null) - label stored in state
|
|
14
|
+
export async function allocate(config, options = {}) {
|
|
15
|
+
const { backup = false, clear = true, tag = null } = options
|
|
16
|
+
|
|
17
|
+
const release = await acquireLock(config)
|
|
18
|
+
try {
|
|
19
|
+
const state = await readState(config)
|
|
20
|
+
const free = freeNames(state)
|
|
21
|
+
if (free.length === 0) {
|
|
22
|
+
const total = Object.keys(state.pool).length
|
|
23
|
+
throw new Error(`No free geniejars available (pool size: ${total})`)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const name = free[0]
|
|
27
|
+
|
|
28
|
+
if (backup) await backupHome(config, name)
|
|
29
|
+
if (clear) await clearHome(config, name)
|
|
30
|
+
|
|
31
|
+
// Allocate ports
|
|
32
|
+
const freePorts = Object.entries(state.ports)
|
|
33
|
+
.filter(([, v]) => v === 'free')
|
|
34
|
+
.map(([k]) => Number(k))
|
|
35
|
+
.sort((a, b) => a - b)
|
|
36
|
+
if (freePorts.length < config.portsPerGeniejars) {
|
|
37
|
+
throw new Error(`Not enough free ports (need ${config.portsPerGeniejars}, have ${freePorts.length})`)
|
|
38
|
+
}
|
|
39
|
+
const assignedPorts = freePorts.slice(0, config.portsPerGeniejars)
|
|
40
|
+
for (const port of assignedPorts) state.ports[port] = name
|
|
41
|
+
|
|
42
|
+
state.pool[name] = {
|
|
43
|
+
status: 'occupied',
|
|
44
|
+
allocatedAt: new Date().toISOString(),
|
|
45
|
+
allocatedBy: config.managerUser,
|
|
46
|
+
tag: tag ?? null,
|
|
47
|
+
ports: assignedPorts,
|
|
48
|
+
prepared: null,
|
|
49
|
+
}
|
|
50
|
+
await writeState(config, state)
|
|
51
|
+
return name
|
|
52
|
+
} finally {
|
|
53
|
+
await release()
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Release a geniejars back to the free pool.
|
|
58
|
+
export async function deallocate(config, name) {
|
|
59
|
+
const release = await acquireLock(config)
|
|
60
|
+
try {
|
|
61
|
+
const state = await readState(config)
|
|
62
|
+
if (!(name in state.pool)) {
|
|
63
|
+
throw new Error(`Unknown geniejars: ${name}`)
|
|
64
|
+
}
|
|
65
|
+
if (state.pool[name].status !== 'occupied') {
|
|
66
|
+
throw new Error(`Geniejars ${name} is not currently occupied`)
|
|
67
|
+
}
|
|
68
|
+
// Free ports
|
|
69
|
+
const ports = state.pool[name].ports ?? []
|
|
70
|
+
for (const port of ports) state.ports[port] = 'free'
|
|
71
|
+
|
|
72
|
+
state.pool[name] = { status: 'free', allocatedAt: null, allocatedBy: null, tag: null, ports: null, prepared: null }
|
|
73
|
+
await writeState(config, state)
|
|
74
|
+
} finally {
|
|
75
|
+
await release()
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Remove all contents inside a geniejars's home dir (not the dir itself).
|
|
80
|
+
// Runs as the manager directly — manager has group write access via default ACLs.
|
|
81
|
+
export async function clearHome(config, name) {
|
|
82
|
+
const home = homeDir(config, name)
|
|
83
|
+
const entries = await fs.readdir(home).catch(() => [])
|
|
84
|
+
await Promise.all(entries.map(e => fs.rm(path.join(home, e), { recursive: true, force: true })))
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// Create a tar.gz snapshot of the geniejars's home into backupDir.
|
|
88
|
+
// Returns the path to the archive.
|
|
89
|
+
export async function backupHome(config, name) {
|
|
90
|
+
const home = homeDir(config, name)
|
|
91
|
+
await fs.mkdir(config.backupDir, { recursive: true })
|
|
92
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, '-')
|
|
93
|
+
const archive = path.join(config.backupDir, `${name}-${timestamp}.tar.gz`)
|
|
94
|
+
await execFileAsync('tar', ['-czf', archive, '-C', path.dirname(home), path.basename(home)])
|
|
95
|
+
return archive
|
|
96
|
+
}
|
package/src/index.mjs
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { loadConfig, poolName, allNames, homeDir, freeNames, occupiedNames, readState, writeState, initialState, resolveName, acquireLock } from './pool.mjs'
|
|
2
|
+
export { allocate, deallocate, clearHome, backupHome } from './allocate.mjs'
|
|
3
|
+
export { parseJFile, readJFile, applyJFile, prepare } from './prepare.mjs'
|
|
4
|
+
export { runAs, runShellAs, startAs, stopProcess, agentStatus, logFilePath, ensureAgent } from './run.mjs'
|
|
5
|
+
export { normalize, applyAcl } from './normalize.mjs'
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { execFile } from 'child_process'
|
|
2
|
+
import { promisify } from 'util'
|
|
3
|
+
import { homeDir } from './pool.mjs'
|
|
4
|
+
|
|
5
|
+
const execFileAsync = promisify(execFile)
|
|
6
|
+
|
|
7
|
+
// Re-apply ACLs to all manager-owned files in a geniejars's home.
|
|
8
|
+
// Grants the geniejars rw(X) on any file currently owned by the manager.
|
|
9
|
+
// Runs without sudo because the manager is in the geniejars's group (set up in Phase 1).
|
|
10
|
+
export async function normalize(config, name) {
|
|
11
|
+
const home = homeDir(config, name)
|
|
12
|
+
const manager = config.managerUser
|
|
13
|
+
const aclEntry = `u:${name}:rwX`
|
|
14
|
+
|
|
15
|
+
// find all files/dirs owned by the manager, then setfacl on them in batch
|
|
16
|
+
await execFileAsync('find', [
|
|
17
|
+
home,
|
|
18
|
+
'-user', manager,
|
|
19
|
+
'-exec', 'setfacl', '-m', aclEntry, '{}', '+'
|
|
20
|
+
])
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Apply a single ACL entry to a specific path.
|
|
24
|
+
export async function applyAcl(config, name, filePath) {
|
|
25
|
+
await execFileAsync('setfacl', ['-m', `u:${name}:rwX`, filePath])
|
|
26
|
+
}
|