@faable/faable 2.1.0 → 3.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 +78 -41
- package/dist/api/FaableApi.js +2 -2
- package/dist/api/context.js +1 -1
- package/dist/api/strategies/oidc.strategy.js +1 -1
- package/dist/commands/deploy/index.js +17 -12
- package/dist/commands/deploy/resolve_app_id.js +3 -3
- package/dist/commands/deploy/waf/add_rule.js +66 -7
- package/dist/commands/deploy/waf/block.js +25 -4
- package/dist/commands/deploy/waf/format.js +26 -5
- package/dist/commands/deploy/waf/rm.js +32 -5
- package/dist/commands/deploy/waf/sink.js +27 -7
- package/dist/index.js +8 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -9,67 +9,104 @@
|
|
|
9
9
|
Your React, Node.js or Python apps, up to the cloud in seconds.
|
|
10
10
|
</p>
|
|
11
11
|
|
|
12
|
-
## Faable
|
|
12
|
+
## Faable CLI
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
`@faable/faable` is the command-line interface for the Faable platform. It covers
|
|
15
|
+
**Faable Deploy** (ship and operate apps) and **Faable Auth** (manage an identity
|
|
16
|
+
tenant: users, login-flow actions, OAuth clients and the audit log) from one binary.
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
Full reference: **[faable.com/docs/cli](https://faable.com/docs/cli)**.
|
|
19
|
+
|
|
20
|
+
To install the latest version:
|
|
19
21
|
|
|
20
22
|
```bash
|
|
21
23
|
npm i -g @faable/faable
|
|
22
24
|
```
|
|
23
25
|
|
|
24
|
-
##
|
|
26
|
+
## Commands
|
|
25
27
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
+
| Group | What it does | Docs |
|
|
29
|
+
| --- | --- | --- |
|
|
30
|
+
| `faable login` / `whoami` / `logout` | Session management (device flow; CI uses OIDC, no login needed) | [Authentication](https://faable.com/docs/cli#authentication) |
|
|
31
|
+
| `faable deploy` | Deploy the current directory, then `status`, `logs`, `deployments`, `inspect`, `trigger`, `redeploy`, `cancel`, `open`, `list` | [Deployment](https://faable.com/docs/cli#deployment) |
|
|
32
|
+
| `faable deploy secrets` | Environment variables: `list`, `set` (`KEY=VALUE` or `--env-file`), `rm` | [Secrets](https://faable.com/docs/cli#secrets) |
|
|
33
|
+
| `faable deploy domains` | Custom domains: `add` (prints the CNAME), `list`, `check`, `rm` | [Domains](https://faable.com/docs/cli#domains) |
|
|
34
|
+
| `faable deploy waf` | Edge rules: `block` (403), `sink` (404 without waking the app), `list`, `rm` | [Edge rules](https://faable.com/docs/cli#edge-rules-waf) |
|
|
35
|
+
| `faable auth users` | `list` (FaableQL filters), `get` (federated identities included), `suspend`, `reinstate` | [Users](https://faable.com/docs/cli#users) |
|
|
36
|
+
| `faable auth actions` | Login-flow hooks: `list`, `get --code`, `create`, `update`, `rm` | [Actions](https://faable.com/docs/cli#actions) |
|
|
37
|
+
| `faable auth clients` | OAuth clients: `list`, `get --secret`, `create`, `rm` | [OAuth clients](https://faable.com/docs/cli#oauth-clients) |
|
|
38
|
+
| `faable auth logs` | Audit log (read-only): `list` with filters, `get` | [Audit logs](https://faable.com/docs/cli#audit-logs) |
|
|
39
|
+
|
|
40
|
+
Every command has `--help`; read commands accept `--json` for output you can pipe
|
|
41
|
+
to `jq`. The complete table is in the
|
|
42
|
+
[command reference](https://faable.com/docs/cli#command-reference).
|
|
43
|
+
|
|
44
|
+
### Deploy
|
|
28
45
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
46
|
+
```bash
|
|
47
|
+
faable login
|
|
48
|
+
faable deploy # deploy the current directory
|
|
49
|
+
faable deploy status # phase, URL and detected stack
|
|
50
|
+
faable deploy logs --build # build output (--follow to tail)
|
|
51
|
+
faable deploy secrets set KEY=value
|
|
52
|
+
faable deploy domains add app.example.com
|
|
53
|
+
```
|
|
34
54
|
|
|
35
|
-
|
|
36
|
-
> backend that also has a `package.json`, ship a `Dockerfile` instead.
|
|
55
|
+
### Faable Auth
|
|
37
56
|
|
|
38
|
-
|
|
57
|
+
`faable auth` manages a [Faable Auth](https://faable.com/docs/auth) tenant and
|
|
58
|
+
reuses your `faable login` session. Point it at your tenant with
|
|
59
|
+
`--auth-url https://<account>.auth.faable.link` (or `FAABLE_AUTH_URL`).
|
|
39
60
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
61
|
+
```bash
|
|
62
|
+
faable auth users list --query email_verified:false --limit 50
|
|
63
|
+
faable auth users get user_abc123 # includes GitHub/… identities
|
|
64
|
+
faable auth users suspend user_abc123 -r "abuse: crypto miner"
|
|
44
65
|
|
|
45
|
-
|
|
66
|
+
# Bulk: pipe ids from a filtered listing
|
|
67
|
+
faable auth users list --suspended --json | jq -r '.[].id' | faable auth users reinstate -y
|
|
46
68
|
|
|
47
|
-
|
|
69
|
+
faable auth actions create -n add-claims -t post-login -f ./claims.js
|
|
70
|
+
faable auth clients create -n my-app --callback https://app.example.com/callback
|
|
71
|
+
faable auth logs list --origin oauth --status failed --since 2026-08-01
|
|
72
|
+
```
|
|
48
73
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
74
|
+
See [`faable auth` in the docs](https://faable.com/docs/cli#faable-auth) for
|
|
75
|
+
every flag. The concepts behind each group are documented in Faable Auth:
|
|
76
|
+
[users & suspension](https://faable.com/docs/auth/suspend-users),
|
|
77
|
+
[actions](https://faable.com/docs/auth/extensibility/actions),
|
|
78
|
+
[login flows](https://faable.com/docs/auth/login-flows),
|
|
79
|
+
[OAuth clients](https://faable.com/docs/auth/clients) and
|
|
80
|
+
[audit logs](https://faable.com/docs/auth/logs).
|
|
54
81
|
|
|
55
|
-
|
|
56
|
-
`pyproject.toml` or `Pipfile`. `gunicorn`/`uvicorn` are installed automatically
|
|
57
|
-
if missing.
|
|
82
|
+
## Documentation
|
|
58
83
|
|
|
59
|
-
|
|
60
|
-
|
|
84
|
+
The CLI is a thin client: what it deploys and what it manages is documented in
|
|
85
|
+
the public docs, not here.
|
|
61
86
|
|
|
62
|
-
###
|
|
87
|
+
### CLI
|
|
63
88
|
|
|
64
|
-
|
|
89
|
+
- [Command reference](https://faable.com/docs/cli#command-reference) — every command and flag
|
|
90
|
+
- [Authentication](https://faable.com/docs/cli#authentication) — `login`, `whoami`, `logout`
|
|
91
|
+
- [Deployment](https://faable.com/docs/cli#deployment) · [Inspecting](https://faable.com/docs/cli#inspecting) · [Secrets](https://faable.com/docs/cli#secrets) · [Domains](https://faable.com/docs/cli#domains) · [Edge rules](https://faable.com/docs/cli#edge-rules-waf) · [Faable Auth](https://faable.com/docs/cli#faable-auth)
|
|
65
92
|
|
|
66
|
-
|
|
67
|
-
- `Procfile` — a `web:` line, e.g. `web: gunicorn app:app --bind 0.0.0.0:$PORT`
|
|
93
|
+
### Faable Deploy
|
|
68
94
|
|
|
69
|
-
|
|
70
|
-
|
|
95
|
+
- [Get started](https://faable.com/docs/deploy/get-started) — first deploy in minutes
|
|
96
|
+
- [How deployment works](https://faable.com/docs/deploy/how-it-works) — push-to-deploy, builds and releases
|
|
97
|
+
- [Runtime](https://faable.com/docs/deploy/runtime) — supported languages, framework detection, `faable.json` / `Procfile` overrides
|
|
98
|
+
- [What the builder expects](https://faable.com/docs/deploy/build-requirements) — build and start commands, `$PORT`, monorepos
|
|
99
|
+
- [Environment & releases](https://faable.com/docs/deploy/environment) — secrets, env vars and release versions
|
|
100
|
+
- [Custom domains](https://faable.com/docs/deploy/domains/custom-domain) and [SSL certificates](https://faable.com/docs/deploy/domains/ssl-certificates)
|
|
101
|
+
- [Web Application Firewall](https://faable.com/docs/deploy/security-waf) — the edge rules behind `faable deploy waf`
|
|
102
|
+
- [Deploy from your own CI](https://faable.com/docs/deploy/github-actions) — OIDC, no login needed
|
|
103
|
+
- Framework guides: [Next.js](https://faable.com/docs/deploy/guides/guide-next), [Vite](https://faable.com/docs/deploy/guides/guide-vite), [Astro](https://faable.com/docs/deploy/guides/guide-astro), [Express](https://faable.com/docs/deploy/guides/guide-express), [Django](https://faable.com/docs/deploy/guides/guide-django), [FastAPI](https://faable.com/docs/deploy/guides/guide-fastapi), [Flask](https://faable.com/docs/deploy/guides/guide-flask), [PHP](https://faable.com/docs/deploy/guides/guide-php)
|
|
71
104
|
|
|
72
|
-
|
|
105
|
+
### Faable Auth
|
|
73
106
|
|
|
74
|
-
|
|
75
|
-
[
|
|
107
|
+
- [Get started](https://faable.com/docs/auth/get-started) — create a tenant
|
|
108
|
+
- [Clients](https://faable.com/docs/auth/clients) · [Connections](https://faable.com/docs/auth/connections) · [Social login](https://faable.com/docs/auth/social) · [Passwordless](https://faable.com/docs/auth/passwordless)
|
|
109
|
+
- [Suspend users](https://faable.com/docs/auth/suspend-users) — what `users suspend` / `reinstate` do to sessions and tokens
|
|
110
|
+
- [Login flows](https://faable.com/docs/auth/login-flows) and [actions](https://faable.com/docs/auth/extensibility/actions) — the hooks managed by `auth actions`
|
|
111
|
+
- [Logs](https://faable.com/docs/auth/logs) — the audit trail read by `auth logs`
|
|
112
|
+
- [OAuth 2.0 flows](https://faable.com/docs/auth/oauth-flows) · [OpenID Connect](https://faable.com/docs/auth/oidc) · [Validate access tokens](https://faable.com/docs/auth/validate-access-tokens)
|
package/dist/api/FaableApi.js
CHANGED
|
@@ -252,9 +252,9 @@ class FaableApi {
|
|
|
252
252
|
async addAppWafRule(app_id, params) {
|
|
253
253
|
return data(this.client.post(`/app/${app_id}/waf/rules`, params));
|
|
254
254
|
}
|
|
255
|
-
async removeAppWafRule(app_id,
|
|
255
|
+
async removeAppWafRule(app_id, params) {
|
|
256
256
|
return data(this.client.delete(`/app/${app_id}/waf/rules`, {
|
|
257
|
-
data:
|
|
257
|
+
data: params
|
|
258
258
|
}));
|
|
259
259
|
}
|
|
260
260
|
}
|
package/dist/api/context.js
CHANGED
|
@@ -20,7 +20,7 @@ const context = async (targetAppId) => {
|
|
|
20
20
|
try {
|
|
21
21
|
const idToken = process.env.FAABLE_ID_TOKEN || (await getIDToken("https://faable.com"));
|
|
22
22
|
// targetAppId disambiguates a monorepo (several apps, one repo) in the
|
|
23
|
-
// OIDC exchange — from `faable deploy <app_id>`.
|
|
23
|
+
// OIDC exchange — from `faable deploy --app <app_id>`.
|
|
24
24
|
api = FaableApi.create({
|
|
25
25
|
authStrategy: oidc_strategy,
|
|
26
26
|
auth: { idToken, appId: targetAppId },
|
|
@@ -27,7 +27,7 @@ const exchangeGithubOidcToken = async (gh_token, target_app_id) => {
|
|
|
27
27
|
// Monorepo: several apps are linked to the same repository.
|
|
28
28
|
if (status === 400) {
|
|
29
29
|
throw new Error(serverMessage ||
|
|
30
|
-
"This repository has multiple linked apps. Specify which one with `faable deploy <app_id>`.", { cause: err });
|
|
30
|
+
"This repository has multiple linked apps. Specify which one with `faable deploy --app <app_id>`.", { cause: err });
|
|
31
31
|
}
|
|
32
32
|
throw new Error(`Faable OIDC token exchange failed (${status ?? "network error"})${serverMessage ? `: ${serverMessage}` : ""}`, { cause: err });
|
|
33
33
|
}
|
|
@@ -22,13 +22,18 @@ import { is_superseded } from './superseded.js';
|
|
|
22
22
|
import { waf } from './waf/index.js';
|
|
23
23
|
|
|
24
24
|
const deploy = {
|
|
25
|
-
|
|
25
|
+
// No positional: `deploy` takes ONLY subcommands. An app_id typed where a
|
|
26
|
+
// subcommand belongs (`faable deploy app_xxx secrets list`) used to be
|
|
27
|
+
// swallowed by an `[app_id]` positional and silently DEPLOYED instead of
|
|
28
|
+
// running the subcommand; with no positional, .strictCommands() in
|
|
29
|
+
// src/index.ts rejects it as an unknown command. Target another app with
|
|
30
|
+
// --app, exactly like every subcommand does.
|
|
31
|
+
command: 'deploy',
|
|
26
32
|
// Name the subcommand groups so `faable --help` makes them discoverable
|
|
27
33
|
// without digging into `faable deploy --help`.
|
|
28
34
|
describe: 'Deploy a faable app and manage it (secrets, domains, logs, deployments…)',
|
|
29
35
|
builder: yargs => {
|
|
30
|
-
// Product subcommands live under `deploy
|
|
31
|
-
// app_id positional, so `faable deploy <app_id>` keeps working).
|
|
36
|
+
// Product subcommands live under `deploy`.
|
|
32
37
|
return yargs
|
|
33
38
|
.command(secrets)
|
|
34
39
|
.command(domains)
|
|
@@ -43,9 +48,10 @@ const deploy = {
|
|
|
43
48
|
.command(redeploy)
|
|
44
49
|
.command(cancel)
|
|
45
50
|
.command(link)
|
|
46
|
-
.
|
|
51
|
+
.option('app', {
|
|
52
|
+
alias: 'a',
|
|
47
53
|
type: 'string',
|
|
48
|
-
description: 'App Identifier'
|
|
54
|
+
description: 'App Identifier (defaults to the app linked to this repository)'
|
|
49
55
|
})
|
|
50
56
|
.option('workdir', {
|
|
51
57
|
alias: 'w',
|
|
@@ -62,22 +68,21 @@ const deploy = {
|
|
|
62
68
|
default: false,
|
|
63
69
|
description: 'Skip the confirmation prompt (only asked in interactive terminals — CI is unaffected)'
|
|
64
70
|
})
|
|
71
|
+
.example('$0 deploy', 'Deploy the app linked to the current directory')
|
|
72
|
+
.example('$0 deploy --app app_a1b2c3', 'Deploy another app (monorepo: several apps, one repo)')
|
|
65
73
|
.showHelpOnFail(false);
|
|
66
74
|
},
|
|
67
75
|
handler: async (args) => {
|
|
68
76
|
const workdir = args.workdir || process.cwd();
|
|
69
77
|
// Pass the explicit app target to the OIDC exchange so a monorepo (several
|
|
70
78
|
// apps, one repo) can be disambiguated in CI.
|
|
71
|
-
const ctx = await requireApi(args.
|
|
79
|
+
const ctx = await requireApi(args.app);
|
|
72
80
|
const { api } = ctx;
|
|
73
81
|
const config = Configuration.instance().deployConfig();
|
|
74
|
-
const app_id = await resolve_app_id(args.
|
|
82
|
+
const app_id = await resolve_app_id(args.app, ctx.appId, api, workdir);
|
|
75
83
|
const app = await api.getApp(app_id);
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
// silently falls through to THIS handler and deploys `workdir` instead.
|
|
79
|
-
// Only prompts in a real terminal — CI (non-TTY) keeps deploying
|
|
80
|
-
// unattended exactly as before, so existing pipelines need no changes.
|
|
84
|
+
// Confirm which app/dir is about to go out. Only in a real terminal — CI
|
|
85
|
+
// (non-TTY) deploys unattended, so pipelines need no changes.
|
|
81
86
|
if (!args.yes && process.stdout.isTTY) {
|
|
82
87
|
const { confirm } = await prompts({
|
|
83
88
|
type: 'toggle',
|
|
@@ -3,7 +3,7 @@ import { getGitRemoteUrl } from '../../lib/git_remote.js';
|
|
|
3
3
|
import { log } from '../../log.js';
|
|
4
4
|
|
|
5
5
|
// app_id resolution (the user never has to look one up):
|
|
6
|
-
// 1. explicit (
|
|
6
|
+
// 1. explicit (--app, on `deploy` and on every subcommand)
|
|
7
7
|
// 2. OIDC in CI — the backend resolves the app from the linked repository
|
|
8
8
|
// 3. locally — a legacy app_id in faable.json (older CLIs wrote it on
|
|
9
9
|
// `faable deploy link`; the current link only persists in the API)
|
|
@@ -25,10 +25,10 @@ const resolve_app_id = async (explicit, ctxAppId, api, workdir = process.cwd())
|
|
|
25
25
|
}
|
|
26
26
|
if (matches.length > 1) {
|
|
27
27
|
const ids = matches.map(app => `${app.name} (${app.id})`).join(', ');
|
|
28
|
-
throw new Error(`Repository ${repository} is linked to several apps: ${ids}. Pass
|
|
28
|
+
throw new Error(`Repository ${repository} is linked to several apps: ${ids}. Pass one with --app <app_id>.`);
|
|
29
29
|
}
|
|
30
30
|
}
|
|
31
|
-
throw new Error('No app linked to this repository. Link it from the dashboard (or run "faable deploy link"), or pass
|
|
31
|
+
throw new Error('No app linked to this repository. Link it from the dashboard (or run "faable deploy link"), or pass one with --app <app_id>.');
|
|
32
32
|
};
|
|
33
33
|
|
|
34
34
|
export { resolve_app_id };
|
|
@@ -1,24 +1,83 @@
|
|
|
1
1
|
import { log } from '../../../log.js';
|
|
2
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 ?? '');
|
|
3
16
|
/**
|
|
4
17
|
* Shared handler for `block` and `sink` — the two differ only in the action
|
|
5
18
|
* 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.
|
|
19
|
+
* verify, explain, tell the user how to check) lives here once.
|
|
7
20
|
*/
|
|
8
21
|
const add_rule = async (opts) => {
|
|
9
|
-
const { api, app_id, app_name, app_url, pattern, action, description } = opts;
|
|
10
|
-
|
|
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
|
+
}
|
|
11
49
|
const answer = action === 'deny' ? '403' : '404';
|
|
12
|
-
log.info(`🛡️ ${
|
|
50
|
+
log.info(`🛡️ ${describe_selector(want)} → ${answer} at the edge`);
|
|
51
|
+
log.info(` for ${app_name} (${app_id}).`);
|
|
13
52
|
log.info(``);
|
|
14
53
|
log.info(action === 'deny'
|
|
15
54
|
? `Matching requests are blocked before they reach your app, so they no longer wake it.`
|
|
16
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
|
+
}
|
|
17
67
|
log.info(``);
|
|
18
68
|
log.info(`It takes about 20s to reach the edge. Then check it with:`);
|
|
19
|
-
log.info(
|
|
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>`);
|
|
20
72
|
log.info(``);
|
|
21
|
-
log.info(`Undo: faable deploy waf rm
|
|
73
|
+
log.info(`Undo: faable deploy waf rm ${undo_args(want)} -a ${app_id}`);
|
|
22
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(' ');
|
|
23
82
|
|
|
24
|
-
export { add_rule };
|
|
83
|
+
export { add_rule, describe_selector, rule_matches_request, undo_args };
|
|
@@ -3,13 +3,17 @@ import { resolve_app_id } from '../resolve_app_id.js';
|
|
|
3
3
|
import { add_rule } from './add_rule.js';
|
|
4
4
|
|
|
5
5
|
const waf_block = {
|
|
6
|
-
command: 'block
|
|
7
|
-
describe: 'Block
|
|
6
|
+
command: 'block [pattern]',
|
|
7
|
+
describe: 'Block requests at the edge with a 403 (they never reach your app)',
|
|
8
8
|
builder: yargs => yargs
|
|
9
9
|
.positional('pattern', {
|
|
10
10
|
type: 'string',
|
|
11
|
-
demandOption: true,
|
|
12
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.'
|
|
13
17
|
})
|
|
14
18
|
.option('app', {
|
|
15
19
|
alias: 'a',
|
|
@@ -20,8 +24,23 @@ const waf_block = {
|
|
|
20
24
|
alias: 'd',
|
|
21
25
|
type: 'string',
|
|
22
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;
|
|
23
40
|
})
|
|
24
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')
|
|
25
44
|
.showHelpOnFail(false),
|
|
26
45
|
handler: async (args) => {
|
|
27
46
|
const ctx = await requireApi();
|
|
@@ -33,8 +52,10 @@ const waf_block = {
|
|
|
33
52
|
app_name: app.name,
|
|
34
53
|
app_url: app.url,
|
|
35
54
|
pattern: args.pattern,
|
|
55
|
+
user_agent: args.userAgent,
|
|
36
56
|
action: 'deny',
|
|
37
|
-
description: args.description
|
|
57
|
+
description: args.description,
|
|
58
|
+
force: args.force
|
|
38
59
|
});
|
|
39
60
|
}
|
|
40
61
|
};
|
|
@@ -3,7 +3,9 @@ const action_label = (action) => action === 'sink'
|
|
|
3
3
|
? '404 (answered by Faable, app not woken)'
|
|
4
4
|
: action === 'deny'
|
|
5
5
|
? '403 (blocked at the edge)'
|
|
6
|
-
: action
|
|
6
|
+
: action === 'probe'
|
|
7
|
+
? 'answered by Faable, app not woken (uptime monitors)'
|
|
8
|
+
: action;
|
|
7
9
|
/**
|
|
8
10
|
* Render the effective WAF of an app.
|
|
9
11
|
*
|
|
@@ -22,9 +24,13 @@ const format_waf = (waf) => {
|
|
|
22
24
|
out.push(' (none)');
|
|
23
25
|
}
|
|
24
26
|
for (const p of waf.platform_profiles) {
|
|
25
|
-
|
|
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)}`);
|
|
26
31
|
for (const r of p.rules ?? []) {
|
|
27
|
-
|
|
32
|
+
const what = p.match === 'user_agent' ? `UA ~ ${r.pattern}` : r.pattern;
|
|
33
|
+
out.push(` ${what}${r.description ? ` # ${r.description}` : ''}`);
|
|
28
34
|
}
|
|
29
35
|
}
|
|
30
36
|
out.push('');
|
|
@@ -33,11 +39,26 @@ const format_waf = (waf) => {
|
|
|
33
39
|
out.push(' (none)');
|
|
34
40
|
}
|
|
35
41
|
for (const r of waf.rules) {
|
|
36
|
-
out.push(` • ${r
|
|
42
|
+
out.push(` • ${rule_subject(r)} → ${action_label(r.action)}`);
|
|
37
43
|
if (r.description)
|
|
38
44
|
out.push(` ${r.description}`);
|
|
39
45
|
}
|
|
40
46
|
return out;
|
|
41
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
|
+
};
|
|
42
63
|
|
|
43
|
-
export { action_label, format_waf };
|
|
64
|
+
export { action_label, format_waf, rule_subject };
|
|
@@ -1,30 +1,57 @@
|
|
|
1
1
|
import { requireApi } from '../../../api/context.js';
|
|
2
2
|
import { log } from '../../../log.js';
|
|
3
3
|
import { resolve_app_id } from '../resolve_app_id.js';
|
|
4
|
+
import { describe_selector } from './add_rule.js';
|
|
4
5
|
|
|
5
6
|
const waf_rm = {
|
|
6
|
-
command: 'rm
|
|
7
|
+
command: 'rm [pattern]',
|
|
7
8
|
describe: 'Remove one of your WAF rules',
|
|
8
9
|
builder: yargs => yargs
|
|
9
10
|
.positional('pattern', {
|
|
10
11
|
type: 'string',
|
|
11
|
-
demandOption: true,
|
|
12
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'
|
|
13
26
|
})
|
|
14
27
|
.option('app', {
|
|
15
28
|
alias: 'a',
|
|
16
29
|
type: 'string',
|
|
17
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;
|
|
18
37
|
})
|
|
19
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')
|
|
20
40
|
.showHelpOnFail(false),
|
|
21
41
|
handler: async (args) => {
|
|
22
42
|
const ctx = await requireApi();
|
|
23
43
|
const app_id = await resolve_app_id(args.app, ctx.appId, ctx.api);
|
|
24
44
|
const app = await ctx.api.getApp(app_id);
|
|
25
|
-
await ctx.api.removeAppWafRule(app_id,
|
|
26
|
-
|
|
27
|
-
|
|
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.`);
|
|
28
55
|
}
|
|
29
56
|
};
|
|
30
57
|
|
|
@@ -3,13 +3,17 @@ import { resolve_app_id } from '../resolve_app_id.js';
|
|
|
3
3
|
import { add_rule } from './add_rule.js';
|
|
4
4
|
|
|
5
5
|
const waf_sink = {
|
|
6
|
-
command: 'sink
|
|
7
|
-
describe: 'Answer
|
|
6
|
+
command: 'sink [pattern]',
|
|
7
|
+
describe: 'Answer requests with a 404 from Faable, without waking your app',
|
|
8
8
|
builder: yargs => yargs
|
|
9
9
|
.positional('pattern', {
|
|
10
10
|
type: 'string',
|
|
11
|
-
demandOption: true,
|
|
12
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.'
|
|
13
17
|
})
|
|
14
18
|
.option('app', {
|
|
15
19
|
alias: 'a',
|
|
@@ -20,11 +24,25 @@ const waf_sink = {
|
|
|
20
24
|
alias: 'd',
|
|
21
25
|
type: 'string',
|
|
22
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;
|
|
23
40
|
})
|
|
24
41
|
.example("$0 deploy waf sink '^/robots\\.txt$'", 'Let Faable answer /robots.txt with a 404 instead of starting your app')
|
|
25
|
-
.
|
|
26
|
-
'
|
|
27
|
-
'
|
|
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.')
|
|
28
46
|
.showHelpOnFail(false),
|
|
29
47
|
handler: async (args) => {
|
|
30
48
|
const ctx = await requireApi();
|
|
@@ -36,8 +54,10 @@ const waf_sink = {
|
|
|
36
54
|
app_name: app.name,
|
|
37
55
|
app_url: app.url,
|
|
38
56
|
pattern: args.pattern,
|
|
57
|
+
user_agent: args.userAgent,
|
|
39
58
|
action: 'sink',
|
|
40
|
-
description: args.description
|
|
59
|
+
description: args.description,
|
|
60
|
+
force: args.force
|
|
41
61
|
});
|
|
42
62
|
}
|
|
43
63
|
};
|
package/dist/index.js
CHANGED
|
@@ -72,6 +72,14 @@ yg.scriptName('faable')
|
|
|
72
72
|
// help, then fail red — a bad invocation must not exit 0.
|
|
73
73
|
yg.showHelp();
|
|
74
74
|
log.error(`❌ ${msg}`);
|
|
75
|
+
// The one unknown command worth naming: an app id passed positionally.
|
|
76
|
+
// `faable deploy <app_id> secrets list` used to deploy the working
|
|
77
|
+
// directory instead of listing secrets, so the id now lives in a flag —
|
|
78
|
+
// say so, or the migration reads as the CLI having lost a feature.
|
|
79
|
+
const stray = /Unknown commands?: (app_[A-Za-z0-9_-]+)/.exec(msg);
|
|
80
|
+
if (stray) {
|
|
81
|
+
log.error(`The app id goes in a flag, not as an argument: faable deploy --app ${stray[1]} [subcommand]`);
|
|
82
|
+
}
|
|
75
83
|
process.exit(1);
|
|
76
84
|
}
|
|
77
85
|
})
|