@faable/faable 2.0.0 → 2.2.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.
@@ -241,6 +241,22 @@ class FaableApi {
241
241
  headers: { 'x-faable-team': team }
242
242
  }));
243
243
  }
244
+ // ── per-app WAF ───────────────────────────────────────────────────────────
245
+ //
246
+ // No `x-faable-team` header on any of these: the routes are scoped by the
247
+ // app in the path (the server reads the team off the App row), and sending
248
+ // a team override would only narrow the lookup.
249
+ async getAppWaf(app_id) {
250
+ return data(this.client.get(`/app/${app_id}/waf`));
251
+ }
252
+ async addAppWafRule(app_id, params) {
253
+ return data(this.client.post(`/app/${app_id}/waf/rules`, params));
254
+ }
255
+ async removeAppWafRule(app_id, params) {
256
+ return data(this.client.delete(`/app/${app_id}/waf/rules`, {
257
+ data: params
258
+ }));
259
+ }
244
260
  }
245
261
 
246
262
  export { FaableApi };
@@ -2,6 +2,7 @@ import { withAuthHints, requireAuthAdmin } from '../../../api/auth_admin.js';
2
2
  import { log } from '../../../log.js';
3
3
  import { json_option, tenant_options } from '../options.js';
4
4
  import { print_json, yes_no } from '../render.js';
5
+ import { formatTriggers } from './triggers.js';
5
6
 
