altd 0.0.4 → 0.0.6

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
@@ -3,6 +3,10 @@
3
3
  # altd
4
4
  Web server access log tail dispatcher for running whitelisted commands based on GET requests.
5
5
 
6
+ This tool runs the command names you whitelist exactly as provided. There is no PATH
7
+ resolution, argument validation, rate limiting, or output limiting built in. Treat the
8
+ access log source as trusted input.
9
+
6
10
  ## Requirements
7
11
 
8
12
  - Node.js 18 or later
@@ -29,7 +33,7 @@ altd <access_log.file> -w [commands]
29
33
 
30
34
  - `-V, --version` output the version number
31
35
  - `-h, --help` output usage information
32
- - `-w, --whitelist <commands>` comma-separated list of allowed commands
36
+ - `-w, --whitelist <commands>` comma-separated list of allowed commands (executed directly)
33
37
 
34
38
  ## Example
35
39
 
@@ -37,6 +41,21 @@ altd <access_log.file> -w [commands]
37
41
  altd /var/log/nginx/access_log -w ls,hostname
38
42
  ```
39
43
 
44
+ Log lines are expected to include a request line like:
45
+
46
+ ```
47
+ GET /hostname HTTP/1.1
48
+ ```
49
+
50
+ This would execute `hostname` with no arguments. Additional path segments are passed as
51
+ arguments, for example:
52
+
53
+ ```
54
+ GET /ls/-la HTTP/1.1
55
+ ```
56
+
57
+ Would execute: `ls -la`.
58
+
40
59
  ## Contributing
41
60
 
42
61
  Bug reports and pull requests are welcome on GitHub at https://github.com/freddiefujiwara/altd
package/dist/altd.js CHANGED
@@ -1,132 +1,131 @@
1
- import {spawn} from 'child_process';
2
- import Tail from 'nodejs-tail';
3
- /**
4
- ** main class of AccessLogTailDispatcher
5
- */
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+ import Tail from "nodejs-tail";
3
+
6
4
  export default class AccessLogTailDispatcher {
7
- /**
8
- * @constructor
9
- * @param {string} file access_log
10
- * @param {Array} whitelist ['command1','command2'..]
11
- */
12
- constructor(file, whitelist) {
13
- this.file = file;
14
- this.whitelist = whitelist;
15
- this.spawn = undefined;
16
- this.tail = undefined;
17
- }
18
- /**
19
- * Get the path from 'GET /path/to/dir HTTP'
20
- * @param {string} line access_log
21
- * @return {string} /path/to/dir
22
- */
23
- path(line) {
24
- if (!(typeof line === 'string')) {
25
- return '';
26
- }
27
- let match = line.match(
28
- /GET\s((\/[a-z0-9-._~%!$&'()*+,;=:@?]+)+\/?)\sHTTP/i);
29
- if (null !== match && match.length > 2) {
30
- return match[1];
31
- }
32
- return '';
33
- }
5
+ /**
6
+ * @param {string} file access_log
7
+ * @param {object} commandRegistry { [commandName]: { execPath: string, buildArgs: (rawArgs:string[])=>string[] } }
8
+ * @param {object} [opts]
9
+ */
10
+ constructor(file, commandRegistry, opts = {}) {
11
+ this.file = file;
12
+ this.registry = commandRegistry;
34
13
 
35
- /**
36
- * Extract command and args
37
- * @param {string} path
38
- * @return {Array} [command,arg1,arg2...]
39
- */
40
- commandWithArgs(path) {
41
- if (!(typeof path === 'string')) {
42
- return [];
43
- }
44
- let commands = path.split(/\//).map(function(element, index, array) {
45
- let ret = "";
46
- try{
47
- ret = decodeURIComponent(element);
48
- }catch(e){
49
- console.error(e);
50
- }
51
- return ret;
52
- });
53
- commands.shift();
54
- return commands;
55
- }
14
+ this.spawnImpl = opts.spawnImpl ?? nodeSpawn;
15
+ this.tail = opts.tail
16
+ ?? new Tail(file, {
17
+ alwaysStat: true,
18
+ ignoreInitial: true,
19
+ persistent: true,
20
+ });
21
+ }
56
22
 
57
- /**
58
- * Filter by whitelist
59
- * @param {Array} commandWithArgs [command,arg1,arg2...]
60
- * @param {Array} whitelist ['command1','command2'...]
61
- * @return {Array} filtered commandWithArgs
62
- */
63
- filterByWhitelist(commandWithArgs, whitelist) {
64
- if (!this.isArray(commandWithArgs) ||
65
- !this.isArray(whitelist) ||
66
- commandWithArgs.length == 0 ||
67
- whitelist.indexOf(commandWithArgs[0]) == -1
68
- ) {
69
- return [];
70
- }
71
- return commandWithArgs;
72
- }
23
+ /**
24
+ * Extract request path from a typical access log line.
25
+ * More robust: parse "METHOD <url> HTTP/..."
26
+ * @param {string} line
27
+ * @returns {string} pathname like "/a/b"
28
+ */
29
+ extractPath(line) {
30
+ if (typeof line !== "string") return "";
73
31
 
74
- /**
75
- * Dispatch
76
- * @param {Array} commandWithArgs [command,arg1,arg2...]
77
- */
78
- dispatch(commandWithArgs) {
79
- if (commandWithArgs.length == 0) {
80
- return;
81
- }
82
- let command = commandWithArgs.shift();
83
- let proc = this.spawn(command, commandWithArgs);
84
- proc.on('error', (err) => {
85
- console.error(err);
86
- });
87
- proc.stdout.on('data', (data) => {
88
- process.stdout.write(data.toString());
89
- });
90
- }
32
+ // Find something like: GET /foo/bar HTTP/1.1
33
+ const m = line.match(
34
+ /\b(GET|POST|PUT|DELETE|HEAD|OPTIONS)\s+(\S+)\s+HTTP\/\d(?:\.\d)?\b/i,
35
+ );
36
+ if (!m) return "";
91
37
 
92
- /**
93
- * isArray
94
- * @param {object} obj [command,arg1,arg2...]
95
- * @return {boolean}
96
- */
97
- isArray(obj) {
98
- return Object.prototype.toString.call(obj) === '[object Array]';
38
+ const rawTarget = m[2];
39
+
40
+ if (rawTarget.startsWith("http://") || rawTarget.startsWith("https://")) {
41
+ try {
42
+ return new URL(rawTarget).pathname || "";
43
+ } catch {
44
+ return "";
45
+ }
99
46
  }
100
47
 
101
- /**
102
- * run
103
- * @param {string} file
104
- * @param {Array} whitelist ['command1','command2'...]
105
- */
106
- run(file, whitelist) {
107
- if ( typeof file !== 'undefined') {
108
- this.file = file;
109
- }
110
- if ( typeof whitelist !== 'undefined') {
111
- this.whitelist = whitelist;
112
- }
113
- if ( typeof this.spawn === 'undefined') {
114
- this.spawn = spawn;
115
- }
116
- if ( typeof this.tail === 'undefined') {
117
- this.tail = new Tail(this.file,
118
- {alwaysStat: true, ignoreInitial: true, persistent: true});
119
- }
120
- this.tail.on('line', (line) => {
121
- this.dispatch(
122
- this.filterByWhitelist(
123
- this.commandWithArgs(
124
- this.path(line)),
125
- this.whitelist));
126
- });
127
- this.tail.on('close', () => {
128
- console.log('watching stopped');
129
- });
130
- this.tail.watch();
48
+ return rawTarget;
49
+ }
50
+
51
+ /**
52
+ * "/cmd/a/b" -> ["cmd","a","b"] (safe decode, size limits)
53
+ * @param {string} pathname
54
+ * @returns {string[]}
55
+ */
56
+ parseCommand(pathname) {
57
+ if (typeof pathname !== "string" || pathname === "") return [];
58
+ if (!pathname.startsWith("/")) return [];
59
+
60
+ const parts = pathname.split("/").filter(Boolean);
61
+ if (parts.length === 0) return [];
62
+
63
+ const decoded = [];
64
+ for (const p of parts) {
65
+ try {
66
+ decoded.push(decodeURIComponent(p));
67
+ } catch {
68
+ return [];
69
+ }
131
70
  }
71
+ return decoded;
72
+ }
73
+
74
+ /**
75
+ * Validate + build exec + args using registry
76
+ * @param {string[]} parsed ["cmd", ...rawArgs]
77
+ * @returns {{execPath:string,args:string[]}|null}
78
+ */
79
+ resolveExecution(parsed) {
80
+ if (!Array.isArray(parsed) || parsed.length === 0) return null;
81
+
82
+ const [cmd, ...rawArgs] = parsed;
83
+
84
+ const entry = this.registry[cmd];
85
+ if (!entry) return null;
86
+
87
+ const buildArgs = entry.buildArgs ?? ((args) => args);
88
+ const args = buildArgs(rawArgs);
89
+ if (!Array.isArray(args)) return null;
90
+
91
+ return { execPath: entry.execPath, args };
92
+ }
93
+
94
+ spawnCommand(execPath, args) {
95
+ const proc = this.spawnImpl(execPath, args, {
96
+ shell: false,
97
+ windowsHide: true,
98
+ stdio: "inherit",
99
+ });
100
+
101
+ proc.on("error", (err) => {
102
+ console.error("[spawn error]", err);
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Start watching
108
+ */
109
+ run() {
110
+ this.tail.on("line", (line) => {
111
+ const pathname = this.extractPath(line);
112
+ const parsed = this.parseCommand(pathname);
113
+ const exec = this.resolveExecution(parsed);
114
+ if (!exec) return;
115
+
116
+ this.spawnCommand(exec.execPath, exec.args);
117
+ });
118
+
119
+ this.tail.on("close", () => {
120
+ console.log("watching stopped");
121
+ });
122
+
123
+ this.tail.watch();
124
+ }
125
+
126
+ stop() {
127
+ try {
128
+ this.tail.unwatch?.();
129
+ } catch {}
130
+ }
132
131
  }
package/dist/index.js CHANGED
@@ -1,10 +1,24 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from 'node:module';
2
3
  import { Command } from 'commander';
3
- import pkg from '../package.json' assert { type: 'json' };
4
4
  import AccessLogTailDispatcher from './altd.js';
5
5
 
6
+ const require = createRequire(import.meta.url);
7
+ const pkg = require('../package.json');
8
+
6
9
  const program = new Command();
7
10
 
11
+ const buildRegistry = (whitelist) => {
12
+ const registry = {};
13
+ for (const command of whitelist) {
14
+ registry[command] = {
15
+ execPath: command,
16
+ buildArgs: (rawArgs) => rawArgs,
17
+ };
18
+ }
19
+ return registry;
20
+ };
21
+
8
22
  program
9
23
  .name('altd')
10
24
  .version(pkg.version)
@@ -13,17 +27,19 @@ program
13
27
  .option(
14
28
  '-w, --whitelist <commands>',
15
29
  'Add commands to whitelist',
16
- (commands) => commands.split(',')
30
+ (commands) => commands.split(',').map((command) => command.trim()).filter(Boolean)
17
31
  )
18
32
  .parse(process.argv);
19
33
 
20
34
  const fileValue = program.args[0];
21
35
  const { whitelist } = program.opts();
22
36
 
23
- if (!fileValue || !whitelist) {
37
+ if (!fileValue || !whitelist || whitelist.length === 0) {
24
38
  console.log('altd <file> -w <commands...>');
25
39
  process.exit(1);
26
40
  }
27
41
 
28
- const altd = new AccessLogTailDispatcher(fileValue, whitelist);
42
+ const registry = buildRegistry(whitelist);
43
+
44
+ const altd = new AccessLogTailDispatcher(fileValue, registry);
29
45
  altd.run();
package/index.js CHANGED
@@ -1,10 +1,24 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from 'node:module';
2
3
  import { Command } from 'commander';
3
- import pkg from './package.json' assert { type: 'json' };
4
4
  import AccessLogTailDispatcher from './src/altd.js';
5
5
 
6
+ const require = createRequire(import.meta.url);
7
+ const pkg = require('./package.json');
8
+
6
9
  const program = new Command();
7
10
 
11
+ const buildRegistry = (whitelist) => {
12
+ const registry = {};
13
+ for (const command of whitelist) {
14
+ registry[command] = {
15
+ execPath: command,
16
+ buildArgs: (rawArgs) => rawArgs,
17
+ };
18
+ }
19
+ return registry;
20
+ };
21
+
8
22
  program
9
23
  .name('altd')
10
24
  .version(pkg.version)
@@ -13,17 +27,19 @@ program
13
27
  .option(
14
28
  '-w, --whitelist <commands>',
15
29
  'Add commands to whitelist',
16
- (commands) => commands.split(',')
30
+ (commands) => commands.split(',').map((command) => command.trim()).filter(Boolean)
17
31
  )
18
32
  .parse(process.argv);
19
33
 
20
34
  const fileValue = program.args[0];
21
35
  const { whitelist } = program.opts();
22
36
 
23
- if (!fileValue || !whitelist) {
37
+ if (!fileValue || !whitelist || whitelist.length === 0) {
24
38
  console.log('altd <file> -w <commands...>');
25
39
  process.exit(1);
26
40
  }
27
41
 
28
- const altd = new AccessLogTailDispatcher(fileValue, whitelist);
42
+ const registry = buildRegistry(whitelist);
43
+
44
+ const altd = new AccessLogTailDispatcher(fileValue, registry);
29
45
  altd.run();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "altd",
3
- "version": "0.0.4",
3
+ "version": "0.0.6",
4
4
  "description": "Access log tail dispatcher",
5
5
  "type": "module",
6
6
  "bin": {
package/src/altd.js CHANGED
@@ -1,132 +1,131 @@
1
- import {spawn} from 'child_process';
2
- import Tail from 'nodejs-tail';
3
- /**
4
- ** main class of AccessLogTailDispatcher
5
- */
1
+ import { spawn as nodeSpawn } from "node:child_process";
2
+ import Tail from "nodejs-tail";
3
+
6
4
  export default class AccessLogTailDispatcher {
7
- /**
8
- * @constructor
9
- * @param {string} file access_log
10
- * @param {Array} whitelist ['command1','command2'..]
11
- */
12
- constructor(file, whitelist) {
13
- this.file = file;
14
- this.whitelist = whitelist;
15
- this.spawn = undefined;
16
- this.tail = undefined;
17
- }
18
- /**
19
- * Get the path from 'GET /path/to/dir HTTP'
20
- * @param {string} line access_log
21
- * @return {string} /path/to/dir
22
- */
23
- path(line) {
24
- if (!(typeof line === 'string')) {
25
- return '';
26
- }
27
- let match = line.match(
28
- /GET\s((\/[a-z0-9-._~%!$&'()*+,;=:@?]+)+\/?)\sHTTP/i);
29
- if (null !== match && match.length > 2) {
30
- return match[1];
31
- }
32
- return '';
33
- }
5
+ /**
6
+ * @param {string} file access_log
7
+ * @param {object} commandRegistry { [commandName]: { execPath: string, buildArgs: (rawArgs:string[])=>string[] } }
8
+ * @param {object} [opts]
9
+ */
10
+ constructor(file, commandRegistry, opts = {}) {
11
+ this.file = file;
12
+ this.registry = commandRegistry;
34
13
 
35
- /**
36
- * Extract command and args
37
- * @param {string} path
38
- * @return {Array} [command,arg1,arg2...]
39
- */
40
- commandWithArgs(path) {
41
- if (!(typeof path === 'string')) {
42
- return [];
43
- }
44
- let commands = path.split(/\//).map(function(element, index, array) {
45
- let ret = "";
46
- try{
47
- ret = decodeURIComponent(element);
48
- }catch(e){
49
- console.error(e);
50
- }
51
- return ret;
52
- });
53
- commands.shift();
54
- return commands;
55
- }
14
+ this.spawnImpl = opts.spawnImpl ?? nodeSpawn;
15
+ this.tail = opts.tail
16
+ ?? new Tail(file, {
17
+ alwaysStat: true,
18
+ ignoreInitial: true,
19
+ persistent: true,
20
+ });
21
+ }
56
22
 
57
- /**
58
- * Filter by whitelist
59
- * @param {Array} commandWithArgs [command,arg1,arg2...]
60
- * @param {Array} whitelist ['command1','command2'...]
61
- * @return {Array} filtered commandWithArgs
62
- */
63
- filterByWhitelist(commandWithArgs, whitelist) {
64
- if (!this.isArray(commandWithArgs) ||
65
- !this.isArray(whitelist) ||
66
- commandWithArgs.length == 0 ||
67
- whitelist.indexOf(commandWithArgs[0]) == -1
68
- ) {
69
- return [];
70
- }
71
- return commandWithArgs;
72
- }
23
+ /**
24
+ * Extract request path from a typical access log line.
25
+ * More robust: parse "METHOD <url> HTTP/..."
26
+ * @param {string} line
27
+ * @returns {string} pathname like "/a/b"
28
+ */
29
+ extractPath(line) {
30
+ if (typeof line !== "string") return "";
73
31
 
74
- /**
75
- * Dispatch
76
- * @param {Array} commandWithArgs [command,arg1,arg2...]
77
- */
78
- dispatch(commandWithArgs) {
79
- if (commandWithArgs.length == 0) {
80
- return;
81
- }
82
- let command = commandWithArgs.shift();
83
- let proc = this.spawn(command, commandWithArgs);
84
- proc.on('error', (err) => {
85
- console.error(err);
86
- });
87
- proc.stdout.on('data', (data) => {
88
- process.stdout.write(data.toString());
89
- });
90
- }
32
+ // Find something like: GET /foo/bar HTTP/1.1
33
+ const m = line.match(
34
+ /\b(GET|POST|PUT|DELETE|HEAD|OPTIONS)\s+(\S+)\s+HTTP\/\d(?:\.\d)?\b/i,
35
+ );
36
+ if (!m) return "";
91
37
 
92
- /**
93
- * isArray
94
- * @param {object} obj [command,arg1,arg2...]
95
- * @return {boolean}
96
- */
97
- isArray(obj) {
98
- return Object.prototype.toString.call(obj) === '[object Array]';
38
+ const rawTarget = m[2];
39
+
40
+ if (rawTarget.startsWith("http://") || rawTarget.startsWith("https://")) {
41
+ try {
42
+ return new URL(rawTarget).pathname || "";
43
+ } catch {
44
+ return "";
45
+ }
99
46
  }
100
47
 
101
- /**
102
- * run
103
- * @param {string} file
104
- * @param {Array} whitelist ['command1','command2'...]
105
- */
106
- run(file, whitelist) {
107
- if ( typeof file !== 'undefined') {
108
- this.file = file;
109
- }
110
- if ( typeof whitelist !== 'undefined') {
111
- this.whitelist = whitelist;
112
- }
113
- if ( typeof this.spawn === 'undefined') {
114
- this.spawn = spawn;
115
- }
116
- if ( typeof this.tail === 'undefined') {
117
- this.tail = new Tail(this.file,
118
- {alwaysStat: true, ignoreInitial: true, persistent: true});
119
- }
120
- this.tail.on('line', (line) => {
121
- this.dispatch(
122
- this.filterByWhitelist(
123
- this.commandWithArgs(
124
- this.path(line)),
125
- this.whitelist));
126
- });
127
- this.tail.on('close', () => {
128
- console.log('watching stopped');
129
- });
130
- this.tail.watch();
48
+ return rawTarget;
49
+ }
50
+
51
+ /**
52
+ * "/cmd/a/b" -> ["cmd","a","b"] (safe decode, size limits)
53
+ * @param {string} pathname
54
+ * @returns {string[]}
55
+ */
56
+ parseCommand(pathname) {
57
+ if (typeof pathname !== "string" || pathname === "") return [];
58
+ if (!pathname.startsWith("/")) return [];
59
+
60
+ const parts = pathname.split("/").filter(Boolean);
61
+ if (parts.length === 0) return [];
62
+
63
+ const decoded = [];
64
+ for (const p of parts) {
65
+ try {
66
+ decoded.push(decodeURIComponent(p));
67
+ } catch {
68
+ return [];
69
+ }
131
70
  }
71
+ return decoded;
72
+ }
73
+
74
+ /**
75
+ * Validate + build exec + args using registry
76
+ * @param {string[]} parsed ["cmd", ...rawArgs]
77
+ * @returns {{execPath:string,args:string[]}|null}
78
+ */
79
+ resolveExecution(parsed) {
80
+ if (!Array.isArray(parsed) || parsed.length === 0) return null;
81
+
82
+ const [cmd, ...rawArgs] = parsed;
83
+
84
+ const entry = this.registry[cmd];
85
+ if (!entry) return null;
86
+
87
+ const buildArgs = entry.buildArgs ?? ((args) => args);
88
+ const args = buildArgs(rawArgs);
89
+ if (!Array.isArray(args)) return null;
90
+
91
+ return { execPath: entry.execPath, args };
92
+ }
93
+
94
+ spawnCommand(execPath, args) {
95
+ const proc = this.spawnImpl(execPath, args, {
96
+ shell: false,
97
+ windowsHide: true,
98
+ stdio: "inherit",
99
+ });
100
+
101
+ proc.on("error", (err) => {
102
+ console.error("[spawn error]", err);
103
+ });
104
+ }
105
+
106
+ /**
107
+ * Start watching
108
+ */
109
+ run() {
110
+ this.tail.on("line", (line) => {
111
+ const pathname = this.extractPath(line);
112
+ const parsed = this.parseCommand(pathname);
113
+ const exec = this.resolveExecution(parsed);
114
+ if (!exec) return;
115
+
116
+ this.spawnCommand(exec.execPath, exec.args);
117
+ });
118
+
119
+ this.tail.on("close", () => {
120
+ console.log("watching stopped");
121
+ });
122
+
123
+ this.tail.watch();
124
+ }
125
+
126
+ stop() {
127
+ try {
128
+ this.tail.unwatch?.();
129
+ } catch {}
130
+ }
132
131
  }