@dr-co/dsh-node-client 0.0.0-stage → 0.0.60
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/README.md +276 -2
- package/bin/dshnc.mjs +337 -0
- package/lib/api.mjs +177 -0
- package/lib/approver.mjs +138 -0
- package/lib/ask.mjs +17 -0
- package/lib/autostart.mjs +311 -0
- package/lib/bash.mjs +168 -0
- package/lib/config.mjs +103 -0
- package/lib/i18n.mjs +511 -0
- package/lib/jobs.mjs +203 -0
- package/lib/onboard.mjs +199 -0
- package/lib/ping-policy.mjs +49 -0
- package/lib/plugins/files.mjs +327 -0
- package/lib/plugins/index.mjs +116 -0
- package/lib/plugins/job.mjs +111 -0
- package/lib/plugins/session.mjs +192 -0
- package/lib/plugins/shell.mjs +71 -0
- package/lib/presence.mjs +171 -0
- package/lib/runner.mjs +600 -0
- package/lib/sandbox.mjs +152 -0
- package/lib/sessions.mjs +148 -0
- package/lib/shell.mjs +154 -0
- package/lib/store.mjs +436 -0
- package/package.json +45 -4
package/lib/api.mjs
ADDED
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Клиентская часть протокола: заявка, объявление, опрос команд, результат.
|
|
3
|
+
*
|
|
4
|
+
* Ничего не знает про CLI — всё принимает явными параметрами, чтобы это можно
|
|
5
|
+
* было тестировать без процессов и терминала.
|
|
6
|
+
*
|
|
7
|
+
* @module dsh-node-client/api
|
|
8
|
+
*/
|
|
9
|
+
import { randomUUID } from 'node:crypto'
|
|
10
|
+
import { readFileSync } from 'node:fs'
|
|
11
|
+
|
|
12
|
+
import { t } from './i18n.mjs'
|
|
13
|
+
|
|
14
|
+
/** Версия протокола, которую объявляем серверу (`proto`). */
|
|
15
|
+
export const PROTO = 1
|
|
16
|
+
// Версия берётся из package.json: зашитая строка врала в админке (все узлы
|
|
17
|
+
// показывались как 0.0.1, сколько бы клиент ни обновляли).
|
|
18
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8'))
|
|
19
|
+
export const CLIENT_VERSION = `dshnc/${pkg.version}`
|
|
20
|
+
/** Голая версия клиента: `dshnc version` и строка в логе при старте демона. */
|
|
21
|
+
export const VERSION = pkg.version
|
|
22
|
+
|
|
23
|
+
/** Ошибка протокола: HTTP-код + код из закрытого словаря. */
|
|
24
|
+
export class NodeError extends Error {
|
|
25
|
+
constructor (status, code, message) {
|
|
26
|
+
super(message)
|
|
27
|
+
this.status = status
|
|
28
|
+
this.code = code
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async function request (base, path, { method = 'GET', token, body, timeoutMs = 30000 } = {}) {
|
|
33
|
+
const controller = new AbortController()
|
|
34
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs)
|
|
35
|
+
try {
|
|
36
|
+
const response = await fetch(base + path, {
|
|
37
|
+
method,
|
|
38
|
+
headers: {
|
|
39
|
+
...(token ? { authorization: `Bearer ${token}` } : {}),
|
|
40
|
+
...(body ? { 'content-type': 'application/json' } : {}),
|
|
41
|
+
},
|
|
42
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
43
|
+
signal: controller.signal,
|
|
44
|
+
})
|
|
45
|
+
const text = await response.text()
|
|
46
|
+
const payload = text ? JSON.parse(text) : {}
|
|
47
|
+
if (!response.ok) {
|
|
48
|
+
if (response.status === 401) {
|
|
49
|
+
if (process.env.DSHNC_DEBUG) {
|
|
50
|
+
// eslint-disable-next-line no-console
|
|
51
|
+
console.error(`[debug] 401 ${path} token=${String(token ?? '').slice(0, 8)}…`)
|
|
52
|
+
}
|
|
53
|
+
throw new NodeError(401, 'unauthorized', t('api.unauthorized'))
|
|
54
|
+
}
|
|
55
|
+
throw new NodeError(response.status, payload.code ?? 'bad_request', payload.message ?? t('api.http', { status: response.status }))
|
|
56
|
+
}
|
|
57
|
+
return { status: response.status, body: payload }
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error instanceof NodeError) throw error
|
|
60
|
+
if (error.name === 'AbortError') throw new NodeError(0, 'timeout', t('api.timeout'))
|
|
61
|
+
throw new NodeError(0, 'network', error.message)
|
|
62
|
+
} finally {
|
|
63
|
+
clearTimeout(timer)
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Заявка на регистрацию (план §9.1). */
|
|
68
|
+
export async function enroll (base, { uuid, name, aliases, nonce }) {
|
|
69
|
+
return request(base, '/v1/enroll', {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
body: { uuid, name, aliases, nonce: nonce ?? null, proto: PROTO, clientVersion: CLIENT_VERSION },
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Опрос заявки: 202 пока ждёт, 200 с токеном после одобрения. */
|
|
76
|
+
export async function enrollStatus (base, uuid, nonce = null) {
|
|
77
|
+
try {
|
|
78
|
+
const query = nonce ? `?nonce=${encodeURIComponent(nonce)}` : ''
|
|
79
|
+
return await request(base, `/v1/enroll/${encodeURIComponent(uuid)}${query}`)
|
|
80
|
+
} catch (error) {
|
|
81
|
+
if (error.status === 202) return { status: 202, body: { status: 'pending' } }
|
|
82
|
+
throw error
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Разобрать ссылку подключения: `.../dsh-nodes/pair?v=<код>`.
|
|
88
|
+
* @param {string} input
|
|
89
|
+
* @returns {{base: string, code: string|null}}
|
|
90
|
+
*/
|
|
91
|
+
export function parseConnectUrl (input) {
|
|
92
|
+
const url = new URL(input)
|
|
93
|
+
const code = url.searchParams.get('v') ?? url.searchParams.get('code') ?? null
|
|
94
|
+
const path = url.pathname.replace(/\/+$/, '').replace(/\/(pair||enroll)$/, '')
|
|
95
|
+
url.search = ''
|
|
96
|
+
url.hash = ''
|
|
97
|
+
url.pathname = path || '/dsh-nodes'
|
|
98
|
+
return { base: `${url.origin}${url.pathname}`, code }
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Обменять код подключения на токен: подтверждать в админке нечего. */
|
|
102
|
+
export async function pair (base, { code, uuid, name, aliases }) {
|
|
103
|
+
return request(base, '/v1/pair', {
|
|
104
|
+
method: 'POST',
|
|
105
|
+
body: { code, uuid, name, aliases, proto: PROTO, clientVersion: CLIENT_VERSION },
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Объявить, что умеем. */
|
|
110
|
+
export async function announce (base, token, apis, { workdir = null, access = null, allowEscalation = true, uptimeSeconds = null, approver = null, startMode = null } = {}) {
|
|
111
|
+
return request(base, '/v1/announce', {
|
|
112
|
+
method: 'PUT',
|
|
113
|
+
token,
|
|
114
|
+
// потолок прав и «можно ли спрашивать» нужны серверу, чтобы решать,
|
|
115
|
+
// задавать ли вопрос человеку при отказе песочницы
|
|
116
|
+
body: { apis, proto: PROTO, clientVersion: CLIENT_VERSION, workdir, access, allowEscalation, uptimeSeconds, approver, base: startMode },
|
|
117
|
+
})
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Забрать пачку команд на исполнение.
|
|
122
|
+
*
|
|
123
|
+
* Курсора больше нет: сервер отдаёт с головы очереди и сразу помечает взятое
|
|
124
|
+
* как `processing`, повторно не выдаёт. `limit` — сколько взять за раз.
|
|
125
|
+
*/
|
|
126
|
+
export async function commands (base, token, { limit = 5 } = {}) {
|
|
127
|
+
return request(base, `/v1/commands?limit=${limit}`, { token })
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Вернуть результат команды. «Уже закрыта» — только 409. */
|
|
131
|
+
export async function result (base, token, seq, payload) {
|
|
132
|
+
try {
|
|
133
|
+
return await request(base, `/v1/commands/${seq}/result`, { method: 'POST', token, body: payload })
|
|
134
|
+
} catch (error) {
|
|
135
|
+
// 404 — это не «уже закрыта», а «такого маршрута нет»: проглотить его значит
|
|
136
|
+
// молча потерять результат и всё равно сдвинуть курсор
|
|
137
|
+
if (error.status === 409) return { status: 409, body: { ok: false, late: true } }
|
|
138
|
+
throw error
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** Счётчики узла: нужны для сверки поколения и наличия работы. */
|
|
143
|
+
export async function state (base, token) {
|
|
144
|
+
return request(base, '/v1/state', { token })
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** API, которое умеет клиент из коробки: одна команда bash. */
|
|
148
|
+
export function bashApi ({ shell = '/bin/sh', defaultTimeout = 60 } = {}) {
|
|
149
|
+
return {
|
|
150
|
+
name: 'bash',
|
|
151
|
+
version: '2',
|
|
152
|
+
description: t('bash.api.description'),
|
|
153
|
+
methods: [
|
|
154
|
+
{
|
|
155
|
+
method: 'exec',
|
|
156
|
+
description: t('bash.exec.description'),
|
|
157
|
+
args: {
|
|
158
|
+
type: 'object',
|
|
159
|
+
properties: {
|
|
160
|
+
command: { type: 'string', description: t('bash.arg.command') },
|
|
161
|
+
cwd: { type: 'string', description: t('bash.arg.cwd') },
|
|
162
|
+
timeout: { type: 'number', description: t('bash.arg.timeout') },
|
|
163
|
+
stdin: { type: 'string', description: t('bash.arg.stdin') },
|
|
164
|
+
},
|
|
165
|
+
required: ['command'],
|
|
166
|
+
additionalProperties: false,
|
|
167
|
+
},
|
|
168
|
+
meta: { shell, defaultTimeout },
|
|
169
|
+
},
|
|
170
|
+
],
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Свежий uuid на случай, если в конфиге его нет. */
|
|
175
|
+
export function newUuid () {
|
|
176
|
+
return randomUUID()
|
|
177
|
+
}
|
package/lib/approver.mjs
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Локальный апрув: спросить человека за этой машиной.
|
|
3
|
+
*
|
|
4
|
+
* Работает через внешнюю команду из настроек узла — `zenity`, `notify-send`,
|
|
5
|
+
* `powershell`, что угодно. Отдельный модуль, потому что тут легко ошибиться:
|
|
6
|
+
* текст вопроса приходит извне (его собрал сервер из команды модели), и
|
|
7
|
+
* подставлять его прямо в командную строку нельзя — это была бы инъекция.
|
|
8
|
+
*
|
|
9
|
+
* Поэтому: `%s` в шаблоне заменяется на экранированный текст, а полный JSON
|
|
10
|
+
* уходит на stdin. Команда запускается без оболочки, списком аргументов.
|
|
11
|
+
*
|
|
12
|
+
* @module dsh-node-client/approver
|
|
13
|
+
*/
|
|
14
|
+
import { spawn } from 'node:child_process'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Аргументы команды апрува.
|
|
18
|
+
*
|
|
19
|
+
* Два способа передать текст:
|
|
20
|
+
* - в шаблоне есть `%s` → подставляем в аргумент (для `zenity --question --text=%s`);
|
|
21
|
+
* - нет `%s` → текст уходит на stdin (для `zenity --text-info`), и тогда длина
|
|
22
|
+
* вопроса ничем не ограничена командной строкой.
|
|
23
|
+
*
|
|
24
|
+
* Разбиение делается до подстановки: иначе пробелы из вопроса разорвали бы
|
|
25
|
+
* аргумент на части. Оболочки здесь нет, поэтому экранировать нечего.
|
|
26
|
+
*/
|
|
27
|
+
export function buildArgs (template, prompt) {
|
|
28
|
+
const args = splitArgs(template)
|
|
29
|
+
if (!template.includes('%s')) return args
|
|
30
|
+
return args.map((arg) => arg.replaceAll('%s', String(prompt)))
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Пойдёт ли текст в аргументы или на stdin. */
|
|
34
|
+
export function usesStdin (template) {
|
|
35
|
+
return !String(template).includes('%s')
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Разбить строку на аргументы, уважая кавычки. */
|
|
39
|
+
export function splitArgs (template) {
|
|
40
|
+
const args = []
|
|
41
|
+
let current = ''
|
|
42
|
+
let quote = null
|
|
43
|
+
for (const char of template) {
|
|
44
|
+
if (quote) {
|
|
45
|
+
if (char === quote) quote = null
|
|
46
|
+
else current += char
|
|
47
|
+
continue
|
|
48
|
+
}
|
|
49
|
+
if (char === '"' || char === "'") {
|
|
50
|
+
quote = char
|
|
51
|
+
continue
|
|
52
|
+
}
|
|
53
|
+
if (/\s/.test(char)) {
|
|
54
|
+
if (current) args.push(current)
|
|
55
|
+
current = ''
|
|
56
|
+
continue
|
|
57
|
+
}
|
|
58
|
+
current += char
|
|
59
|
+
}
|
|
60
|
+
if (current) args.push(current)
|
|
61
|
+
return args
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Спросить человека.
|
|
66
|
+
*
|
|
67
|
+
* `ok` команды = «разрешаю»: инструмент задаёт вопрос так, что код 0 означает
|
|
68
|
+
* согласие (`zenity --question` возвращает 0 на «Yes»), ненулевой — отказ или
|
|
69
|
+
* закрытое окно. Это же поведение у `notify-send` с действиями не получится,
|
|
70
|
+
* поэтому для таких случаев нужен свой скрипт — конфиг это позволяет.
|
|
71
|
+
*
|
|
72
|
+
* @param {object} options
|
|
73
|
+
* @param {string} options.tool команда из настроек
|
|
74
|
+
* @param {string} options.prompt текст вопроса
|
|
75
|
+
* @param {string} options.payload JSON для stdin
|
|
76
|
+
* @param {AbortSignal} [options.signal]
|
|
77
|
+
* @param {number} [options.timeout] секунды
|
|
78
|
+
* @returns {Promise<boolean>} true — разрешено
|
|
79
|
+
*/
|
|
80
|
+
export function askLocally ({ tool, prompt, payload, signal, timeout = 86400 }) {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
const argv = buildArgs(tool, prompt)
|
|
83
|
+
if (!argv.length) {
|
|
84
|
+
reject(Object.assign(new Error('пустая команда апрува'), { code: 'no_local_approver' }))
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
// структурированные данные — в окружение, человеку — текст на stdin
|
|
88
|
+
const env = {
|
|
89
|
+
...process.env,
|
|
90
|
+
DSHNC_APPROVAL_ID: String(payload?.id ?? ''),
|
|
91
|
+
DSHNC_APPROVAL_MODE: String(payload?.mode ?? ''),
|
|
92
|
+
// в рамках чего спрашиваем: сессия и вопрос (корневой вызов хода)
|
|
93
|
+
DSHNC_SESSION: String(payload?.session ?? ''),
|
|
94
|
+
DSHNC_QUESTION: String(payload?.question?.rootCallId ?? ''),
|
|
95
|
+
DSHNC_CALL: String(payload?.question?.callId ?? ''),
|
|
96
|
+
}
|
|
97
|
+
const child = spawn(argv[0], argv.slice(1), { stdio: ['pipe', 'ignore', 'pipe'], env })
|
|
98
|
+
let stderr = ''
|
|
99
|
+
let settled = false
|
|
100
|
+
const finish = (fn, value) => {
|
|
101
|
+
if (settled) return
|
|
102
|
+
settled = true
|
|
103
|
+
clearTimeout(timer)
|
|
104
|
+
signal?.removeEventListener?.('abort', onAbort)
|
|
105
|
+
fn(value)
|
|
106
|
+
}
|
|
107
|
+
const onAbort = () => {
|
|
108
|
+
try {
|
|
109
|
+
child.kill('SIGTERM')
|
|
110
|
+
} catch {}
|
|
111
|
+
finish(reject, Object.assign(new Error('апрув отменён'), { code: 'cancelled' }))
|
|
112
|
+
}
|
|
113
|
+
const timer = setTimeout(() => {
|
|
114
|
+
try {
|
|
115
|
+
child.kill('SIGTERM')
|
|
116
|
+
} catch {}
|
|
117
|
+
finish(reject, Object.assign(new Error('апрув истёк'), { code: 'approve_timeout' }))
|
|
118
|
+
}, Math.max(1, timeout) * 1000)
|
|
119
|
+
if (typeof timer.unref === 'function') timer.unref()
|
|
120
|
+
signal?.addEventListener?.('abort', onAbort, { once: true })
|
|
121
|
+
|
|
122
|
+
child.stderr?.on('data', (chunk) => {
|
|
123
|
+
if (stderr.length < 2000) stderr += chunk
|
|
124
|
+
})
|
|
125
|
+
child.on('error', (error) => finish(reject, Object.assign(error, { code: 'approve_failed' })))
|
|
126
|
+
child.on('close', (code) => {
|
|
127
|
+
if (code === 0) finish(resolve, true)
|
|
128
|
+
else finish(resolve, false)
|
|
129
|
+
void stderr
|
|
130
|
+
})
|
|
131
|
+
// инструмент может не читать stdin и выйти сразу — это не ошибка
|
|
132
|
+
child.stdin.on('error', () => {})
|
|
133
|
+
try {
|
|
134
|
+
// диалогу нужен человеческий текст; JSON отдаём переменными окружения
|
|
135
|
+
child.stdin.end(usesStdin(tool) ? String(prompt) : String(payload))
|
|
136
|
+
} catch {}
|
|
137
|
+
})
|
|
138
|
+
}
|
package/lib/ask.mjs
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Подпись вопроса для интерактивного ввода.
|
|
3
|
+
*
|
|
4
|
+
* Значение по умолчанию показывается в квадратных скобках — но только если оно
|
|
5
|
+
* уже не показано в самом тексте вопроса. Иначе получается «...[user] [user]»,
|
|
6
|
+
* что и случилось на первом запуске мастера.
|
|
7
|
+
*
|
|
8
|
+
* @param {string} question текст вопроса
|
|
9
|
+
* @param {string} [fallback] значение по умолчанию
|
|
10
|
+
* @returns {string} строка с двоеточием на конце
|
|
11
|
+
*/
|
|
12
|
+
export function formatQuestion (question, fallback) {
|
|
13
|
+
const text = String(question ?? '').trim()
|
|
14
|
+
const hasBracketDefault = /\[[^\]]*\]\s*$/.test(text)
|
|
15
|
+
const suffix = fallback && !hasBracketDefault ? ` [${fallback}]` : ''
|
|
16
|
+
return `${text}${suffix}: `
|
|
17
|
+
}
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `dshnc autostart`: вопросы, юнит systemd, установка.
|
|
3
|
+
*
|
|
4
|
+
* Юнит собирается отдельной функцией, потому что ошибиться в systemd легко и
|
|
5
|
+
* молча: не тот target, забытый `User=`, запуск в графической сессии без
|
|
6
|
+
* переменных окружения. Текст юнита проверяется тестами, а не глазами.
|
|
7
|
+
*
|
|
8
|
+
* @module dsh-node-client/autostart
|
|
9
|
+
*/
|
|
10
|
+
import { spawnSync } from 'node:child_process'
|
|
11
|
+
import { chmodSync, copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs'
|
|
12
|
+
import { homedir } from 'node:os'
|
|
13
|
+
import { dirname, join } from 'node:path'
|
|
14
|
+
|
|
15
|
+
import { t } from './i18n.mjs'
|
|
16
|
+
|
|
17
|
+
/** Где живёт user-юнит и системный юнит. */
|
|
18
|
+
export const USER_UNIT_DIR = join(homedir(), '.config', 'systemd', 'user')
|
|
19
|
+
export const SYSTEM_UNIT_DIR = '/etc/systemd/system'
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Текст юнита.
|
|
23
|
+
* @param {object} options
|
|
24
|
+
* @param {'user'|'system'} options.scope
|
|
25
|
+
* @param {string} options.execStart абсолютный путь к dshnc
|
|
26
|
+
* @param {string[]} [options.args] аргументы (по умолчанию `daemon`)
|
|
27
|
+
* @param {string} [options.user] `User=` для системного юнита
|
|
28
|
+
* @param {string} [options.home] домашний каталог (для `Environment=HOME=`)
|
|
29
|
+
* @param {boolean} [options.graphical] стартовать внутри графической сессии
|
|
30
|
+
* @param {string} [options.shell] DSHNC_SHELL
|
|
31
|
+
* @param {number} [options.restartSec]
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
export function renderUnit ({ scope, execStart, args = ['daemon'], user, home, graphical = false, shell = '/bin/bash', restartSec = 5, pathEnv }) {
|
|
35
|
+
const lines = ['[Unit]', `Description=${t('unit.description')}`]
|
|
36
|
+
if (scope === 'system') {
|
|
37
|
+
lines.push('After=network-online.target', 'Wants=network-online.target')
|
|
38
|
+
} else if (graphical) {
|
|
39
|
+
// запускаемся вместе с графической сессией и забираем её окружение
|
|
40
|
+
lines.push('After=graphical-session.target', 'PartOf=graphical-session.target', 'Wants=graphical-session.target')
|
|
41
|
+
} else {
|
|
42
|
+
lines.push('After=network-online.target', 'Wants=network-online.target')
|
|
43
|
+
}
|
|
44
|
+
lines.push('', '[Service]')
|
|
45
|
+
if (scope === 'system') {
|
|
46
|
+
if (user) lines.push(`User=${user}`)
|
|
47
|
+
if (home) lines.push(`Environment=HOME=${home}`)
|
|
48
|
+
}
|
|
49
|
+
// У юнита свой PATH: node из nvm туда не попадает, и `env: 'node'` падает
|
|
50
|
+
lines.push(`Environment=PATH=${pathEnv ?? '/usr/local/bin:/usr/bin:/bin'}`)
|
|
51
|
+
lines.push(`Environment=DSHNC_SHELL=${shell}`)
|
|
52
|
+
if (scope === 'user' && graphical) {
|
|
53
|
+
// без этого DISPLAY/XAUTHORITY в user-юните пустые
|
|
54
|
+
lines.push("ExecStartPre=-/bin/sh -c 'systemctl --user import-environment DISPLAY XAUTHORITY WAYLAND_DISPLAY XDG_RUNTIME_DIR 2>/dev/null || true'")
|
|
55
|
+
}
|
|
56
|
+
lines.push(
|
|
57
|
+
`ExecStart=${execStart} ${args.join(' ')}`.trim(),
|
|
58
|
+
// перечитать конфиг без разрыва соединений: `systemctl --user reload dshnc`
|
|
59
|
+
'ExecReload=/bin/kill -HUP $MAINPID',
|
|
60
|
+
'Restart=always',
|
|
61
|
+
`RestartSec=${restartSec}`,
|
|
62
|
+
'',
|
|
63
|
+
'[Install]',
|
|
64
|
+
`WantedBy=${scope === 'user' && graphical ? 'graphical-session.target' : 'default.target'}`,
|
|
65
|
+
)
|
|
66
|
+
return lines.join('\n') + '\n'
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Чем на самом деле запускать: если нас вызвали как `node …/dshnc.mjs`, то
|
|
71
|
+
* `ExecStart` должен нести интерпретатор и скрипт — на `.mjs` из юнита systemd
|
|
72
|
+
* не запустит по shebang.
|
|
73
|
+
* @param {string} script путь из `process.argv[1]`
|
|
74
|
+
* @param {string} [execPath] путь к node (`process.execPath`)
|
|
75
|
+
*/
|
|
76
|
+
export function resolveExecStart (script, execPath = process.execPath) {
|
|
77
|
+
if (!script) return execPath
|
|
78
|
+
return script.endsWith('.mjs') ? `${execPath} ${script}` : script
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Имя файла юнита. */
|
|
82
|
+
export function unitFileName (scope, name = 'dshnc') {
|
|
83
|
+
return `${name}.service`
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Путь к файлу юнита. */
|
|
87
|
+
export function unitPath (scope, name = 'dshnc', dir) {
|
|
88
|
+
const base = dir ?? (scope === 'user' ? USER_UNIT_DIR : SYSTEM_UNIT_DIR)
|
|
89
|
+
return join(base, unitFileName(scope, name))
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Записать юнит.
|
|
94
|
+
* @returns {string} путь
|
|
95
|
+
*/
|
|
96
|
+
export function writeUnit ({ scope, name = 'dshnc', text, dir }) {
|
|
97
|
+
const path = unitPath(scope, name, dir)
|
|
98
|
+
mkdirSync(join(path, '..'), { recursive: true })
|
|
99
|
+
writeFileSync(path, text, { mode: 0o644 })
|
|
100
|
+
return path
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Команды, которые надо выполнить после записи файла. */
|
|
104
|
+
export function installCommands ({ scope, name = 'dshnc', graphUser }) {
|
|
105
|
+
if (scope === 'user') {
|
|
106
|
+
const prefix = graphUser ? `sudo -u ${graphUser} -i ` : ''
|
|
107
|
+
return [
|
|
108
|
+
...(graphUser ? [] : ['systemctl --user daemon-reload']),
|
|
109
|
+
`${prefix}systemctl --user enable --now ${name}`,
|
|
110
|
+
`${prefix}systemctl --user status ${name} --no-pager`,
|
|
111
|
+
]
|
|
112
|
+
}
|
|
113
|
+
return [
|
|
114
|
+
'sudo systemctl daemon-reload',
|
|
115
|
+
`sudo systemctl enable --now ${name}`,
|
|
116
|
+
`sudo systemctl status ${name} --no-pager`,
|
|
117
|
+
]
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Есть ли у нас право создавать системных пользователей без вопросов. */
|
|
121
|
+
export function canCreateUsers ({ uid = process.getuid?.() ?? -1, hasSudo = false } = {}) {
|
|
122
|
+
return uid === 0 || hasSudo
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Создать системного пользователя. Если мы root — сами, если есть sudo — через
|
|
127
|
+
* него, иначе возвращаем false: тогда мастер покажет команду и подождёт.
|
|
128
|
+
* @param {object} options
|
|
129
|
+
* @param {string} options.user
|
|
130
|
+
* @param {string} [options.home]
|
|
131
|
+
* @param {number} [options.uid]
|
|
132
|
+
* @param {boolean} [options.hasSudo]
|
|
133
|
+
* @param {(file: string, args: string[]) => {status: number|null}} [options.run]
|
|
134
|
+
* @returns {boolean}
|
|
135
|
+
*/
|
|
136
|
+
export function createUser ({ user, home = `/home/${user}`, uid = process.getuid?.() ?? -1, hasSudo = false, run }) {
|
|
137
|
+
const args = ['--system', '--create-home', '--home-dir', home, '--shell', '/usr/sbin/nologin', user]
|
|
138
|
+
const exec = run ?? ((file, argv) => spawnSync(file, argv, { stdio: 'inherit' }))
|
|
139
|
+
if (uid === 0) return exec('useradd', args).status === 0
|
|
140
|
+
if (hasSudo) return exec('sudo', ['-n', 'useradd', ...args]).status === 0
|
|
141
|
+
return false
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Перенести состояние клиента другому пользователю.
|
|
146
|
+
* @param {object} options
|
|
147
|
+
* @param {string} options.from каталог-источник
|
|
148
|
+
* @param {string} options.to каталог-назначение
|
|
149
|
+
* @param {string} options.user владелец
|
|
150
|
+
* @param {number} [options.uid]
|
|
151
|
+
* @param {boolean} [options.hasSudo]
|
|
152
|
+
* @param {(file: string, args: string[]) => {status: number|null}} [options.run]
|
|
153
|
+
* @returns {boolean}
|
|
154
|
+
*/
|
|
155
|
+
export function copyState ({ from, to, user, uid = process.getuid?.() ?? -1, hasSudo = false, run }) {
|
|
156
|
+
const exec = run ?? ((file, argv) => spawnSync(file, argv, { stdio: 'inherit' }))
|
|
157
|
+
try {
|
|
158
|
+
mkdirSync(to, { recursive: true, mode: 0o700 })
|
|
159
|
+
for (const file of readdirSync(from)) {
|
|
160
|
+
if (file === 'node_modules') continue
|
|
161
|
+
const source = join(from, file)
|
|
162
|
+
const target = join(to, file)
|
|
163
|
+
const stat = statSync(source)
|
|
164
|
+
if (stat.isDirectory()) {
|
|
165
|
+
cpSync(source, target, { recursive: true })
|
|
166
|
+
} else {
|
|
167
|
+
copyFileSync(source, target)
|
|
168
|
+
chmodSync(target, file === 'config.json' ? 0o600 : stat.mode & 0o777)
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
} catch (error) {
|
|
172
|
+
return false
|
|
173
|
+
}
|
|
174
|
+
const argv = ['-R', `${user}:${user}`, to]
|
|
175
|
+
const result = uid === 0 ? exec('chown', argv) : hasSudo ? exec('sudo', ['-n', 'chown', ...argv]) : { status: 1 }
|
|
176
|
+
return result.status === 0
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Команда создания пользователя, которую можно показать человеку. */
|
|
180
|
+
export function createUserCommand (user, home = `/home/${user}`) {
|
|
181
|
+
return `sudo useradd --system --create-home --home-dir ${home} --shell /usr/sbin/nologin ${user}`
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Существует ли пользователь. */
|
|
185
|
+
export function userExists (user) {
|
|
186
|
+
try {
|
|
187
|
+
return readFileSync('/etc/passwd', 'utf8').split('\n').some((line) => line.startsWith(`${user}:`))
|
|
188
|
+
} catch {
|
|
189
|
+
return false
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Каталог состояния пользователя. */
|
|
194
|
+
export function stateDirFor ({ scope, user, home }) {
|
|
195
|
+
if (scope === 'user' || !user) return join(homedir(), '.dshnc')
|
|
196
|
+
return join(home ?? `/home/${user}`, '.dshnc')
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Мастер: задать вопросы, собрать юнит, записать его, показать команды установки.
|
|
201
|
+
*
|
|
202
|
+
* `ask` и `print` приходят снаружи — поэтому весь диалог проверяется тестом без
|
|
203
|
+
* терминала. Копирование состояния делается только когда для системного юнита
|
|
204
|
+
* создан отдельный пользователь: иначе он просто не найдёт токен.
|
|
205
|
+
*
|
|
206
|
+
* @param {object} options
|
|
207
|
+
* @param {(question: string, fallback?: string) => Promise<string>} options.ask
|
|
208
|
+
* @param {(line: string) => void} options.print
|
|
209
|
+
* @param {object} options.config состояние клиента (name, aliases, servers)
|
|
210
|
+
* @param {string} options.execStart путь к dshnc
|
|
211
|
+
* @param {(scope: string, name: string) => string} [options.unitFile] переопределить путь (тесты)
|
|
212
|
+
* @param {boolean} [options.hasSudo]
|
|
213
|
+
* @param {number} [options.uid]
|
|
214
|
+
*/
|
|
215
|
+
export async function runAutostart ({ ask, print, config, execStart, unitFile, hasSudo = false, uid = process.getuid?.() ?? -1, run, stateFrom, stateTo }) {
|
|
216
|
+
print(t('auto.title'))
|
|
217
|
+
|
|
218
|
+
const name = (await ask(t('auto.name'), config.name ?? '')).trim() || config.name
|
|
219
|
+
const aliasesRaw = await ask(t('auto.aliases'), (config.aliases ?? []).join(','))
|
|
220
|
+
const aliases = aliasesRaw.split(',').map((value) => value.trim()).filter(Boolean).slice(0, 8)
|
|
221
|
+
|
|
222
|
+
const scopeAnswer = (await ask(t('auto.scope'), 'user')).trim().toLowerCase()
|
|
223
|
+
const scope = scopeAnswer.startsWith('s') ? 'system' : 'user'
|
|
224
|
+
|
|
225
|
+
let user = null
|
|
226
|
+
let home = null
|
|
227
|
+
let graphical = false
|
|
228
|
+
|
|
229
|
+
if (scope === 'system') {
|
|
230
|
+
const dedicated = (await ask(t('auto.dedicated'), 'n')).trim().toLowerCase().startsWith('y')
|
|
231
|
+
if (dedicated) {
|
|
232
|
+
user = (await ask(t('auto.username'), 'dshnc')).trim() || 'dshnc'
|
|
233
|
+
home = `/home/${user}`
|
|
234
|
+
if (!userExists(user)) {
|
|
235
|
+
const command = createUserCommand(user, home)
|
|
236
|
+
if (canCreateUsers({ uid, hasSudo }) && createUser({ user, home, uid, hasSudo, run })) {
|
|
237
|
+
print(t('auto.userCreated', { user }))
|
|
238
|
+
} else {
|
|
239
|
+
print(t('auto.createUser'))
|
|
240
|
+
print(' ' + command)
|
|
241
|
+
await ask(t('auto.waitingUser', { user }))
|
|
242
|
+
for (let attempt = 0; attempt < 120 && !userExists(user); attempt++) {
|
|
243
|
+
await new Promise((resolve) => setTimeout(resolve, 500))
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
} else {
|
|
249
|
+
graphical = (await ask(t('auto.graphical'), 'n')).trim().toLowerCase().startsWith('y')
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const pathEnv = [dirname(process.execPath), '/usr/local/bin', '/usr/bin', '/bin'].join(':')
|
|
253
|
+
const text = renderUnit({ scope, execStart, user, home: home ?? undefined, graphical, pathEnv, shell: process.env.DSHNC_SHELL ?? '/bin/bash' })
|
|
254
|
+
// проверить, что запускать есть что: иначе юнит уйдёт в цикл падений
|
|
255
|
+
const launcher = execStart.split(' ')[0]
|
|
256
|
+
if (!existsSync(launcher)) {
|
|
257
|
+
print(t('auto.noLauncher', { path: launcher }))
|
|
258
|
+
}
|
|
259
|
+
const path = unitFile ? unitFile(scope, 'dshnc') : unitPath(scope, 'dshnc')
|
|
260
|
+
if (unitFile) {
|
|
261
|
+
mkdirSync(join(path, '..'), { recursive: true })
|
|
262
|
+
writeFileSync(path, text, { mode: 0o644 })
|
|
263
|
+
} else {
|
|
264
|
+
writeUnit({ scope, text })
|
|
265
|
+
}
|
|
266
|
+
print(t('auto.written', { path }))
|
|
267
|
+
|
|
268
|
+
const stateDir = stateDirFor({ scope, user, home })
|
|
269
|
+
print(t('auto.stateDir', { dir: stateDir }))
|
|
270
|
+
let copied = null
|
|
271
|
+
if (scope === 'system' && user) {
|
|
272
|
+
const hasToken = Object.values(config.servers ?? {}).some((entry) => entry?.token)
|
|
273
|
+
if (!hasToken) {
|
|
274
|
+
// демон без токена бесполезен: говорим прямо, что делать
|
|
275
|
+
print(t('auto.noToken', { user }))
|
|
276
|
+
} else if ((await ask(t('auto.copyState', { dir: stateDir }), 'n')).trim().toLowerCase().startsWith('y')) {
|
|
277
|
+
copied = copyState({ from: stateFrom ?? home_of_config(), to: stateTo ?? stateDir, user, uid, hasSudo, run })
|
|
278
|
+
print(copied ? t('auto.copied', { dir: stateTo ?? stateDir }) : t('auto.copyFailed', { user, message: 'chown/copy' }))
|
|
279
|
+
} else {
|
|
280
|
+
print(t('auto.copySkip'))
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
const commands = installCommands({ scope, name: 'dshnc' })
|
|
285
|
+
print('')
|
|
286
|
+
print(t('auto.next'))
|
|
287
|
+
for (const command of commands) print(' ' + command)
|
|
288
|
+
print('')
|
|
289
|
+
print(t('auto.done'))
|
|
290
|
+
|
|
291
|
+
return { name, aliases, scope, user, home, graphical, path, text, commands, stateDir, copied }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** Каталог состояния текущего пользователя. */
|
|
295
|
+
export function home_of_config () {
|
|
296
|
+
return join(homedir(), '.dshnc')
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** Применить ответы к конфигу клиента. */
|
|
300
|
+
export function applyAutostartAnswers (config, { name, aliases }) {
|
|
301
|
+
if (name) config.name = name
|
|
302
|
+
config.aliases = aliasArray(aliases)
|
|
303
|
+
return config
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/** Привести алиасы к массиву строк. */
|
|
307
|
+
export function aliasArray (value) {
|
|
308
|
+
if (Array.isArray(value)) return value.map((item) => String(item).trim()).filter(Boolean)
|
|
309
|
+
if (typeof value === 'string') return value.split(',').map((item) => item.trim()).filter(Boolean)
|
|
310
|
+
return []
|
|
311
|
+
}
|