@faable/faable 2.0.0 → 2.1.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/dist/api/FaableApi.js +16 -0
- package/dist/commands/auth/actions/get.js +1 -0
- package/dist/commands/auth/actions/list.js +1 -0
- package/dist/commands/deploy/index.js +2 -0
- package/dist/commands/deploy/waf/add_rule.js +24 -0
- package/dist/commands/deploy/waf/block.js +42 -0
- package/dist/commands/deploy/waf/format.js +43 -0
- package/dist/commands/deploy/waf/index.js +21 -0
- package/dist/commands/deploy/waf/list.js +29 -0
- package/dist/commands/deploy/waf/rm.js +31 -0
- package/dist/commands/deploy/waf/sink.js +45 -0
- package/package.json +1 -1
package/dist/api/FaableApi.js
CHANGED
|
@@ -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, pattern) {
|
|
256
|
+
return data(this.client.delete(`/app/${app_id}/waf/rules`, {
|
|
257
|
+
data: { pattern }
|
|
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,24 @@
|
|
|
1
|
+
import { log } from '../../../log.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Shared handler for `block` and `sink` — the two differ only in the action
|
|
5
|
+
* they store and in what the edge answers, so the flow (resolve app, write,
|
|
6
|
+
* explain, tell the user how to check) lives here once.
|
|
7
|
+
*/
|
|
8
|
+
const add_rule = async (opts) => {
|
|
9
|
+
const { api, app_id, app_name, app_url, pattern, action, description } = opts;
|
|
10
|
+
await api.addAppWafRule(app_id, { pattern, action, description });
|
|
11
|
+
const answer = action === 'deny' ? '403' : '404';
|
|
12
|
+
log.info(`🛡️ ${pattern} → ${answer} at the edge for ${app_name} (${app_id}).`);
|
|
13
|
+
log.info(``);
|
|
14
|
+
log.info(action === 'deny'
|
|
15
|
+
? `Matching requests are blocked before they reach your app, so they no longer wake it.`
|
|
16
|
+
: `Faable answers matching requests with a ${answer} itself, so they no longer wake your app.`);
|
|
17
|
+
log.info(``);
|
|
18
|
+
log.info(`It takes about 20s to reach the edge. Then check it with:`);
|
|
19
|
+
log.info(` curl -s -o /dev/null -w '%{http_code}\\n' https://${app_url}<path>`);
|
|
20
|
+
log.info(``);
|
|
21
|
+
log.info(`Undo: faable deploy waf rm '${pattern}' -a ${app_id}`);
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export { add_rule };
|
|
@@ -0,0 +1,42 @@
|
|
|
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 a path at the edge with a 403 (never reaches your app)',
|
|
8
|
+
builder: yargs => yargs
|
|
9
|
+
.positional('pattern', {
|
|
10
|
+
type: 'string',
|
|
11
|
+
demandOption: true,
|
|
12
|
+
description: 'Anchored path regex, e.g. ^/\\.well-known/ (quote it in your shell)'
|
|
13
|
+
})
|
|
14
|
+
.option('app', {
|
|
15
|
+
alias: 'a',
|
|
16
|
+
type: 'string',
|
|
17
|
+
description: 'App Identifier (defaults to the linked app)'
|
|
18
|
+
})
|
|
19
|
+
.option('description', {
|
|
20
|
+
alias: 'd',
|
|
21
|
+
type: 'string',
|
|
22
|
+
description: 'Why this rule exists (shown in `waf list`)'
|
|
23
|
+
})
|
|
24
|
+
.example("$0 deploy waf block '^/\\.well-known/'", 'Stop scanner probes under /.well-known from waking the app')
|
|
25
|
+
.showHelpOnFail(false),
|
|
26
|
+
handler: async (args) => {
|
|
27
|
+
const ctx = await requireApi();
|
|
28
|
+
const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
|
|
29
|
+
const app = await ctx.api.getApp(app_id);
|
|
30
|
+
await add_rule({
|
|
31
|
+
api: ctx.api,
|
|
32
|
+
app_id,
|
|
33
|
+
app_name: app.name,
|
|
34
|
+
app_url: app.url,
|
|
35
|
+
pattern: args.pattern,
|
|
36
|
+
action: 'deny',
|
|
37
|
+
description: args.description
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
export { waf_block };
|
|
@@ -0,0 +1,43 @@
|
|
|
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;
|
|
7
|
+
/**
|
|
8
|
+
* Render the effective WAF of an app.
|
|
9
|
+
*
|
|
10
|
+
* Platform profiles come back as names + counts for a normal user and with
|
|
11
|
+
* their patterns for an admin, so this prints whatever the server chose to
|
|
12
|
+
* send rather than assuming either shape.
|
|
13
|
+
*/
|
|
14
|
+
const format_waf = (waf) => {
|
|
15
|
+
const out = [];
|
|
16
|
+
if (!waf.enabled) {
|
|
17
|
+
out.push('⚠️ WAF disabled for this app — no rule below is enforced.');
|
|
18
|
+
out.push('');
|
|
19
|
+
}
|
|
20
|
+
out.push('Platform rules (managed by Faable):');
|
|
21
|
+
if (waf.platform_profiles.length === 0) {
|
|
22
|
+
out.push(' (none)');
|
|
23
|
+
}
|
|
24
|
+
for (const p of waf.platform_profiles) {
|
|
25
|
+
out.push(` • ${p.name} — ${p.rule_count} rule(s), ${action_label(p.action)}`);
|
|
26
|
+
for (const r of p.rules ?? []) {
|
|
27
|
+
out.push(` ${r.pattern}${r.description ? ` # ${r.description}` : ''}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
out.push('');
|
|
31
|
+
out.push('Your rules:');
|
|
32
|
+
if (waf.rules.length === 0) {
|
|
33
|
+
out.push(' (none)');
|
|
34
|
+
}
|
|
35
|
+
for (const r of waf.rules) {
|
|
36
|
+
out.push(` • ${r.pattern} → ${action_label(r.action)}`);
|
|
37
|
+
if (r.description)
|
|
38
|
+
out.push(` ${r.description}`);
|
|
39
|
+
}
|
|
40
|
+
return out;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export { action_label, format_waf };
|
|
@@ -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,31 @@
|
|
|
1
|
+
import { requireApi } from '../../../api/context.js';
|
|
2
|
+
import { log } from '../../../log.js';
|
|
3
|
+
import { resolve_app_id } from '../resolve_app_id.js';
|
|
4
|
+
|
|
5
|
+
const waf_rm = {
|
|
6
|
+
command: 'rm <pattern>',
|
|
7
|
+
describe: 'Remove one of your WAF rules',
|
|
8
|
+
builder: yargs => yargs
|
|
9
|
+
.positional('pattern', {
|
|
10
|
+
type: 'string',
|
|
11
|
+
demandOption: true,
|
|
12
|
+
description: 'The exact pattern to remove (see `faable deploy waf list`)'
|
|
13
|
+
})
|
|
14
|
+
.option('app', {
|
|
15
|
+
alias: 'a',
|
|
16
|
+
type: 'string',
|
|
17
|
+
description: 'App Identifier (defaults to the linked app)'
|
|
18
|
+
})
|
|
19
|
+
.example("$0 deploy waf rm '^/robots\\.txt$'", 'Stop handling /robots.txt at the edge')
|
|
20
|
+
.showHelpOnFail(false),
|
|
21
|
+
handler: async (args) => {
|
|
22
|
+
const ctx = await requireApi();
|
|
23
|
+
const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
|
|
24
|
+
const app = await ctx.api.getApp(app_id);
|
|
25
|
+
await ctx.api.removeAppWafRule(app_id, args.pattern);
|
|
26
|
+
log.info(`🗑️ Removed ${args.pattern} from ${app.name} (${app_id}).`);
|
|
27
|
+
log.info(`Requests for it reach your app again within ~20s, once the edge picks up the change.`);
|
|
28
|
+
}
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export { waf_rm };
|
|
@@ -0,0 +1,45 @@
|
|
|
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 a path with a 404 from Faable, without waking your app',
|
|
8
|
+
builder: yargs => yargs
|
|
9
|
+
.positional('pattern', {
|
|
10
|
+
type: 'string',
|
|
11
|
+
demandOption: true,
|
|
12
|
+
description: 'Anchored path regex, e.g. ^/robots\\.txt$ (quote it in your shell)'
|
|
13
|
+
})
|
|
14
|
+
.option('app', {
|
|
15
|
+
alias: 'a',
|
|
16
|
+
type: 'string',
|
|
17
|
+
description: 'App Identifier (defaults to the linked app)'
|
|
18
|
+
})
|
|
19
|
+
.option('description', {
|
|
20
|
+
alias: 'd',
|
|
21
|
+
type: 'string',
|
|
22
|
+
description: 'Why this rule exists (shown in `waf list`)'
|
|
23
|
+
})
|
|
24
|
+
.example("$0 deploy waf sink '^/robots\\.txt$'", 'Let Faable answer /robots.txt with a 404 instead of starting your app')
|
|
25
|
+
.epilogue('Use `sink` for paths your app does not serve anyway: Faable replies ' +
|
|
26
|
+
'with the same 404 your app would have, without the cold start. ' +
|
|
27
|
+
'Use `block` instead when you want the request refused outright.')
|
|
28
|
+
.showHelpOnFail(false),
|
|
29
|
+
handler: async (args) => {
|
|
30
|
+
const ctx = await requireApi();
|
|
31
|
+
const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
|
|
32
|
+
const app = await ctx.api.getApp(app_id);
|
|
33
|
+
await add_rule({
|
|
34
|
+
api: ctx.api,
|
|
35
|
+
app_id,
|
|
36
|
+
app_name: app.name,
|
|
37
|
+
app_url: app.url,
|
|
38
|
+
pattern: args.pattern,
|
|
39
|
+
action: 'sink',
|
|
40
|
+
description: args.description
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
export { waf_sink };
|