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 CHANGED
@@ -1,125 +1,167 @@
1
1
  # mqttpc
2
2
 
3
- [![npm version](https://badge.fury.io/js/mqttpc.svg)](https://badge.fury.io/js/mqttpc)
3
+ [![mqtt-smarthome](https://img.shields.io/badge/mqtt-smarthome-blue.svg)](https://github.com/mqtt-smarthome/mqtt-smarthome)
4
+ [![NPM version](https://badge.fury.io/js/mqttpc.svg)](http://badge.fury.io/js/mqttpc)
5
+ [![CI](https://github.com/hobbyquaker/mqttpc/actions/workflows/ci.yml/badge.svg)](https://github.com/hobbyquaker/mqttpc/actions/workflows/ci.yml)
4
6
  [![License][mit-badge]][mit-url]
5
7
 
6
- > Advanced process control via MQTT :satellite:
8
+ > Control processes on a host over MQTT :satellite:
7
9
 
8
- ## Installation
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
- Needs Node.js and npm.
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
- ````npm install -g mqttpc````
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
- ## Documentation
15
-
16
- ### Command line options
20
+ ## Install
17
21
 
18
22
  ```
19
- Usage: index.js [options]
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
- ### Config file
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
- The config file contains a JSON definition of all processes you want to control via MQTT:
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
- "<process_name>": {
42
- "path": "/usr/bin/example",
43
- "args": ["-x", "-y"],
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
- #### Availabe attributes
53
-
54
- The only mandatory attribute for each process is "path", all others are optional.
55
-
56
- * path - (string) path to the process
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
- Let's say we got a backup script located in ```/usr/local/bin/my-backup.sh``` that we want to control via MQTT.
115
+ and call `sudo` from the procs entry:
70
116
 
71
- Create a config entry like this:
72
- ```Javascript
117
+ ```json
73
118
  {
74
- "my-backup": {
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
- #### pc/status/&lt;process_name&gt;/pid
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
- After process start the pid is published retained. When process ends an empty payload will be published (removing the retained message).
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
- #### pc/status/&lt;process_name&gt;/exit
133
+ ### `--root`
89
134
 
90
- After process exit the exit code (or the killing signal) will be published retained.
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
- #### pc/status/&lt;process_name&gt;/error
140
+ ## Options
93
141
 
94
- Errors on process spawn will be published retained on this topic. On next successful process start an empty payload will be published (removing the retained message).
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
- #### pc/status/&lt;process_name&gt;/stdout
150
+ `--help` lists the shared options too, and `--config-schema` prints the JSON Schema a management UI
151
+ reads.
97
152
 
98
- The processes stdout will be published on this topic (not retained).
153
+ ## Credits
99
154
 
100
- #### pc/status/&lt;process_name&gt;/stderr
101
-
102
- The processes stderr will be published on this topic (not retained).
103
-
104
- ### Topics mqttpc subscribes
105
-
106
- #### pc/set/&lt;process_name&gt;/spawn
107
-
108
- Start the process
109
-
110
- #### pc/set/&lt;process_name&gt;/pipe
111
-
112
- Pipe payload into stdin of the process
113
-
114
- #### pc/set/&lt;process_name&gt;/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 (c) Sebastian Raff
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
- var pkg = require('./package.json');
2
- var config = require('yargs')
3
- .usage(pkg.name + ' ' + pkg.version + '\n' + pkg.description + '\n\nUsage: $0 [options]')
4
- .describe('v', 'possible values: "error", "warn", "info", "debug"')
5
- .describe('n', 'instance name. used as mqtt client id and as prefix for connected topic')
6
- .describe('u', 'mqtt broker url. See https://github.com/mqttjs/MQTT.js#connect-using-a-url')
7
- .describe('f', 'config file')
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
- module.exports = config;
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
- var pkg = require('./package.json');
4
- var log = require('yalm');
5
- var config = require('./config.js');
6
- var Mqtt = require('mqtt');
7
- var spawn = require('child_process').spawn;
8
-
9
- var procs = require(config.config);
10
-
11
- var mqttConnected;
12
-
13
- log.setLevel(config.verbosity);
14
-
15
- log.info(pkg.name + ' ' + pkg.version + ' starting');
16
- log.info('mqtt trying to connect', config.url);
17
-
18
- var mqtt = Mqtt.connect(config.url, {will: {topic: config.name + '/connected', payload: '0', retain: true}});
19
-
20
- mqtt.on('connect', function () {
21
- mqttConnected = true;
22
-
23
- log.info('mqtt connected', config.url);
24
- mqtt.publish(config.name + '/connected', '1', {retain: true});
25
-
26
- log.info('mqtt subscribe', config.name + '/set/#');
27
- mqtt.subscribe(config.name + '/set/#');
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
- mqtt.on('error', function (err) {
40
- log.error('mqtt', err);
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
- mqtt.on('message', function (topic, payload) {
45
- payload = payload.toString();
46
- log.debug('mqtt <', topic, payload);
47
-
48
- var tmp = topic.split('/');
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
@@ -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": "1.0.0",
4
- "description": "Advanced process control via MQTT",
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
- "test": "echo \"Error: no test specified\" && exit 1"
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
- "start",
18
- "stop",
44
+ "spawn",
19
45
  "shell",
20
46
  "command",
21
- "pipe",
22
47
  "stdin",
23
48
  "stdout"
24
49
  ],
25
- "author": "Sebastian Raff <hq@ccu.io>",
26
- "license": "MIT",
27
- "bugs": {
28
- "url": "https://github.com/hobbyquaker/mqttpc/issues"
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": "^1.11.1",
33
- "yalm": "^3.0.0",
34
- "yargs": "^4.7.1"
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
@@ -1,2 +0,0 @@
1
- .idea
2
- node_modules
package/procs.json DELETED
@@ -1,17 +0,0 @@
1
- {
2
- "df": {
3
- "path": "/bin/df",
4
- "args": [
5
- "-h"
6
- ]
7
- },
8
- "nope": {
9
- "path": "/bin/nope"
10
- },
11
- "echo": {
12
- "path": "/bin/echo",
13
- "args": [
14
- "\"Test!\""
15
- ]
16
- }
17
- }