mqttpc 1.0.0 → 2.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.
- package/README.md +127 -85
- package/config.js +54 -26
- package/example-procs.json +20 -0
- package/index.js +64 -135
- package/lib/dispatch.js +32 -0
- package/lib/install.js +47 -0
- package/lib/procs.js +146 -0
- package/lib/runner.js +259 -0
- package/package.json +45 -15
- package/procs.schema.json +105 -0
- package/.npmignore +0 -2
- package/procs.json +0 -17
package/README.md
CHANGED
|
@@ -1,125 +1,167 @@
|
|
|
1
1
|
# mqttpc
|
|
2
2
|
|
|
3
|
-
[](https://github.com/mqtt-smarthome/mqtt-smarthome)
|
|
4
|
+
[](http://badge.fury.io/js/mqttpc)
|
|
5
|
+
[](https://github.com/hobbyquaker/mqttpc/actions/workflows/ci.yml)
|
|
4
6
|
[![License][mit-badge]][mit-url]
|
|
5
7
|
|
|
6
|
-
>
|
|
8
|
+
> Control processes on a host over MQTT :satellite:
|
|
7
9
|
|
|
8
|
-
|
|
10
|
+
Start a backup script, restart a service, tail a log — by publishing to an MQTT topic. The
|
|
11
|
+
processes it may run are listed in a JSON file; **a name that is not in that file cannot be
|
|
12
|
+
started**, so the file is the allowlist and MQTT only ever picks an entry from it.
|
|
9
13
|
|
|
10
|
-
|
|
14
|
+
Built on [mqtt-interfaces-core](https://github.com/hobbyquaker/mqtt-interfaces-core), so it has the
|
|
15
|
+
same options, `--install`, `<name>/info` and maintenance topics as the rest of the `xyz2mqtt` fleet.
|
|
11
16
|
|
|
12
|
-
|
|
17
|
+
> **Read [Security](#security) before you deploy this.** Anyone who can publish to `<name>/set/#`
|
|
18
|
+
> can run every process in your procs file.
|
|
13
19
|
|
|
14
|
-
##
|
|
15
|
-
|
|
16
|
-
### Command line options
|
|
20
|
+
## Install
|
|
17
21
|
|
|
18
22
|
```
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
Options:
|
|
22
|
-
-v, --verbosity possible values: "error", "warn", "info", "debug"
|
|
23
|
-
[default: "info"]
|
|
24
|
-
-n, --name instance name. used as mqtt client id and as prefix for
|
|
25
|
-
connected topic [default: "pc"]
|
|
26
|
-
-u, --url mqtt broker url. See
|
|
27
|
-
https://github.com/mqttjs/MQTT.js#connect-using-a-url
|
|
28
|
-
[default: "mqtt://127.0.0.1"]
|
|
29
|
-
-f, --config config file [default: "./procs.json"]
|
|
30
|
-
-h, --help Show help
|
|
31
|
-
--version Show version number
|
|
32
|
-
|
|
23
|
+
npm install -g mqttpc
|
|
24
|
+
sudo mqttpc --install -n pc -u mqtt://broker -f /etc/mqttpc/pc.procs.json
|
|
33
25
|
```
|
|
34
26
|
|
|
35
|
-
|
|
27
|
+
That creates the `mqttpc` system user, `/etc/mqttpc/pc.env` and the systemd unit `mqttpc@pc`, and
|
|
28
|
+
starts it. Put your processes in `/etc/mqttpc/pc.procs.json` — copy `example-procs.json` from the
|
|
29
|
+
package to start from.
|
|
36
30
|
|
|
37
|
-
|
|
31
|
+
There is deliberately **no Docker image**: a process controller inside a container can only control
|
|
32
|
+
processes inside that container, which is not what anyone wants it for.
|
|
38
33
|
|
|
39
|
-
|
|
34
|
+
## The procs file
|
|
35
|
+
|
|
36
|
+
```json
|
|
40
37
|
{
|
|
41
|
-
"
|
|
42
|
-
"path": "/usr/bin/
|
|
43
|
-
"
|
|
44
|
-
...
|
|
38
|
+
"backup": {
|
|
39
|
+
"path": "/usr/local/bin/my-backup.sh",
|
|
40
|
+
"output": "buffer"
|
|
45
41
|
},
|
|
46
|
-
|
|
42
|
+
"disk-free": {
|
|
43
|
+
"path": "/bin/df",
|
|
44
|
+
"args": ["-h"]
|
|
45
|
+
}
|
|
47
46
|
}
|
|
48
|
-
|
|
49
47
|
```
|
|
50
48
|
|
|
49
|
+
`mqttpc --install` writes the path into the instance's env file; a management UI
|
|
50
|
+
([she](https://github.com/hobbyquaker/she)) edits the file itself against the JSON schema shipped
|
|
51
|
+
with the package, with completion and validation.
|
|
52
|
+
|
|
53
|
+
| Attribute | Meaning |
|
|
54
|
+
| ----------------------- | --------------------------------------------------------------------------------------------------------- |
|
|
55
|
+
| `path` | **required** — absolute path of the program |
|
|
56
|
+
| `args` | arguments, **one per array element** (`["-h", "/tmp"]`, never `["-h /tmp"]`) |
|
|
57
|
+
| `cwd` | working directory (default: mqttpc's own) |
|
|
58
|
+
| `env` | environment — **replaces** mqttpc's rather than adding to it |
|
|
59
|
+
| `shell` | run through a shell. Only needed for pipes and redirections; without it nothing is re-parsed |
|
|
60
|
+
| `uid` / `gid` | run as another user — **only possible when mqttpc itself runs as root**, otherwise ignored with a warning |
|
|
61
|
+
| `stdout` / `stderr` | `stream` (default) · `buffer` · `drop` · `stream_retain` · `buffer_retain` |
|
|
62
|
+
| `output` | the two combined on one topic, same modes; `drop` by default so nothing is published twice |
|
|
63
|
+
| `bufferMax` | bytes kept in buffer mode (default 131072); older output is dropped and the message says how much |
|
|
64
|
+
| `disableStdin` | refuse `set/<name>/pipe`, so nothing from MQTT reaches this program's stdin |
|
|
65
|
+
| `stdinFromSpawnPayload` | the `spawn` payload is written to stdin, which is then closed — a one-shot command with its input |
|
|
66
|
+
| `enqueueSpawns` | a `spawn` while it is already running waits its turn instead of being refused |
|
|
67
|
+
| `comment` | free text, ignored — JSON has no comments |
|
|
68
|
+
|
|
69
|
+
**Output modes.** `stream` publishes each chunk as it arrives, which is what you want for a log you
|
|
70
|
+
are watching. `buffer` collects everything and publishes it as **one message when the process
|
|
71
|
+
exits**, which is what you want for a backup script whose output is only interesting as a whole.
|
|
72
|
+
The `_retain` variants publish retained — only for output that should survive a restart, and note
|
|
73
|
+
that changing a process away from a retained mode leaves the old message on the broker.
|
|
74
|
+
|
|
75
|
+
## Topics
|
|
76
|
+
|
|
77
|
+
| Topic | Meaning |
|
|
78
|
+
| --------------------------------------------- | ---------------------------------------------------------------------- |
|
|
79
|
+
| `<name>/set/<proc>/spawn` | start it (payload ignored, unless `stdinFromSpawnPayload`) |
|
|
80
|
+
| `<name>/set/<proc>/signal` | send a signal — `SIGHUP`, `SIGKILL`, …; empty payload means `SIGTERM` |
|
|
81
|
+
| `<name>/set/<proc>/pipe` | write the payload to its stdin; **an empty payload closes stdin** |
|
|
82
|
+
| `<name>/status/<proc>/pid` | retained; empty once it has exited |
|
|
83
|
+
| `<name>/status/<proc>/exit` | retained; the exit code, or the signal that killed it |
|
|
84
|
+
| `<name>/status/<proc>/error` | retained; why a start failed, cleared on the next successful start |
|
|
85
|
+
| `<name>/status/<proc>/{stdout,stderr,output}` | output, per the modes above |
|
|
86
|
+
| `<name>/connected` | `2` running · `0` stopped |
|
|
87
|
+
| `<name>/info` | the procs file, the process names, what is running, whether it is root |
|
|
88
|
+
|
|
89
|
+
## Security
|
|
90
|
+
|
|
91
|
+
**mqttpc turns "may publish to `<name>/set/#`" into "may run these programs on this host".** That is
|
|
92
|
+
its purpose, and it is worth being deliberate about.
|
|
93
|
+
|
|
94
|
+
The procs file is the allowlist: MQTT chooses _which_ entry runs, never what the command is, and
|
|
95
|
+
arguments never come from a message. So the first control is simply **keeping that file small**.
|
|
96
|
+
The second is **broker access**: give the instance its own MQTT credentials with an ACL that
|
|
97
|
+
restricts who may write to its topic tree. she can create a per-instance Mosquitto dynsec identity
|
|
98
|
+
for this.
|
|
99
|
+
|
|
100
|
+
### Running without root
|
|
101
|
+
|
|
102
|
+
`--install` runs the service as the **`mqttpc` system user**, not root. Most things you want to
|
|
103
|
+
control do not need root at all — a backup script that reads a directory it owns, a build, a
|
|
104
|
+
`curl`.
|
|
105
|
+
|
|
106
|
+
For the few that do, add a **sudoers entry for exactly those commands** rather than running all of
|
|
107
|
+
mqttpc as root:
|
|
51
108
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
* args - (array[string]) arguments
|
|
58
|
-
* cwd - (string) the working directory (default: the cwd of mqttpc)
|
|
59
|
-
* env - (object) key-value paired environment (default: the env of mqttpc)
|
|
60
|
-
* uid - (number) user id
|
|
61
|
-
* gid - (number) group id
|
|
62
|
-
* shell - (boolean|string) run command in a shell (default: false). See https://nodejs.org/api/child_process.html#child_process_child_process_spawn_command_args_options
|
|
63
|
-
* disableStdin - (boolean) Disable the possibility to send data through MQTT to the process stdin (default: false).
|
|
64
|
-
* disableStdout - (boolean) Disable MQTT publish of the process stdout (default: false).
|
|
65
|
-
* disableStderr - (boolean) Disable MQTT publish of the process stderr (default: false).
|
|
66
|
-
|
|
67
|
-
### Usage example
|
|
109
|
+
```
|
|
110
|
+
# /etc/sudoers.d/mqttpc — install with: sudo visudo -f /etc/sudoers.d/mqttpc
|
|
111
|
+
mqttpc ALL=(root) NOPASSWD: /usr/sbin/reboot
|
|
112
|
+
mqttpc ALL=(root) NOPASSWD: /usr/bin/systemctl restart nginx
|
|
113
|
+
```
|
|
68
114
|
|
|
69
|
-
|
|
115
|
+
and call `sudo` from the procs entry:
|
|
70
116
|
|
|
71
|
-
|
|
72
|
-
```Javascript
|
|
117
|
+
```json
|
|
73
118
|
{
|
|
74
|
-
|
|
75
|
-
"path": "/usr/local/bin/my-backup.sh"
|
|
76
|
-
}
|
|
119
|
+
"reboot": {"path": "/usr/bin/sudo", "args": ["/usr/sbin/reboot"]}
|
|
77
120
|
}
|
|
78
121
|
```
|
|
79
|
-
...and (Re)start mqttpc. Now you can start your Backup Script by publish on ```pc/set/my-backup/spawn``` (Payload is irrelevant).
|
|
80
|
-
If you want to stop your script via MQTT you could publish ```SIGKILL``` on the topic ```pc/set/my-backup/signal```.
|
|
81
|
-
|
|
82
|
-
### Topics mqttpc publishes
|
|
83
122
|
|
|
84
|
-
|
|
123
|
+
Now a compromised broker can reboot the machine and restart nginx — and nothing else. Name full
|
|
124
|
+
paths and specific arguments; `NOPASSWD: /usr/bin/systemctl` without them grants control of every
|
|
125
|
+
unit on the host, and `ALL` grants everything.
|
|
85
126
|
|
|
86
|
-
|
|
127
|
+
> The unit deliberately does **not** set `NoNewPrivileges`, `ProtectSystem`, `ProtectHome` or
|
|
128
|
+
> `PrivateTmp`, which every other adapter in the fleet does. A systemd sandbox is inherited by every
|
|
129
|
+
> child process, so `ProtectHome` would hide `/home` from a backup script and `NoNewPrivileges`
|
|
130
|
+
> would stop `sudo` working at all. A process controller cannot be sandboxed and still do its job —
|
|
131
|
+
> which is another reason to keep the procs file short.
|
|
87
132
|
|
|
88
|
-
|
|
133
|
+
### `--root`
|
|
89
134
|
|
|
90
|
-
|
|
135
|
+
`--install --root` runs the service as root, as mqttpc 1.x did. It is supported, it prints a
|
|
136
|
+
warning at install time and logs one on every start, and you should not use it unless the sudoers
|
|
137
|
+
route genuinely cannot express what you need. It is also the only way `uid` / `gid` in a procs
|
|
138
|
+
entry can work, since a process that is not root cannot become another user.
|
|
91
139
|
|
|
92
|
-
|
|
140
|
+
## Options
|
|
93
141
|
|
|
94
|
-
|
|
142
|
+
| Option | Default | Meaning |
|
|
143
|
+
| ------------------ | ------------------ | -------------------------------------------------- |
|
|
144
|
+
| `-f, --procs-file` | **required** | the JSON file listing what may run |
|
|
145
|
+
| `-u, --mqtt-url` | `mqtt://localhost` | broker url |
|
|
146
|
+
| `-n, --name` | `pc` | instance name = topic prefix |
|
|
147
|
+
| `--root` | `false` | `--install` runs the service as root (discouraged) |
|
|
148
|
+
| `-v, --verbosity` | `info` | `error`, `warn`, `info`, `debug` |
|
|
95
149
|
|
|
96
|
-
|
|
150
|
+
`--help` lists the shared options too, and `--config-schema` prints the JSON Schema a management UI
|
|
151
|
+
reads.
|
|
97
152
|
|
|
98
|
-
|
|
153
|
+
## Credits
|
|
99
154
|
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
### Topics mqttpc subscribes
|
|
105
|
-
|
|
106
|
-
#### pc/set/<process_name>/spawn
|
|
107
|
-
|
|
108
|
-
Start the process
|
|
109
|
-
|
|
110
|
-
#### pc/set/<process_name>/pipe
|
|
111
|
-
|
|
112
|
-
Pipe payload into stdin of the process
|
|
113
|
-
|
|
114
|
-
#### pc/set/<process_name>/signal
|
|
115
|
-
|
|
116
|
-
Send a signal to the process (Payload should be a string containing the signal name, e.g. "SIGHUP")
|
|
155
|
+
The output modes, buffering, `stdinFromSpawnPayload` and `enqueueSpawns` come from
|
|
156
|
+
[ddlsmurf](https://github.com/ddlsmurf)'s [PR #1](https://github.com/hobbyquaker/mqttpc/pull/1),
|
|
157
|
+
which also found the `pipe` and exit-signal bugs independently. Thank you.
|
|
117
158
|
|
|
159
|
+
[ROADMAP.md](ROADMAP.md) has what is not in yet — a sudoers snippet generator, and specialised
|
|
160
|
+
modes for systemd units and journal logs.
|
|
118
161
|
|
|
119
162
|
## License
|
|
120
163
|
|
|
121
|
-
MIT
|
|
122
|
-
|
|
164
|
+
MIT © [Sebastian Raff](https://github.com/hobbyquaker)
|
|
123
165
|
|
|
124
166
|
[mit-badge]: https://img.shields.io/badge/License-MIT-blue.svg?style=flat
|
|
125
|
-
[mit-url]: LICENSE
|
|
167
|
+
[mit-url]: LICENSE
|
package/config.js
CHANGED
|
@@ -1,27 +1,55 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
.describe('h', 'show help')
|
|
9
|
-
.alias({
|
|
10
|
-
'h': 'help',
|
|
11
|
-
'n': 'name',
|
|
12
|
-
'u': 'url',
|
|
13
|
-
'v': 'verbosity',
|
|
14
|
-
'f': 'config'
|
|
15
|
-
})
|
|
16
|
-
.default({
|
|
17
|
-
'u': 'mqtt://127.0.0.1',
|
|
18
|
-
'n': 'pc',
|
|
19
|
-
'v': 'info',
|
|
20
|
-
'f': './procs.json'
|
|
21
|
-
})
|
|
22
|
-
//.config('config')
|
|
23
|
-
.version(pkg.version)
|
|
24
|
-
.help('help')
|
|
25
|
-
.argv;
|
|
1
|
+
/**
|
|
2
|
+
* Adapter options on top of the core's parseConfig(): the shared MQTT / name / maintenance
|
|
3
|
+
* options, MQTTPC_* environment variables and --config-schema come from mqtt-interfaces-core.
|
|
4
|
+
*
|
|
5
|
+
* mqttpc runs other programs, so what it may run is a file rather than a set of flags — see
|
|
6
|
+
* --procs-file, which a management UI can edit against the JSON schema shipped with the package.
|
|
7
|
+
*/
|
|
26
8
|
|
|
27
|
-
|
|
9
|
+
import {parseConfig} from 'mqtt-interfaces-core';
|
|
10
|
+
import pkg from './package.json' with {type: 'json'};
|
|
11
|
+
|
|
12
|
+
export const OPTIONS = {
|
|
13
|
+
'procs-file': {
|
|
14
|
+
alias: 'f',
|
|
15
|
+
type: 'string',
|
|
16
|
+
describe: 'JSON file listing the processes this instance may run (see example-procs.json)',
|
|
17
|
+
demandOption: true,
|
|
18
|
+
file: {
|
|
19
|
+
format: 'json',
|
|
20
|
+
example: 'example-procs.json',
|
|
21
|
+
schema: 'procs.schema.json',
|
|
22
|
+
describe: 'processes mqttpc may run',
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
root: {
|
|
26
|
+
type: 'boolean',
|
|
27
|
+
describe: 'run the service as root — every process starts with full privileges. Prefer a sudoers entry',
|
|
28
|
+
default: false,
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
/** yargs .check(): what the option types cannot express. */
|
|
33
|
+
export function check(argv) {
|
|
34
|
+
if (argv.root && !argv.install && process.getuid && process.getuid() !== 0) {
|
|
35
|
+
throw new Error('--root only makes sense for --install, or when already running as root');
|
|
36
|
+
}
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export default parseConfig({
|
|
41
|
+
pkg,
|
|
42
|
+
options: OPTIONS,
|
|
43
|
+
defaults: {name: 'pc'},
|
|
44
|
+
check,
|
|
45
|
+
examples: [
|
|
46
|
+
['$0 -u mqtt://broker -f /etc/mqttpc/pc.procs.json', 'run in the foreground'],
|
|
47
|
+
['sudo $0 --install -n pc -u mqtt://broker -f /etc/mqttpc/pc.procs.json', 'install as mqttpc@pc'],
|
|
48
|
+
['sudo $0 --install -n pc --root -f …', 'install running as root (discouraged, see the README)'],
|
|
49
|
+
],
|
|
50
|
+
epilog:
|
|
51
|
+
'mqttpc runs the programs named in its procs file on request over MQTT. Anyone who can\n' +
|
|
52
|
+
'publish to <name>/set/# can run every one of them, so keep that file small and give the\n' +
|
|
53
|
+
'instance its own broker credentials with an ACL. Prefer a sudoers entry over --root.\n' +
|
|
54
|
+
pkg.homepage,
|
|
55
|
+
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"backup": {
|
|
3
|
+
"path": "/usr/local/bin/my-backup.sh",
|
|
4
|
+
"comment": "started with: mosquitto_pub -t pc/set/backup/spawn -n"
|
|
5
|
+
},
|
|
6
|
+
"disk-free": {
|
|
7
|
+
"path": "/bin/df",
|
|
8
|
+
"args": ["-h"]
|
|
9
|
+
},
|
|
10
|
+
"reboot": {
|
|
11
|
+
"path": "/usr/bin/sudo",
|
|
12
|
+
"args": ["/usr/sbin/reboot"],
|
|
13
|
+
"comment": "needs a sudoers entry — see the README; do not run mqttpc as root for this"
|
|
14
|
+
},
|
|
15
|
+
"log-tail": {
|
|
16
|
+
"path": "/usr/bin/tail",
|
|
17
|
+
"args": ["-f", "/var/log/syslog"],
|
|
18
|
+
"disableStdin": true
|
|
19
|
+
}
|
|
20
|
+
}
|
package/index.js
CHANGED
|
@@ -1,141 +1,70 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
});
|
|
30
|
-
|
|
31
|
-
mqtt.on('close', function () {
|
|
32
|
-
if (mqttConnected) {
|
|
33
|
-
mqttConnected = false;
|
|
34
|
-
log.info('mqtt closed ' + config.url);
|
|
3
|
+
/**
|
|
4
|
+
* mqttpc — control processes on a host over MQTT, on mqtt-interfaces-core.
|
|
5
|
+
*
|
|
6
|
+
* <name>/set/<proc>/spawn start it (payload ignored)
|
|
7
|
+
* <name>/set/<proc>/signal send a signal, default SIGTERM
|
|
8
|
+
* <name>/set/<proc>/pipe write the payload to its stdin
|
|
9
|
+
* <name>/status/<proc>/{pid,exit,error,stdout,stderr}
|
|
10
|
+
*
|
|
11
|
+
* The procs file is the allowlist: MQTT picks which entry runs, never what the command is.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {createAdapter} from 'mqtt-interfaces-core';
|
|
15
|
+
import config from './config.js';
|
|
16
|
+
import pkg from './package.json' with {type: 'json'};
|
|
17
|
+
import {handle as handleInstall, ROOT_WARNING} from './lib/install.js';
|
|
18
|
+
import {loadProcs, needsRoot} from './lib/procs.js';
|
|
19
|
+
import {createRunner} from './lib/runner.js';
|
|
20
|
+
import {createDispatch} from './lib/dispatch.js';
|
|
21
|
+
|
|
22
|
+
handleInstall(config); // --install / --uninstall never reach the rest
|
|
23
|
+
|
|
24
|
+
const {procs, problems} = loadProcs(config.procsFile);
|
|
25
|
+
if (problems.length > 0) {
|
|
26
|
+
// starting with an empty allowlist would look like it worked and then do nothing
|
|
27
|
+
for (const problem of problems) {
|
|
28
|
+
console.error(`mqttpc: ${problem}`);
|
|
35
29
|
}
|
|
36
|
-
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
|
|
34
|
+
let runner;
|
|
35
|
+
let dispatch;
|
|
36
|
+
|
|
37
|
+
const adapter = createAdapter({
|
|
38
|
+
pkg,
|
|
39
|
+
config,
|
|
40
|
+
deviceLabel: 'proc',
|
|
41
|
+
info: () => ({
|
|
42
|
+
procsFile: config.procsFile,
|
|
43
|
+
processes: Object.keys(procs),
|
|
44
|
+
running: runner ? runner.names : [],
|
|
45
|
+
root: isRoot,
|
|
46
|
+
}),
|
|
47
|
+
onSet: (...args) => dispatch(...args),
|
|
48
|
+
// children of a process controller outlive nothing: stop them with it
|
|
49
|
+
onShutdown: () => runner.stopAll(),
|
|
42
50
|
});
|
|
43
51
|
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
log.
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
var p = tmp[2];
|
|
51
|
-
var cmd = tmp[3];
|
|
52
|
-
|
|
53
|
-
if (!procs[p]) {
|
|
54
|
-
log.error('unknown process ' + p);
|
|
55
|
-
return;
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
var proc = procs[p];
|
|
59
|
-
|
|
60
|
-
switch (cmd) {
|
|
61
|
-
case 'pipe':
|
|
62
|
-
if (proc.disableStdin) {
|
|
63
|
-
log.error('piping to stdin disabled');
|
|
64
|
-
return;
|
|
65
|
-
}
|
|
66
|
-
if (!proc._) {
|
|
67
|
-
log.error(p, 'not running');
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
break;
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
case 'spawn':
|
|
74
|
-
if (proc._) {
|
|
75
|
-
log.error(p, 'already running', proc._.pid);
|
|
76
|
-
return;
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
mqtt.publish(config.name + '/status/' + p + '/error', '', {retain: true});
|
|
80
|
-
|
|
81
|
-
proc._ = spawn(proc.path, proc.args, {
|
|
82
|
-
cwd: proc.cwd,
|
|
83
|
-
env: proc.env,
|
|
84
|
-
uid: proc.uid,
|
|
85
|
-
gid: proc.gid,
|
|
86
|
-
shell: proc.shell,
|
|
87
|
-
stdio: 'pipe'
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
if (proc._.pid) {
|
|
91
|
-
log.info(p, 'started', proc.path, proc._.pid);
|
|
92
|
-
mqtt.publish(config.name + '/status/' + p + '/pid', '' + proc._.pid, {retain: true});
|
|
93
|
-
|
|
94
|
-
} else {
|
|
95
|
-
log.error(p, 'no pid, start failed');
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
proc._.stdout.on('data', function (data) {
|
|
99
|
-
log.debug(p, 'stdout', data.toString().replace(/\n$/, ''));
|
|
100
|
-
if (!proc.disableStdout) mqtt.publish(config.name + '/status/' + p + '/stdout', data.toString(), {retain: true});
|
|
101
|
-
});
|
|
102
|
-
|
|
103
|
-
proc._.stderr.on('data', function (data) {
|
|
104
|
-
log.debug(p, 'stderr', data.toString().replace(/\n$/, ''));
|
|
105
|
-
if (!proc.disableStderr) mqtt.publish(config.name + '/status/' + p + '/stderr', data.toString(), {retain: true});
|
|
106
|
-
});
|
|
107
|
-
|
|
108
|
-
proc._.on('exit', function (code, signal) {
|
|
109
|
-
log.info(p, 'exit', code, signal);
|
|
110
|
-
mqtt.publish(config.name + '/status/' + p + '/pid', '', {retain: true});
|
|
111
|
-
mqtt.publish(config.name + '/status/' + p + '/exit', '' + (typeof code === null ? signal : code), {retain: true});
|
|
112
|
-
delete(proc._);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
proc._.on('error', function (e) {
|
|
116
|
-
log.error(p, 'error', e);
|
|
117
|
-
mqtt.publish(config.name + '/status/' + p + '/error', e.toString(), {retain: true});
|
|
118
|
-
});
|
|
119
|
-
|
|
120
|
-
break;
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
case 'signal':
|
|
124
|
-
if (!proc._) {
|
|
125
|
-
log.error(p, 'not running');
|
|
126
|
-
return;
|
|
127
|
-
}
|
|
128
|
-
if (!payload.match(/SIG[A-Z]+/)) {
|
|
129
|
-
log.error(p, 'invalid signal', payload);
|
|
130
|
-
}
|
|
131
|
-
log.info(p, 'sending', payload);
|
|
132
|
-
proc._.kill(payload);
|
|
133
|
-
|
|
134
|
-
break;
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
default:
|
|
138
|
-
log.error('received unknown command ' + cmd + ' for process ' + p);
|
|
139
|
-
}
|
|
140
|
-
|
|
52
|
+
runner = createRunner({
|
|
53
|
+
procs,
|
|
54
|
+
log: adapter.log,
|
|
55
|
+
isRoot,
|
|
56
|
+
publish: (item, value, options) => adapter.pubStatus(item, value, options),
|
|
141
57
|
});
|
|
58
|
+
dispatch = createDispatch({procs, runner, log: adapter.log});
|
|
59
|
+
|
|
60
|
+
if (isRoot) {
|
|
61
|
+
adapter.log.warn(ROOT_WARNING.replace('<name>', config.name));
|
|
62
|
+
}
|
|
63
|
+
const wantsRoot = needsRoot(procs);
|
|
64
|
+
if (wantsRoot.length > 0 && !isRoot) {
|
|
65
|
+
// uid/gid are silently ignored when we cannot use them, which would look like a bug later
|
|
66
|
+
adapter.log.warn(`uid/gid ignored for ${wantsRoot.join(', ')} — only root can start a process as another user`);
|
|
67
|
+
}
|
|
68
|
+
adapter.log.info(`${Object.keys(procs).length} process(es) from ${config.procsFile}`);
|
|
69
|
+
adapter.start();
|
|
70
|
+
adapter.setDeviceConnected(true); // nothing to connect to: the host is always there
|
package/lib/dispatch.js
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `<name>/set/<proc>/<command>` → the runner.
|
|
3
|
+
*
|
|
4
|
+
* Its own module because it is the seam where index.js used to drop the spawn payload on the
|
|
5
|
+
* floor, so `stdinFromSpawnPayload` received an empty string and the process read EOF immediately.
|
|
6
|
+
* A unit test of the runner cannot see that; a unit test of this can.
|
|
7
|
+
*
|
|
8
|
+
* The **raw** payload is what reaches a process, not the parsed value: stdin is bytes the user
|
|
9
|
+
* chose, and `{"val": …}` unwrapping or number coercion would corrupt it.
|
|
10
|
+
*/
|
|
11
|
+
export function createDispatch({procs, runner, log}) {
|
|
12
|
+
return function onSet(parts, value, topic, raw) {
|
|
13
|
+
const [name, command] = parts;
|
|
14
|
+
if (!Object.hasOwn(procs, name)) {
|
|
15
|
+
log.warn('no process named', name);
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
switch (command) {
|
|
19
|
+
case 'spawn':
|
|
20
|
+
runner.start(name, raw ?? '');
|
|
21
|
+
break;
|
|
22
|
+
case 'signal':
|
|
23
|
+
runner.signal(name, raw ?? '');
|
|
24
|
+
break;
|
|
25
|
+
case 'pipe':
|
|
26
|
+
runner.pipe(name, raw ?? '');
|
|
27
|
+
break;
|
|
28
|
+
default:
|
|
29
|
+
log.warn('unknown command', command, 'for', name);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
}
|
package/lib/install.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* --install / --uninstall: systemd template service mqttpc@<name>.
|
|
3
|
+
*
|
|
4
|
+
* Two deliberate differences from every other adapter in the fleet, both because this one exists
|
|
5
|
+
* to run *other* programs:
|
|
6
|
+
*
|
|
7
|
+
* hardening: false the systemd sandbox is inherited by every child, so ProtectHome would hide
|
|
8
|
+
* /home from a backup script, ProtectSystem=full would make /usr read-only
|
|
9
|
+
* for it, and NoNewPrivileges=true would stop `sudo` working at all — which
|
|
10
|
+
* is the arrangement that lets mqttpc stay unprivileged and still reboot the
|
|
11
|
+
* machine.
|
|
12
|
+
* --root opt-in User=root. Discouraged: it turns "may publish to <name>/set/#" into
|
|
13
|
+
* "may run anything as root". The README documents the sudoers alternative.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {createInstaller} from 'mqtt-interfaces-core';
|
|
17
|
+
|
|
18
|
+
export const SERVICE = 'mqttpc';
|
|
19
|
+
export const ENV_PREFIX = 'MQTTPC';
|
|
20
|
+
|
|
21
|
+
/** The installer for one run — the unit's user depends on --root, so it cannot be built once. */
|
|
22
|
+
export function installerFor({root = false} = {}) {
|
|
23
|
+
return createInstaller({
|
|
24
|
+
service: SERVICE,
|
|
25
|
+
envPrefix: ENV_PREFIX,
|
|
26
|
+
description: `${SERVICE} %i - process control over MQTT`,
|
|
27
|
+
documentation: 'https://github.com/hobbyquaker/mqttpc',
|
|
28
|
+
user: root ? 'root' : SERVICE,
|
|
29
|
+
// a process controller cannot sandbox itself without sandboxing what it runs
|
|
30
|
+
hardening: false,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The warning `--install --root` prints, and `index.js` logs on every start. */
|
|
35
|
+
export const ROOT_WARNING =
|
|
36
|
+
'running as root: anyone who can publish to <name>/set/# can run every process in the procs ' +
|
|
37
|
+
'file with full privileges. A sudoers entry for the few commands that need it is safer — see ' +
|
|
38
|
+
'https://github.com/hobbyquaker/mqttpc#running-without-root';
|
|
39
|
+
|
|
40
|
+
export function handle(config, log = console.log) {
|
|
41
|
+
if (config.install && config.root) {
|
|
42
|
+
log(`WARNING: ${ROOT_WARNING}`);
|
|
43
|
+
}
|
|
44
|
+
return installerFor({root: config.root}).handle(config);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export const {unitFile, envFile} = installerFor();
|
package/lib/procs.js
ADDED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading and validating the procs file — the allowlist of what this instance may run.
|
|
3
|
+
*
|
|
4
|
+
* A name that is not in here cannot be started over MQTT, so this file *is* the security boundary:
|
|
5
|
+
* MQTT chooses which entry to run, never what the command is. Arguments never come from a message.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
|
|
10
|
+
/** A process name is one MQTT topic level. */
|
|
11
|
+
export const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
12
|
+
|
|
13
|
+
const KNOWN = new Set([
|
|
14
|
+
'path',
|
|
15
|
+
'args',
|
|
16
|
+
'cwd',
|
|
17
|
+
'env',
|
|
18
|
+
'uid',
|
|
19
|
+
'gid',
|
|
20
|
+
'shell',
|
|
21
|
+
'disableStdin',
|
|
22
|
+
'disableStdout',
|
|
23
|
+
'disableStderr',
|
|
24
|
+
'stdout',
|
|
25
|
+
'stderr',
|
|
26
|
+
'output',
|
|
27
|
+
'bufferMax',
|
|
28
|
+
'stdinFromSpawnPayload',
|
|
29
|
+
'enqueueSpawns',
|
|
30
|
+
'comment',
|
|
31
|
+
]);
|
|
32
|
+
|
|
33
|
+
/** How an output stream is published. */
|
|
34
|
+
export const OUTPUT_MODES = ['drop', 'stream', 'stream_retain', 'buffer', 'buffer_retain'];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Check a parsed procs object, returning the problems as strings.
|
|
38
|
+
*
|
|
39
|
+
* Reported rather than thrown one at a time: someone editing this file in a config UI wants every
|
|
40
|
+
* mistake at once, not the first one.
|
|
41
|
+
*
|
|
42
|
+
* @returns {string[]} empty when the file is usable
|
|
43
|
+
*/
|
|
44
|
+
export function validate(procs) {
|
|
45
|
+
const problems = [];
|
|
46
|
+
if (!procs || typeof procs !== 'object' || Array.isArray(procs)) {
|
|
47
|
+
return ['the procs file must be a JSON object of <name>: {path, …}'];
|
|
48
|
+
}
|
|
49
|
+
for (const [name, proc] of Object.entries(procs)) {
|
|
50
|
+
if (!NAME_RE.test(name)) {
|
|
51
|
+
problems.push(`${name}: not a usable process name — it becomes an MQTT topic level`);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (!proc || typeof proc !== 'object' || Array.isArray(proc)) {
|
|
55
|
+
problems.push(`${name}: must be an object with at least a path`);
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
if (typeof proc.path !== 'string' || proc.path === '') {
|
|
59
|
+
problems.push(`${name}: needs a path`);
|
|
60
|
+
}
|
|
61
|
+
if (proc.args !== undefined && (!Array.isArray(proc.args) || proc.args.some((a) => typeof a !== 'string'))) {
|
|
62
|
+
problems.push(`${name}: args must be an array of strings, one argument per element`);
|
|
63
|
+
}
|
|
64
|
+
if (proc.env !== undefined && (typeof proc.env !== 'object' || proc.env === null || Array.isArray(proc.env))) {
|
|
65
|
+
problems.push(`${name}: env must be an object`);
|
|
66
|
+
}
|
|
67
|
+
for (const stream of ['stdout', 'stderr', 'output']) {
|
|
68
|
+
if (proc[stream] !== undefined && !OUTPUT_MODES.includes(proc[stream])) {
|
|
69
|
+
problems.push(`${name}: ${stream} must be one of ${OUTPUT_MODES.join(', ')}`);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
if (proc.bufferMax !== undefined && !(Number.isInteger(proc.bufferMax) && proc.bufferMax > 0)) {
|
|
73
|
+
problems.push(`${name}: bufferMax must be a positive number of bytes`);
|
|
74
|
+
}
|
|
75
|
+
for (const key of Object.keys(proc)) {
|
|
76
|
+
if (!KNOWN.has(key)) {
|
|
77
|
+
problems.push(`${name}: unknown attribute ${key}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return problems;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Read and validate the procs file.
|
|
86
|
+
*
|
|
87
|
+
* @param {string} file
|
|
88
|
+
* @param {{fs?: object}} [deps]
|
|
89
|
+
* @returns {{procs: object, problems: string[]}}
|
|
90
|
+
*/
|
|
91
|
+
export function loadProcs(file, {fs: fsImpl = fs} = {}) {
|
|
92
|
+
let raw;
|
|
93
|
+
try {
|
|
94
|
+
raw = fsImpl.readFileSync(file, 'utf8');
|
|
95
|
+
} catch (error) {
|
|
96
|
+
return {procs: {}, problems: [`cannot read ${file}: ${error.message}`]};
|
|
97
|
+
}
|
|
98
|
+
let parsed;
|
|
99
|
+
try {
|
|
100
|
+
parsed = JSON.parse(raw);
|
|
101
|
+
} catch (error) {
|
|
102
|
+
return {procs: {}, problems: [`${file} is not valid JSON: ${error.message}`]};
|
|
103
|
+
}
|
|
104
|
+
const problems = validate(parsed);
|
|
105
|
+
return {procs: problems.length > 0 ? {} : parsed, problems};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* The options `child_process.spawn` gets for one entry.
|
|
110
|
+
*
|
|
111
|
+
* `uid`/`gid` are only passed when we can actually use them: a process that is not root cannot
|
|
112
|
+
* become another user, and passing them anyway makes spawn fail with EPERM instead of saying why.
|
|
113
|
+
*/
|
|
114
|
+
export function spawnOptions(proc, {isRoot = false} = {}) {
|
|
115
|
+
const options = {stdio: 'pipe'};
|
|
116
|
+
if (proc.cwd) {
|
|
117
|
+
options.cwd = proc.cwd;
|
|
118
|
+
}
|
|
119
|
+
if (proc.env) {
|
|
120
|
+
options.env = proc.env;
|
|
121
|
+
}
|
|
122
|
+
if (proc.shell !== undefined) {
|
|
123
|
+
options.shell = proc.shell;
|
|
124
|
+
}
|
|
125
|
+
if (isRoot) {
|
|
126
|
+
if (proc.uid !== undefined) {
|
|
127
|
+
options.uid = proc.uid;
|
|
128
|
+
}
|
|
129
|
+
if (proc.gid !== undefined) {
|
|
130
|
+
options.gid = proc.gid;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return options;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Which entries ask for a uid/gid that only root can grant — worth a warning at startup. */
|
|
137
|
+
export function needsRoot(procs) {
|
|
138
|
+
return Object.entries(procs)
|
|
139
|
+
.filter(([, proc]) => proc && (proc.uid !== undefined || proc.gid !== undefined))
|
|
140
|
+
.map(([name]) => name);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** Signals `kill()` accepts. Anything else would throw rather than be delivered. */
|
|
144
|
+
export function isSignal(value) {
|
|
145
|
+
return typeof value === 'string' && /^SIG[A-Z0-9]+$/.test(value.trim());
|
|
146
|
+
}
|
package/lib/runner.js
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Running the processes: spawn, signal, pipe, and what each of those publishes.
|
|
3
|
+
*
|
|
4
|
+
* Separated from index.js so the lifecycle can be tested against a fake spawn — a real one would
|
|
5
|
+
* make the tests depend on the machine they run on.
|
|
6
|
+
*
|
|
7
|
+
* The output modes, the buffering, `stdinFromSpawnPayload` and `enqueueSpawns` come from
|
|
8
|
+
* ddlsmurf's PR #1 (2023), reworked here: `stream` stays the default, as 1.x behaved, rather than
|
|
9
|
+
* `drop`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {spawn as nodeSpawn} from 'node:child_process';
|
|
13
|
+
import {spawnOptions, isSignal} from './procs.js';
|
|
14
|
+
|
|
15
|
+
/** Bytes kept in buffer mode before the oldest output is dropped. */
|
|
16
|
+
export const DEFAULT_BUFFER_MAX = 128 * 1024;
|
|
17
|
+
|
|
18
|
+
/** What an entry can ask for per output stream. */
|
|
19
|
+
export const OUTPUT_MODES = ['drop', 'stream', 'stream_retain', 'buffer', 'buffer_retain'];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The mode for one stream, honouring the 1.x `disableStdout` / `disableStderr` booleans.
|
|
23
|
+
*
|
|
24
|
+
* `stdout` and `stderr` stream by default, which is what 1.x did (retained, which was wrong — see
|
|
25
|
+
* the changelog). `output`, the two combined, is off unless asked for: defaulting it to stream
|
|
26
|
+
* would publish everything twice.
|
|
27
|
+
*/
|
|
28
|
+
export function outputMode(proc, stream) {
|
|
29
|
+
const explicit = proc[stream];
|
|
30
|
+
if (typeof explicit === 'string') {
|
|
31
|
+
return explicit;
|
|
32
|
+
}
|
|
33
|
+
if (stream === 'output') {
|
|
34
|
+
return 'drop';
|
|
35
|
+
}
|
|
36
|
+
if (stream === 'stdout' && proc.disableStdout) {
|
|
37
|
+
return 'drop';
|
|
38
|
+
}
|
|
39
|
+
if (stream === 'stderr' && proc.disableStderr) {
|
|
40
|
+
return 'drop';
|
|
41
|
+
}
|
|
42
|
+
return 'stream';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {object} options
|
|
47
|
+
* @param {object} options.procs the validated procs file
|
|
48
|
+
* @param {object} options.log
|
|
49
|
+
* @param {(item: string, value: *, opts?: object) => void} options.publish `<name>/status/<item>`
|
|
50
|
+
* @param {boolean} [options.isRoot] whether uid/gid in an entry can be honoured
|
|
51
|
+
* @param {Function} [options.spawn] for tests
|
|
52
|
+
*/
|
|
53
|
+
export function createRunner({procs, log, publish, isRoot = false, spawn = nodeSpawn}) {
|
|
54
|
+
/** name → the running ChildProcess. Absent means not running. */
|
|
55
|
+
const running = new Map();
|
|
56
|
+
/** name → payloads waiting for their turn (`enqueueSpawns`). */
|
|
57
|
+
const queues = new Map();
|
|
58
|
+
/** name → {stream: {chunks, bytes, clipped}} while buffering. */
|
|
59
|
+
const buffers = new Map();
|
|
60
|
+
|
|
61
|
+
const statusOf = (name, item, value, opts) => publish(`${name}/${item}`, value, opts);
|
|
62
|
+
|
|
63
|
+
function bufferOf(name, stream) {
|
|
64
|
+
const perProc = buffers.get(name) || {};
|
|
65
|
+
buffers.set(name, perProc);
|
|
66
|
+
perProc[stream] = perProc[stream] || {chunks: [], bytes: 0, clipped: 0};
|
|
67
|
+
return perProc[stream];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Append, dropping the oldest chunks once the buffer is over its limit. */
|
|
71
|
+
function appendBuffer(name, stream, data, max) {
|
|
72
|
+
const buf = bufferOf(name, stream);
|
|
73
|
+
buf.chunks.push(data);
|
|
74
|
+
buf.bytes += data.length;
|
|
75
|
+
while (buf.chunks.length > 1 && buf.bytes > max) {
|
|
76
|
+
const dropped = buf.chunks.shift();
|
|
77
|
+
buf.bytes -= dropped.length;
|
|
78
|
+
buf.clipped += dropped.length;
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function handleOutput(name, proc, stream, data) {
|
|
83
|
+
const mode = outputMode(proc, stream);
|
|
84
|
+
const max = Number(proc.bufferMax) > 0 ? Number(proc.bufferMax) : DEFAULT_BUFFER_MAX;
|
|
85
|
+
switch (mode) {
|
|
86
|
+
case 'drop':
|
|
87
|
+
break;
|
|
88
|
+
case 'stream':
|
|
89
|
+
case 'stream_retain':
|
|
90
|
+
statusOf(name, stream, data.toString(), {retain: mode === 'stream_retain'});
|
|
91
|
+
break;
|
|
92
|
+
case 'buffer':
|
|
93
|
+
case 'buffer_retain':
|
|
94
|
+
appendBuffer(name, stream, data, max);
|
|
95
|
+
break;
|
|
96
|
+
default:
|
|
97
|
+
// validate() refuses an unknown mode, so this is only reachable through the api
|
|
98
|
+
log.warn(name, 'unknown output mode', mode, 'for', stream);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Publish what buffer mode collected, once, when the process is done. */
|
|
103
|
+
function flushBuffer(name, proc, stream) {
|
|
104
|
+
const mode = outputMode(proc, stream);
|
|
105
|
+
if (mode !== 'buffer' && mode !== 'buffer_retain') {
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
const buf = bufferOf(name, stream);
|
|
109
|
+
if (buf.bytes === 0 && buf.clipped === 0) {
|
|
110
|
+
return;
|
|
111
|
+
}
|
|
112
|
+
const body = Buffer.concat(buf.chunks, buf.bytes).toString();
|
|
113
|
+
// say so rather than silently handing back a truncated log
|
|
114
|
+
const text = buf.clipped > 0 ? `...(clipped ${buf.clipped} bytes)...\n${body}` : body;
|
|
115
|
+
statusOf(name, stream, text, {retain: mode === 'buffer_retain'});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function start(name, payload = '') {
|
|
119
|
+
const proc = procs[name];
|
|
120
|
+
if (running.has(name)) {
|
|
121
|
+
if (proc.enqueueSpawns) {
|
|
122
|
+
const queue = queues.get(name) || [];
|
|
123
|
+
queue.push(payload);
|
|
124
|
+
queues.set(name, queue);
|
|
125
|
+
log.info(name, 'is running; queued (' + queue.length + ' waiting)');
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
log.warn(name, 'is already running as pid', running.get(name).pid);
|
|
129
|
+
return false;
|
|
130
|
+
}
|
|
131
|
+
let child;
|
|
132
|
+
try {
|
|
133
|
+
child = spawn(proc.path, proc.args || [], spawnOptions(proc, {isRoot}));
|
|
134
|
+
} catch (error) {
|
|
135
|
+
log.error(name, 'could not be started:', error.message);
|
|
136
|
+
statusOf(name, 'error', error.message);
|
|
137
|
+
return false;
|
|
138
|
+
}
|
|
139
|
+
running.set(name, child);
|
|
140
|
+
buffers.delete(name); // this run's output, not the last one's
|
|
141
|
+
statusOf(name, 'error', ''); // a previous failure is not this run's
|
|
142
|
+
|
|
143
|
+
child.on('error', (error) => {
|
|
144
|
+
// spawn failures arrive here asynchronously, with no exit event to follow
|
|
145
|
+
log.error(name, 'failed:', error.message);
|
|
146
|
+
statusOf(name, 'error', error.message);
|
|
147
|
+
statusOf(name, 'pid', '');
|
|
148
|
+
running.delete(name);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
for (const stream of ['stdout', 'stderr']) {
|
|
152
|
+
if (child[stream]) {
|
|
153
|
+
child[stream].on('data', (data) => {
|
|
154
|
+
log.debug(name, stream, String(data).trimEnd());
|
|
155
|
+
handleOutput(name, proc, stream, data);
|
|
156
|
+
handleOutput(name, proc, 'output', data);
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
child.on('exit', (code, signal) => {
|
|
162
|
+
log.info(name, 'exited', signal ? `on ${signal}` : `with code ${code}`);
|
|
163
|
+
running.delete(name);
|
|
164
|
+
for (const stream of ['stdout', 'stderr', 'output']) {
|
|
165
|
+
flushBuffer(name, proc, stream);
|
|
166
|
+
}
|
|
167
|
+
buffers.delete(name);
|
|
168
|
+
statusOf(name, 'pid', '');
|
|
169
|
+
// `code` is null when a signal killed it — 1.x tested `typeof code === null`, which is
|
|
170
|
+
// never true, so the signal never made it out
|
|
171
|
+
statusOf(name, 'exit', signal === null || signal === undefined ? code : signal);
|
|
172
|
+
const queue = queues.get(name);
|
|
173
|
+
if (queue && queue.length > 0) {
|
|
174
|
+
log.info(name, 'finished; starting the next queued run');
|
|
175
|
+
start(name, queue.shift());
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
if (child.pid) {
|
|
180
|
+
log.info(name, 'started', proc.path, 'as pid', child.pid);
|
|
181
|
+
statusOf(name, 'pid', child.pid);
|
|
182
|
+
}
|
|
183
|
+
if (proc.stdinFromSpawnPayload && child.stdin) {
|
|
184
|
+
// one-shot: the payload is the whole input, so close stdin or the program waits for EOF
|
|
185
|
+
child.stdin.write(String(payload));
|
|
186
|
+
child.stdin.end();
|
|
187
|
+
}
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function signal(name, value) {
|
|
192
|
+
const child = running.get(name);
|
|
193
|
+
if (!child) {
|
|
194
|
+
log.warn(name, 'is not running');
|
|
195
|
+
return false;
|
|
196
|
+
}
|
|
197
|
+
const wanted = String(value ?? '').trim() || 'SIGTERM';
|
|
198
|
+
if (!isSignal(wanted)) {
|
|
199
|
+
// kill() throws on an unknown signal, so this has to stop here
|
|
200
|
+
log.error(name, 'not a signal:', wanted);
|
|
201
|
+
return false;
|
|
202
|
+
}
|
|
203
|
+
log.info(name, 'sending', wanted);
|
|
204
|
+
try {
|
|
205
|
+
child.kill(wanted);
|
|
206
|
+
} catch (error) {
|
|
207
|
+
log.error(name, 'could not be signalled:', error.message);
|
|
208
|
+
return false;
|
|
209
|
+
}
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
function pipe(name, value) {
|
|
214
|
+
const proc = procs[name];
|
|
215
|
+
if (proc.disableStdin) {
|
|
216
|
+
log.warn(name, 'has stdin disabled');
|
|
217
|
+
return false;
|
|
218
|
+
}
|
|
219
|
+
const child = running.get(name);
|
|
220
|
+
if (!child || !child.stdin || child.stdin.destroyed) {
|
|
221
|
+
log.warn(name, 'is not running');
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
const text = String(value ?? '');
|
|
225
|
+
if (text === '') {
|
|
226
|
+
// a program that reads until EOF never finishes otherwise
|
|
227
|
+
log.debug(name, 'closing stdin');
|
|
228
|
+
child.stdin.end();
|
|
229
|
+
return true;
|
|
230
|
+
}
|
|
231
|
+
// 1.x validated all of this and then never wrote anything — the feature had never worked
|
|
232
|
+
child.stdin.write(text);
|
|
233
|
+
log.debug(name, 'stdin <', text.trimEnd());
|
|
234
|
+
return true;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
return {
|
|
238
|
+
start,
|
|
239
|
+
signal,
|
|
240
|
+
pipe,
|
|
241
|
+
isRunning: (name) => running.has(name),
|
|
242
|
+
queued: (name) => (queues.get(name) || []).length,
|
|
243
|
+
get names() {
|
|
244
|
+
return [...running.keys()];
|
|
245
|
+
},
|
|
246
|
+
/** SIGTERM everything still running, so a restart does not orphan children. */
|
|
247
|
+
stopAll() {
|
|
248
|
+
queues.clear(); // a queued run must not start while we are going down
|
|
249
|
+
for (const [name, child] of running) {
|
|
250
|
+
log.info(name, 'stopping (mqttpc is shutting down)');
|
|
251
|
+
try {
|
|
252
|
+
child.kill('SIGTERM');
|
|
253
|
+
} catch {
|
|
254
|
+
// already gone
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
};
|
|
259
|
+
}
|
package/package.json
CHANGED
|
@@ -1,36 +1,66 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mqttpc",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "Control processes on a host via MQTT. Follows the mqtt-smarthome architecture.",
|
|
5
|
+
"type": "module",
|
|
5
6
|
"main": "index.js",
|
|
7
|
+
"bin": {
|
|
8
|
+
"mqttpc": "index.js"
|
|
9
|
+
},
|
|
10
|
+
"preferGlobal": true,
|
|
11
|
+
"files": [
|
|
12
|
+
"index.js",
|
|
13
|
+
"config.js",
|
|
14
|
+
"lib/",
|
|
15
|
+
"example-procs.json",
|
|
16
|
+
"procs.schema.json"
|
|
17
|
+
],
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": "^20.19 || ^22.12 || >=24"
|
|
20
|
+
},
|
|
6
21
|
"scripts": {
|
|
7
|
-
"
|
|
22
|
+
"start": "node index.js",
|
|
23
|
+
"lint": "eslint . && prettier --check .",
|
|
24
|
+
"format": "prettier --write . && eslint --fix .",
|
|
25
|
+
"test": "node --test",
|
|
26
|
+
"deploy": "bash deploy.sh"
|
|
8
27
|
},
|
|
9
28
|
"repository": {
|
|
10
29
|
"type": "git",
|
|
11
|
-
"url": "https://github.com/hobbyquaker/mqttpc"
|
|
30
|
+
"url": "git+https://github.com/hobbyquaker/mqttpc.git"
|
|
31
|
+
},
|
|
32
|
+
"homepage": "https://github.com/hobbyquaker/mqttpc",
|
|
33
|
+
"bugs": {
|
|
34
|
+
"url": "https://github.com/hobbyquaker/mqttpc/issues"
|
|
12
35
|
},
|
|
36
|
+
"author": "Sebastian Raff <hobbyquaker@gmail.com> (https://github.com/hobbyquaker)",
|
|
37
|
+
"license": "MIT",
|
|
13
38
|
"keywords": [
|
|
14
39
|
"mqtt",
|
|
40
|
+
"mqtt-smarthome",
|
|
41
|
+
"home-automation",
|
|
15
42
|
"process",
|
|
16
43
|
"controller",
|
|
17
|
-
"
|
|
18
|
-
"stop",
|
|
44
|
+
"spawn",
|
|
19
45
|
"shell",
|
|
20
46
|
"command",
|
|
21
|
-
"pipe",
|
|
22
47
|
"stdin",
|
|
23
48
|
"stdout"
|
|
24
49
|
],
|
|
25
|
-
"
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
"
|
|
50
|
+
"mqttInterfaces": {
|
|
51
|
+
"spec": "2.0",
|
|
52
|
+
"envPrefix": "MQTTPC",
|
|
53
|
+
"needs": [],
|
|
54
|
+
"serviceExtra": []
|
|
29
55
|
},
|
|
30
|
-
"homepage": "https://github.com/hobbyquaker/mqttpc",
|
|
31
56
|
"dependencies": {
|
|
32
|
-
"mqtt": "^
|
|
33
|
-
|
|
34
|
-
|
|
57
|
+
"mqtt-interfaces-core": "^0.14.0"
|
|
58
|
+
},
|
|
59
|
+
"devDependencies": {
|
|
60
|
+
"@eslint/js": "^9",
|
|
61
|
+
"eslint": "^9",
|
|
62
|
+
"eslint-config-prettier": "^10",
|
|
63
|
+
"globals": "^16",
|
|
64
|
+
"prettier": "^3"
|
|
35
65
|
}
|
|
36
66
|
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "https://github.com/hobbyquaker/mqttpc/procs.schema.json",
|
|
4
|
+
"title": "mqttpc processes",
|
|
5
|
+
"description": "The processes this instance may run. A name that is not in here cannot be started over MQTT, so this file is the allowlist \u2014 keep it as small as the job needs.",
|
|
6
|
+
"type": "object",
|
|
7
|
+
"additionalProperties": false,
|
|
8
|
+
"propertyNames": {
|
|
9
|
+
"pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$",
|
|
10
|
+
"description": "process name; becomes an MQTT topic level, so no slashes, + or #"
|
|
11
|
+
},
|
|
12
|
+
"patternProperties": {
|
|
13
|
+
"^[A-Za-z0-9][A-Za-z0-9._-]*$": {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"additionalProperties": false,
|
|
16
|
+
"required": ["path"],
|
|
17
|
+
"properties": {
|
|
18
|
+
"path": {
|
|
19
|
+
"type": "string",
|
|
20
|
+
"minLength": 1,
|
|
21
|
+
"description": "absolute path of the program to run"
|
|
22
|
+
},
|
|
23
|
+
"args": {
|
|
24
|
+
"type": "array",
|
|
25
|
+
"items": {
|
|
26
|
+
"type": "string"
|
|
27
|
+
},
|
|
28
|
+
"description": "arguments, one per element (never a single string of them)"
|
|
29
|
+
},
|
|
30
|
+
"cwd": {
|
|
31
|
+
"type": "string",
|
|
32
|
+
"description": "working directory (default: mqttpc's own)"
|
|
33
|
+
},
|
|
34
|
+
"env": {
|
|
35
|
+
"type": "object",
|
|
36
|
+
"additionalProperties": {
|
|
37
|
+
"type": "string"
|
|
38
|
+
},
|
|
39
|
+
"description": "environment; replaces mqttpc's rather than adding to it"
|
|
40
|
+
},
|
|
41
|
+
"uid": {
|
|
42
|
+
"type": "integer",
|
|
43
|
+
"minimum": 0,
|
|
44
|
+
"description": "run as this user id \u2014 only possible when mqttpc itself runs as root"
|
|
45
|
+
},
|
|
46
|
+
"gid": {
|
|
47
|
+
"type": "integer",
|
|
48
|
+
"minimum": 0,
|
|
49
|
+
"description": "run as this group id \u2014 only possible when mqttpc itself runs as root"
|
|
50
|
+
},
|
|
51
|
+
"shell": {
|
|
52
|
+
"type": ["boolean", "string"],
|
|
53
|
+
"description": "run through a shell. Only needed for pipes and redirections; without it path and args are passed to execve untouched, which is safer"
|
|
54
|
+
},
|
|
55
|
+
"disableStdin": {
|
|
56
|
+
"type": "boolean",
|
|
57
|
+
"default": false,
|
|
58
|
+
"description": "refuse set/<name>/pipe, so nothing from MQTT reaches this program's stdin"
|
|
59
|
+
},
|
|
60
|
+
"disableStdout": {
|
|
61
|
+
"type": "boolean",
|
|
62
|
+
"default": false,
|
|
63
|
+
"description": "do not publish stdout"
|
|
64
|
+
},
|
|
65
|
+
"disableStderr": {
|
|
66
|
+
"type": "boolean",
|
|
67
|
+
"default": false,
|
|
68
|
+
"description": "do not publish stderr"
|
|
69
|
+
},
|
|
70
|
+
"comment": {
|
|
71
|
+
"type": "string",
|
|
72
|
+
"description": "free text, ignored \u2014 JSON has no comments"
|
|
73
|
+
},
|
|
74
|
+
"stdout": {
|
|
75
|
+
"enum": ["drop", "stream", "stream_retain", "buffer", "buffer_retain"],
|
|
76
|
+
"description": "how stdout is published (default: stream). drop: not published. stream: each chunk as it arrives. buffer: collected and published as one message when the process exits. The _retain variants publish retained, which only makes sense for output you want to survive a restart"
|
|
77
|
+
},
|
|
78
|
+
"stderr": {
|
|
79
|
+
"enum": ["drop", "stream", "stream_retain", "buffer", "buffer_retain"],
|
|
80
|
+
"description": "how stderr is published (default: stream). drop: not published. stream: each chunk as it arrives. buffer: collected and published as one message when the process exits. The _retain variants publish retained, which only makes sense for output you want to survive a restart"
|
|
81
|
+
},
|
|
82
|
+
"output": {
|
|
83
|
+
"enum": ["drop", "stream", "stream_retain", "buffer", "buffer_retain"],
|
|
84
|
+
"description": "stdout and stderr combined, in the order they were flushed, on <name>/status/<proc>/output (default: drop \u2014 leaving it on would publish everything twice). drop: not published. stream: each chunk as it arrives. buffer: collected and published as one message when the process exits. The _retain variants publish retained, which only makes sense for output you want to survive a restart"
|
|
85
|
+
},
|
|
86
|
+
"bufferMax": {
|
|
87
|
+
"type": "integer",
|
|
88
|
+
"minimum": 1,
|
|
89
|
+
"default": 131072,
|
|
90
|
+
"description": "bytes kept in buffer mode; older output is dropped and the message says how much"
|
|
91
|
+
},
|
|
92
|
+
"stdinFromSpawnPayload": {
|
|
93
|
+
"type": "boolean",
|
|
94
|
+
"default": false,
|
|
95
|
+
"description": "the spawn payload is written to stdin, which is then closed \u2014 a one-shot command with its input in one message"
|
|
96
|
+
},
|
|
97
|
+
"enqueueSpawns": {
|
|
98
|
+
"type": "boolean",
|
|
99
|
+
"default": false,
|
|
100
|
+
"description": "a spawn while it is already running waits its turn instead of being refused"
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
package/.npmignore
DELETED