6
7
  const actions_get = {
7
8
  command: 'get <action_id>',
@@ -3,6 +3,7 @@ import { log } from '../../../log.js';
3
3
  import { json_option, list_options, tenant_options } from '../options.js';
4
4
  import { fetch_items } from '../paging.js';
5
5
  import { print_json, yes_no, when, table_lines } from '../render.js';
6
+ import { formatTriggers } from './triggers.js';
6
7
 
7
8
  const actions_list = {
8
9
  command: 'list',
@@ -19,6 +19,7 @@ import { deploy_remote } from './remote/index.js';
19
19
  import { resolve_app_id } from './resolve_app_id.js';
20
20
  import { secrets } from './secrets/index.js';
21
21
  import { is_superseded } from './superseded.js';
22
+ import { waf } from './waf/index.js';
22
23
 
23
24
  const deploy = {
24
25
  command: 'deploy [app_id]',
@@ -31,6 +32,7 @@ const deploy = {
31
32
  return yargs
32
33
  .command(secrets)
33
34
  .command(domains)
35
+ .command(waf)
34
36
  .command(logs)
35
37
  .command(status)
36
38
  .command(apps_list)
@@ -0,0 +1,83 @@
1
+ import { log } from '../../../log.js';
2
+
3
+ /** How a rule reads in a sentence, for the confirmation the user gets back. */
4
+ const describe_selector = (s) => {
5
+ if (s.pattern && s.user_agent) {
6
+ return `requests for ${s.pattern} from user-agents matching ${s.user_agent}`;
7
+ }
8
+ if (s.user_agent)
9
+ return `requests from user-agents matching ${s.user_agent}`;
10
+ return `requests for ${s.pattern}`;
11
+ };
12
+ /** Does this returned rule carry everything we asked for? */
13
+ const rule_matches_request = (rule, want, action) => rule.action === action &&
14
+ (rule.pattern ?? '') === (want.pattern ?? '') &&
15
+ (rule.user_agent ?? '') === (want.user_agent ?? '');
16
+ /**
17
+ * Shared handler for `block` and `sink` — the two differ only in the action
18
+ * they store and in what the edge answers, so the flow (resolve app, write,
19
+ * verify, explain, tell the user how to check) lives here once.
20
+ */
21
+ const add_rule = async (opts) => {
22
+ const { api, app_id, app_name, app_url, pattern, user_agent, action, description, force } = opts;
23
+ const want = { pattern, user_agent };
24
+ const waf = await api.addAppWafRule(app_id, {
25
+ pattern,
26
+ user_agent,
27
+ action,
28
+ description,
29
+ force
30
+ });
31
+ // Read the rule back before telling the user it worked.
32
+ //
33
+ // An older Faable server has no `user_agent` on this endpoint and drops it
34
+ // silently, storing the path half on its own — which turns "block /login for
35
+ // this bot" into "block /login for everyone". No server-side schema can
36
+ // prevent that (the old server is the old code), so the client checks that
37
+ // what came back is what it asked for, and undoes the write if it is not.
38
+ if (user_agent &&
39
+ !waf.rules.some(r => rule_matches_request(r, want, action))) {
40
+ await api
41
+ .removeAppWafRule(app_id, { pattern, user_agent, action })
42
+ // If the rollback itself is unsupported, fall back to the path-only
43
+ // shape the old server actually stored.
44
+ .catch(() => api.removeAppWafRule(app_id, { pattern, action }))
45
+ .catch(() => undefined);
46
+ throw new Error(`This Faable server does not support user-agent rules yet, so the rule was not created.\n` +
47
+ ` Update the CLI and try again, or ask support to upgrade the platform.`);
48
+ }
49
+ const answer = action === 'deny' ? '403' : '404';
50
+ log.info(`🛡️ ${describe_selector(want)} → ${answer} at the edge`);
51
+ log.info(` for ${app_name} (${app_id}).`);
52
+ log.info(``);
53
+ log.info(action === 'deny'
54
+ ? `Matching requests are blocked before they reach your app, so they no longer wake it.`
55
+ : `Faable answers matching requests with a ${answer} itself, so they no longer wake your app.`);
56
+ if (user_agent) {
57
+ log.info(``);
58
+ log.info(`⚠️ A user-agent rule has no path restriction: it applies to EVERY path` +
59
+ (pattern ? ` that also matches ${pattern}.` : ` on this app.`));
60
+ if (action === 'sink' && !pattern) {
61
+ // Worth saying out loud: the sink router sits above the sleep-through
62
+ // probe, so a monitor caught by this rule gets the 404 instead of the
63
+ // health answer it expects.
64
+ log.info(` If that user-agent is an uptime monitor, it will now see a 404 instead of a health check.`);
65
+ }
66
+ }
67
+ log.info(``);
68
+ log.info(`It takes about 20s to reach the edge. Then check it with:`);
69
+ log.info(user_agent
70
+ ? ` curl -s -o /dev/null -w '%{http_code}\\n' -A '${user_agent}' https://${app_url}/`
71
+ : ` curl -s -o /dev/null -w '%{http_code}\\n' https://${app_url}<path>`);
72
+ log.info(``);
73
+ log.info(`Undo: faable deploy waf rm ${undo_args(want)} -a ${app_id}`);
74
+ };
75
+ /** The exact arguments that reverse this rule. */
76
+ const undo_args = (s) => [
77
+ s.pattern ? `'${s.pattern}'` : '',
78
+ s.user_agent ? `--user-agent '${s.user_agent}'` : ''
79
+ ]
80
+ .filter(Boolean)
81
+ .join(' ');
82
+
83
+ export { add_rule, describe_selector, rule_matches_request, undo_args };
@@ -0,0 +1,63 @@
1
+ import { requireApi } from '../../../api/context.js';
2
+ import { resolve_app_id } from '../resolve_app_id.js';
3
+ import { add_rule } from './add_rule.js';
4
+
5
+ const waf_block = {
6
+ command: 'block [pattern]',
7
+ describe: 'Block requests at the edge with a 403 (they never reach your app)',
8
+ builder: yargs => yargs
9
+ .positional('pattern', {
10
+ type: 'string',
11
+ description: 'Anchored path regex, e.g. ^/\\.well-known/ (quote it in your shell)'
12
+ })
13
+ .option('user-agent', {
14
+ alias: 'u',
15
+ type: 'string',
16
+ description: 'RE2 fragment matched against User-Agent, e.g. YisouSpider. With a path, BOTH must match.'
17
+ })
18
+ .option('app', {
19
+ alias: 'a',
20
+ type: 'string',
21
+ description: 'App Identifier (defaults to the linked app)'
22
+ })
23
+ .option('description', {
24
+ alias: 'd',
25
+ type: 'string',
26
+ description: 'Why this rule exists (shown in `waf list`)'
27
+ })
28
+ .option('force', {
29
+ type: 'boolean',
30
+ default: false,
31
+ description: 'Accept a user-agent that also matches a known search/AI crawler or uptime monitor'
32
+ })
33
+ .check(({ pattern, userAgent }) => {
34
+ if (!pattern && !userAgent) {
35
+ throw new Error('Give a path pattern, --user-agent, or both.\n' +
36
+ " e.g. faable deploy waf block '^/wp-admin'\n" +
37
+ ' faable deploy waf block --user-agent YisouSpider');
38
+ }
39
+ return true;
40
+ })
41
+ .example("$0 deploy waf block '^/\\.well-known/'", 'Stop scanner probes under /.well-known from waking the app')
42
+ .example('$0 deploy waf block --user-agent YisouSpider', 'Refuse one crawler outright, on every path')
43
+ .example("$0 deploy waf block '^/api/' --user-agent curl", 'Refuse /api/ to that user-agent only, leaving browsers untouched')
44
+ .showHelpOnFail(false),
45
+ handler: async (args) => {
46
+ const ctx = await requireApi();
47
+ const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
48
+ const app = await ctx.api.getApp(app_id);
49
+ await add_rule({
50
+ api: ctx.api,
51
+ app_id,
52
+ app_name: app.name,
53
+ app_url: app.url,
54
+ pattern: args.pattern,
55
+ user_agent: args.userAgent,
56
+ action: 'deny',
57
+ description: args.description,
58
+ force: args.force
59
+ });
60
+ }
61
+ };
62
+
63
+ export { waf_block };
@@ -0,0 +1,64 @@
1
+ /** Human label for a rule action, so `list` explains itself without docs. */
2
+ const action_label = (action) => action === 'sink'
3
+ ? '404 (answered by Faable, app not woken)'
4
+ : action === 'deny'
5
+ ? '403 (blocked at the edge)'
6
+ : action === 'probe'
7
+ ? 'answered by Faable, app not woken (uptime monitors)'
8
+ : action;
9
+ /**
10
+ * Render the effective WAF of an app.
11
+ *
12
+ * Platform profiles come back as names + counts for a normal user and with
13
+ * their patterns for an admin, so this prints whatever the server chose to
14
+ * send rather than assuming either shape.
15
+ */
16
+ const format_waf = (waf) => {
17
+ const out = [];
18
+ if (!waf.enabled) {
19
+ out.push('⚠️ WAF disabled for this app — no rule below is enforced.');
20
+ out.push('');
21
+ }
22
+ out.push('Platform rules (managed by Faable):');
23
+ if (waf.platform_profiles.length === 0) {
24
+ out.push(' (none)');
25
+ }
26
+ for (const p of waf.platform_profiles) {
27
+ // Say what the ruleset matches ON, not just what it answers: without it a
28
+ // `probe` profile of user-agents reads as if it blocked paths.
29
+ const on = p.match === 'user_agent' ? ' by user-agent' : '';
30
+ out.push(` • ${p.name} — ${p.rule_count} rule(s)${on}, ${action_label(p.action)}`);
31
+ for (const r of p.rules ?? []) {
32
+ const what = p.match === 'user_agent' ? `UA ~ ${r.pattern}` : r.pattern;
33
+ out.push(` ${what}${r.description ? ` # ${r.description}` : ''}`);
34
+ }
35
+ }
36
+ out.push('');
37
+ out.push('Your rules:');
38
+ if (waf.rules.length === 0) {
39
+ out.push(' (none)');
40
+ }
41
+ for (const r of waf.rules) {
42
+ out.push(` • ${rule_subject(r)} → ${action_label(r.action)}`);
43
+ if (r.description)
44
+ out.push(` ${r.description}`);
45
+ }
46
+ return out;
47
+ };
48
+ /**
49
+ * What a rule selects, in one line.
50
+ *
51
+ * The three shapes have to be visibly different here: a user-agent rule has no
52
+ * path restriction (it applies to the whole app) and a combined one is an AND,
53
+ * so printing either as a bare pattern would misrepresent its reach.
54
+ */
55
+ const rule_subject = (r) => {
56
+ if (r.pattern && r.user_agent) {
57
+ return `${r.pattern} + UA ~ ${r.user_agent}`;
58
+ }
59
+ if (r.user_agent)
60
+ return `UA ~ ${r.user_agent} (any path)`;
61
+ return r.pattern ?? '(unknown)';
62
+ };
63
+
64
+ export { action_label, format_waf, rule_subject };
@@ -0,0 +1,21 @@
1
+ import { waf_block } from './block.js';
2
+ import { waf_list } from './list.js';
3
+ import { waf_rm } from './rm.js';
4
+ import { waf_sink } from './sink.js';
5
+
6
+ const waf = {
7
+ command: 'waf <command>',
8
+ describe: 'Block or silence request paths at the edge, before the app wakes',
9
+ builder: yargs => yargs
10
+ .command(waf_list)
11
+ .command(waf_block)
12
+ .command(waf_sink)
13
+ .command(waf_rm)
14
+ .demandCommand(1, 'Specify a waf command: list, block, sink or rm'),
15
+ handler: () => {
16
+ // Unreachable: demandCommand(1) either routes to a subcommand or fails
17
+ // through the global .fail() in src/index.ts.
18
+ }
19
+ };
20
+
21
+ export { waf };
@@ -0,0 +1,29 @@
1
+ import { requireApi } from '../../../api/context.js';
2
+ import { log } from '../../../log.js';
3
+ import { resolve_app_id } from '../resolve_app_id.js';
4
+ import { format_waf } from './format.js';
5
+
6
+ const waf_list = {
7
+ command: 'list',
8
+ describe: 'Show the WAF rules in effect for the app',
9
+ builder: yargs => yargs
10
+ .option('app', {
11
+ alias: 'a',
12
+ type: 'string',
13
+ description: 'App Identifier (defaults to the linked app)'
14
+ })
15
+ .example('$0 deploy waf list', 'Show the rules protecting the linked app')
16
+ .showHelpOnFail(false),
17
+ handler: async (args) => {
18
+ const ctx = await requireApi();
19
+ const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
20
+ const app = await ctx.api.getApp(app_id);
21
+ const waf = await ctx.api.getAppWaf(app_id);
22
+ log.info(`🛡️ WAF for ${app.name} (${app_id})`);
23
+ log.info(``);
24
+ for (const line of format_waf(waf))
25
+ log.info(line);
26
+ }
27
+ };
28
+
29
+ export { waf_list };
@@ -0,0 +1,58 @@
1
+ import { requireApi } from '../../../api/context.js';
2
+ import { log } from '../../../log.js';
3
+ import { resolve_app_id } from '../resolve_app_id.js';
4
+ import { describe_selector } from './add_rule.js';
5
+
6
+ const waf_rm = {
7
+ command: 'rm [pattern]',
8
+ describe: 'Remove one of your WAF rules',
9
+ builder: yargs => yargs
10
+ .positional('pattern', {
11
+ type: 'string',
12
+ description: 'The exact pattern to remove (see `faable deploy waf list`)'
13
+ })
14
+ // A rule is addressed by everything that defines it, not by its path
15
+ // alone: "/login" and "/login for YisouSpider" are two different rules
16
+ // that share a pattern, and removing one must not take out the other.
17
+ .option('user-agent', {
18
+ alias: 'u',
19
+ type: 'string',
20
+ description: 'The user-agent of the rule to remove'
21
+ })
22
+ .option('action', {
23
+ type: 'string',
24
+ choices: ['deny', 'sink'],
25
+ description: 'Only needed when the same rule exists as both'
26
+ })
27
+ .option('app', {
28
+ alias: 'a',
29
+ type: 'string',
30
+ description: 'App Identifier (defaults to the linked app)'
31
+ })
32
+ .check(({ pattern, userAgent }) => {
33
+ if (!pattern && !userAgent) {
34
+ throw new Error('Give the path pattern, --user-agent, or both.');
35
+ }
36
+ return true;
37
+ })
38
+ .example("$0 deploy waf rm '^/robots\\.txt$'", 'Stop handling /robots.txt at the edge')
39
+ .example('$0 deploy waf rm --user-agent YisouSpider', 'Stop blocking that crawler')
40
+ .showHelpOnFail(false),
41
+ handler: async (args) => {
42
+ const ctx = await requireApi();
43
+ const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
44
+ const app = await ctx.api.getApp(app_id);
45
+ await ctx.api.removeAppWafRule(app_id, {
46
+ pattern: args.pattern,
47
+ user_agent: args.userAgent,
48
+ action: args.action
49
+ });
50
+ log.info(`🗑️ Removed ${describe_selector({
51
+ pattern: args.pattern,
52
+ user_agent: args.userAgent
53
+ })} from ${app.name} (${app_id}).`);
54
+ log.info(`They reach your app again within ~20s, once the edge picks up the change.`);
55
+ }
56
+ };
57
+
58
+ export { waf_rm };
@@ -0,0 +1,65 @@
1
+ import { requireApi } from '../../../api/context.js';
2
+ import { resolve_app_id } from '../resolve_app_id.js';
3
+ import { add_rule } from './add_rule.js';
4
+
5
+ const waf_sink = {
6
+ command: 'sink [pattern]',
7
+ describe: 'Answer requests with a 404 from Faable, without waking your app',
8
+ builder: yargs => yargs
9
+ .positional('pattern', {
10
+ type: 'string',
11
+ description: 'Anchored path regex, e.g. ^/robots\\.txt$ (quote it in your shell)'
12
+ })
13
+ .option('user-agent', {
14
+ alias: 'u',
15
+ type: 'string',
16
+ description: 'RE2 fragment matched against User-Agent, e.g. YisouSpider. With a path, BOTH must match.'
17
+ })
18
+ .option('app', {
19
+ alias: 'a',
20
+ type: 'string',
21
+ description: 'App Identifier (defaults to the linked app)'
22
+ })
23
+ .option('description', {
24
+ alias: 'd',
25
+ type: 'string',
26
+ description: 'Why this rule exists (shown in `waf list`)'
27
+ })
28
+ .option('force', {
29
+ type: 'boolean',
30
+ default: false,
31
+ description: 'Accept a user-agent that also matches a known search/AI crawler or uptime monitor'
32
+ })
33
+ .check(({ pattern, userAgent }) => {
34
+ if (!pattern && !userAgent) {
35
+ throw new Error('Give a path pattern, --user-agent, or both.\n' +
36
+ " e.g. faable deploy waf sink '^/wp-admin'\n" +
37
+ ' faable deploy waf sink --user-agent YisouSpider');
38
+ }
39
+ return true;
40
+ })
41
+ .example("$0 deploy waf sink '^/robots\\.txt$'", 'Let Faable answer /robots.txt with a 404 instead of starting your app')
42
+ .example('$0 deploy waf sink --user-agent SemrushBot', 'Answer one crawler with a 404 instead of waking the app for it')
43
+ .epilogue('Use `sink` for requests your app would 404 anyway: Faable replies ' +
44
+ 'with the same 404, without the cold start. Use `block` instead ' +
45
+ 'when you want the request refused outright.')
46
+ .showHelpOnFail(false),
47
+ handler: async (args) => {
48
+ const ctx = await requireApi();
49
+ const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
50
+ const app = await ctx.api.getApp(app_id);
51
+ await add_rule({
52
+ api: ctx.api,
53
+ app_id,
54
+ app_name: app.name,
55
+ app_url: app.url,
56
+ pattern: args.pattern,
57
+ user_agent: args.userAgent,
58
+ action: 'sink',
59
+ description: args.description,
60
+ force: args.force
61
+ });
62
+ }
63
+ };
64
+
65
+ export { waf_sink };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@faable/faable",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "main": "dist/index.js",
5
5
  "license": "MIT",
6
6
  "author": "Marc Pomar <marc@faable.com>",