Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3911b1611c | ||
|
|
f6435b1a73 |
No files matched your search
+39
-38
@@ -12,8 +12,8 @@ configured only by environment variables, that provides:
|
|||||||
- R1: rate limits per minute, per hour and per day
|
- R1: rate limits per minute, per hour and per day
|
||||||
- R2: netblocks that bypass rate limiting
|
- R2: netblocks that bypass rate limiting
|
||||||
- R3: alerts on attacks through webhook, Slack or ntfy
|
- R3: alerts on attacks through webhook, Slack or ntfy
|
||||||
- R4: alerts on anomalies: requests or bytes per minute or hour, for given IPs
|
- R4: alerts on anomalies: requests made or bytes transferred per minute or
|
||||||
or netblocks, crossing a threshold
|
hour, for given IPs or netblocks, crossing a threshold
|
||||||
- R5: temporary blocks for abusers, permanent bans for repeat offenders
|
- R5: temporary blocks for abusers, permanent bans for repeat offenders
|
||||||
- R6: one or more RBLs or IP reputation APIs
|
- R6: one or more RBLs or IP reputation APIs
|
||||||
- R7: AS number lookup
|
- R7: AS number lookup
|
||||||
@@ -25,13 +25,13 @@ configured only by environment variables, that provides:
|
|||||||
## Verdict
|
## Verdict
|
||||||
|
|
||||||
Nothing existing is suitable. No surveyed tool meets R8 (scaling limits by AS
|
Nothing existing is suitable. No surveyed tool meets R8 (scaling limits by AS
|
||||||
number or country) or the bytes half of R4 at all, and the tools that come
|
number or country) or R4's alerts on bytes transferred, and the tools that come
|
||||||
closest on the rest fail R10: they need several containers, YAML or a web UI.
|
closest on the rest fail R10: they need several containers, YAML or a web UI.
|
||||||
The recommendation is to build a small single-binary sidecar; `SPEC.md` in this
|
The recommendation is to build a small single-binary sidecar; `SPEC.md` in this
|
||||||
directory begins that spec. It reuses the mature parts of the surveyed field as
|
directory begins that spec. The sidecar reuses the mature parts of the surveyed
|
||||||
libraries and data sources (Coraza and the OWASP Core Rule Set for attack
|
field as libraries and data sources (Coraza and the OWASP Core Rule Set for
|
||||||
detection, DNSBLs, AbuseIPDB, optionally a CrowdSec decision feed) instead of
|
attack detection, DNSBLs, AbuseIPDB, optionally a CrowdSec decision feed)
|
||||||
reimplementing them.
|
instead of reimplementing them.
|
||||||
|
|
||||||
Closest existing options, and why each still falls short:
|
Closest existing options, and why each still falls short:
|
||||||
|
|
||||||
@@ -46,12 +46,12 @@ Closest existing options, and why each still falls short:
|
|||||||
blacklist and whitelist decisions), R9 (ModSecurity with the Core Rule
|
blacklist and whitelist decisions), R9 (ModSecurity with the Core Rule
|
||||||
Set, or Coraza plugin), env-var settings.
|
Set, or Coraza plugin), env-var settings.
|
||||||
- Fails: R8 (AS number and country can only allow or deny, never scale a
|
- Fails: R8 (AS number and country can only allow or deny, never scale a
|
||||||
limit), R4 (no volume or byte threshold alerts in the free edition;
|
limit), R4 (no alerts on request-count or byte thresholds in the free
|
||||||
reporting is a paid feature), R5 escalation, and R10 in spirit: since 1.6
|
edition; reporting is a paid feature), R5 escalation, and R10's plain
|
||||||
it needs a `bunkerweb` container plus a `bw-scheduler` container and a
|
sidecar: since 1.6 it needs a `bunkerweb` container plus a `bw-scheduler`
|
||||||
database, or the all-in-one image that bundles nginx, scheduler, UI and
|
container and a database, or the all-in-one image that bundles nginx,
|
||||||
Redis in one container. It is designed to be the front door for many
|
scheduler, UI and Redis in one container. It is designed as the proxy in
|
||||||
sites, not a per-app sidecar. AGPL-3.0.
|
front of many sites, not as a sidecar for one app. AGPL-3.0.
|
||||||
- Unverified: whether several rates (minute, hour, day) can be stacked on
|
- Unverified: whether several rates (minute, hour, day) can be stacked on
|
||||||
the same URL pattern; the documented design is one rate per URL pattern.
|
the same URL pattern; the documented design is one rate per URL pattern.
|
||||||
- CrowdSec is the strongest detection and ban engine, but it is not a proxy.
|
- CrowdSec is the strongest detection and ban engine, but it is not a proxy.
|
||||||
@@ -68,14 +68,15 @@ Closest existing options, and why each still falls short:
|
|||||||
answers 429.
|
answers 429.
|
||||||
- Fails: R10 (needs the security engine container with persistent state, log
|
- Fails: R10 (needs the security engine container with persistent state, log
|
||||||
acquisition from traefik, a bouncer such as the traefik plugin, and YAML
|
acquisition from traefik, a bouncer such as the traefik plugin, and YAML
|
||||||
for acquisition, profiles, scenarios and notifications), bytes half of R4.
|
for acquisition, profiles, scenarios and notifications), R4's alerts on
|
||||||
|
bytes.
|
||||||
- CrowdSec plus traefik's own `rateLimit` middleware is the best combination
|
- CrowdSec plus traefik's own `rateLimit` middleware is the best combination
|
||||||
with no new code. It gives inline limiting (one window per middleware, keyed
|
with no new code. It gives inline limiting (one window per middleware, keyed
|
||||||
by IP, with `sourceCriterion` exclusions), bans with escalation, alerts and
|
by IP, with `sourceCriterion` exclusions), bans with escalation, alerts and
|
||||||
reputation. It still fails R8, the bytes half of R4, R1's three stacked
|
reputation lookups. It still fails R8, R4's alerts on bytes, and R10 entirely:
|
||||||
windows only by chaining three middlewares per router, and R10 entirely: the
|
the configuration lives in traefik labels and CrowdSec YAML on each host, not
|
||||||
configuration lives in traefik labels and CrowdSec YAML on each host, not in
|
in one sidecar's environment. It meets R1's three windows only by chaining
|
||||||
one sidecar's environment.
|
three middlewares per router.
|
||||||
|
|
||||||
## Candidate by candidate
|
## Candidate by candidate
|
||||||
|
|
||||||
@@ -87,10 +88,10 @@ Closest existing options, and why each still falls short:
|
|||||||
cached, streamed decision list (recommended), standalone against the
|
cached, streamed decision list (recommended), standalone against the
|
||||||
central API, or AppSec only. Supports captcha remediation.
|
central API, or AppSec only. Supports captcha remediation.
|
||||||
- Covers: R5, R6, R9, R3 and R7 through the engine (see Verdict).
|
- Covers: R5, R6, R9, R3 and R7 through the engine (see Verdict).
|
||||||
- Misses: the plugin itself does no rate limiting; R8; R4 bytes.
|
- Misses: the plugin itself does no rate limiting; R8; R4's alerts on bytes.
|
||||||
- Configuration: traefik static config to load the plugin, dynamic config or
|
- Configuration: traefik static config to load the plugin, dynamic config or
|
||||||
labels for the middleware, CrowdSec YAML for everything else.
|
labels for the middleware, CrowdSec YAML for everything else.
|
||||||
- Sidecar fit: no. It lives inside traefik, plus a separate engine
|
- Sidecar fit: no. It runs inside traefik and needs a separate engine
|
||||||
container.
|
container.
|
||||||
- Maturity: widely used (about 900 stars, listed in the traefik plugin
|
- Maturity: widely used (about 900 stars, listed in the traefik plugin
|
||||||
catalog, documented by CrowdSec itself), actively maintained. Traefik
|
catalog, documented by CrowdSec itself), actively maintained. Traefik
|
||||||
@@ -115,7 +116,7 @@ Closest existing options, and why each still falls short:
|
|||||||
- What it is: nginx-derived (Tengine) reverse proxy with a proprietary
|
- What it is: nginx-derived (Tengine) reverse proxy with a proprietary
|
||||||
semantic detection engine and a web console. About 22,000 stars.
|
semantic detection engine and a web console. About 22,000 stars.
|
||||||
- Covers: R9 well, basic per-IP rate limiting with block duration, bot
|
- Covers: R9 well, basic per-IP rate limiting with block duration, bot
|
||||||
challenge, authentication gate.
|
challenge, authentication in front of the app.
|
||||||
- Misses: R8, R7, R4; R1 has one window per rule. Country blocking, alert
|
- Misses: R8, R7, R4; R1 has one window per rule. Country blocking, alert
|
||||||
messages, syslog forwarding and the better reputation database are paid
|
messages, syslog forwarding and the better reputation database are paid
|
||||||
(Lite about 10 USD per month, Pro about 100 USD per month); the free
|
(Lite about 10 USD per month, Pro about 100 USD per month); the free
|
||||||
@@ -128,7 +129,7 @@ Closest existing options, and why each still falls short:
|
|||||||
- Coraza (`corazawaf/coraza`) and Coraza-based proxies
|
- Coraza (`corazawaf/coraza`) and Coraza-based proxies
|
||||||
- What it is: a Go library that implements the ModSecurity rule language and
|
- What it is: a Go library that implements the ModSecurity rule language and
|
||||||
runs the OWASP Core Rule Set. OWASP project, actively maintained, the
|
runs the OWASP Core Rule Set. OWASP project, actively maintained, the
|
||||||
successor path now that ModSecurity is in maintenance only.
|
successor to ModSecurity, which is now in maintenance only.
|
||||||
- Packagings: `coraza-caddy` (Caddy module), `coraza-spoa` (HAProxy),
|
- Packagings: `coraza-caddy` (Caddy module), `coraza-spoa` (HAProxy),
|
||||||
`coraza-proxy-wasm` (Envoy), a traefik WASM plugin, and
|
`coraza-proxy-wasm` (Envoy), a traefik WASM plugin, and
|
||||||
`coreruleset/coraza-crs-docker` (Caddy plus Coraza plus the Core Rule Set,
|
`coreruleset/coraza-crs-docker` (Caddy plus Coraza plus the Core Rule Set,
|
||||||
@@ -142,7 +143,7 @@ Closest existing options, and why each still falls short:
|
|||||||
- ModSecurity Core Rule Set containers (`owasp/modsecurity-crs`)
|
- ModSecurity Core Rule Set containers (`owasp/modsecurity-crs`)
|
||||||
- What it is: official images of Apache or nginx with ModSecurity and the
|
- What it is: official images of Apache or nginx with ModSecurity and the
|
||||||
Core Rule Set, as a reverse proxy configured by env vars (`BACKEND`,
|
Core Rule Set, as a reverse proxy configured by env vars (`BACKEND`,
|
||||||
paranoia level, anomaly thresholds, many more). Current images carry
|
paranoia level, anomaly thresholds, many more). Current images contain
|
||||||
ModSecurity 2.9.x on Apache and Core Rule Set 4.x.
|
ModSecurity 2.9.x on Apache and Core Rule Set 4.x.
|
||||||
- Covers: R9, and R10 (single container, env vars, one backend).
|
- Covers: R9, and R10 (single container, env vars, one backend).
|
||||||
- Misses: R1 to R8.
|
- Misses: R1 to R8.
|
||||||
@@ -152,8 +153,8 @@ Closest existing options, and why each still falls short:
|
|||||||
- Anubis (`TecharoHQ/anubis`), 1.27 current
|
- Anubis (`TecharoHQ/anubis`), 1.27 current
|
||||||
- What it is: a single-binary reverse proxy that makes browsers solve a
|
- What it is: a single-binary reverse proxy that makes browsers solve a
|
||||||
proof-of-work challenge before passing them to `TARGET`. Aimed at
|
proof-of-work challenge before passing them to `TARGET`. Aimed at
|
||||||
scrapers, which is likely a large share of unwanted traffic on a public
|
scrapers, which are likely a large share of the unwanted traffic on a
|
||||||
code forge.
|
public code forge.
|
||||||
- Covers: R10 well (one container, env vars for listener, target,
|
- Covers: R10 well (one container, env vars for listener, target,
|
||||||
difficulty, cookies). Policy rules can match on path, user agent, headers,
|
difficulty, cookies). Policy rules can match on path, user agent, headers,
|
||||||
IP ranges, and with the vendor's hosted data service also AS number and
|
IP ranges, and with the vendor's hosted data service also AS number and
|
||||||
@@ -180,13 +181,13 @@ Closest existing options, and why each still falls short:
|
|||||||
temporary ban, no escalation, state lost on traefik restart) and R2
|
temporary ban, no escalation, state lost on traefik restart) and R2
|
||||||
(allowlist). Nothing else.
|
(allowlist). Nothing else.
|
||||||
- `juitde/traefik-plugin-fail2ban`, fail2ban connector plugins, GeoBlock
|
- `juitde/traefik-plugin-fail2ban`, fail2ban connector plugins, GeoBlock
|
||||||
plugins (country allow or deny through an external lookup API): same
|
plugins (country allow or deny through an external lookup API): each adds
|
||||||
shape, each one small feature, configured in traefik, state in memory.
|
one small feature, is configured in traefik and keeps its state in memory.
|
||||||
- Sidecar fit: no; they live inside traefik.
|
- Sidecar fit: no; they live inside traefik.
|
||||||
- Traefik built-in middlewares
|
- Traefik built-in middlewares
|
||||||
- `rateLimit` (one average-and-burst window per middleware, optional Redis
|
- `rateLimit` (one average-and-burst window per middleware, optional Redis
|
||||||
in traefik 3.x), `inFlightReq`, `ipAllowList`. No bans, alerts, reputation
|
in traefik 3.x), `inFlightReq`, `ipAllowList`. No bans, alerts, reputation
|
||||||
or AS number awareness.
|
lookups or AS number lookups.
|
||||||
- caddy-waf (`fabriziosalmi/caddy-waf`), 0.4.x
|
- caddy-waf (`fabriziosalmi/caddy-waf`), 0.4.x
|
||||||
- What it is: a Caddy module with regex rules and anomaly scoring, per-IP
|
- What it is: a Caddy module with regex rules and anomaly scoring, per-IP
|
||||||
and per-path rate limiting with one configurable window, IP and DNS
|
and per-path rate limiting with one configurable window, IP and DNS
|
||||||
@@ -198,12 +199,12 @@ Closest existing options, and why each still falls short:
|
|||||||
- open-appsec (Check Point)
|
- open-appsec (Check Point)
|
||||||
- Machine-learning WAF agent attached to nginx, Kong, Envoy or similar, with
|
- Machine-learning WAF agent attached to nginx, Kong, Envoy or similar, with
|
||||||
a declarative policy file or the vendor's cloud console. Covers R9 only;
|
a declarative policy file or the vendor's cloud console. Covers R9 only;
|
||||||
rate limiting and richer features are in paid tiers. Not a sidecar in the
|
rate limiting and richer features are in paid tiers. It attaches to an
|
||||||
required sense.
|
existing proxy, so it is not the sidecar R10 asks for.
|
||||||
- iocaine
|
- iocaine
|
||||||
- Serves generated garbage pages to clients the fronting proxy classifies as
|
- Serves generated garbage pages to clients the fronting proxy classifies as
|
||||||
scrapers. Not a limiter, WAF or ban tool; out of scope except as a
|
scrapers. Not a limiter, WAF or ban tool; out of scope, and only of
|
||||||
curiosity for scraper traffic.
|
passing interest for scraper traffic.
|
||||||
- Pangolin
|
- Pangolin
|
||||||
- A tunnelled access platform that bundles traefik and optionally CrowdSec.
|
- A tunnelled access platform that bundles traefik and optionally CrowdSec.
|
||||||
Replaces the ingress rather than adding a sidecar; out of scope.
|
Replaces the ingress rather than adding a sidecar; out of scope.
|
||||||
@@ -214,17 +215,17 @@ Closest existing options, and why each still falls short:
|
|||||||
Everyone else offers one window per rule or none.
|
Everyone else offers one window per rule or none.
|
||||||
- R2 bypass netblocks: BunkerWeb, CrowdSec, traefik, fail2ban plugins.
|
- R2 bypass netblocks: BunkerWeb, CrowdSec, traefik, fail2ban plugins.
|
||||||
- R3 alerts: CrowdSec (best), BunkerWeb (denied requests only), SafeLine (paid).
|
- R3 alerts: CrowdSec (best), BunkerWeb (denied requests only), SafeLine (paid).
|
||||||
- R4 volume and byte anomalies: none. CrowdSec can approximate request-count
|
- R4 alerts on request counts and bytes: none. CrowdSec can approximate alerts
|
||||||
anomalies with a custom scenario; nothing handles bytes.
|
on request counts with a custom scenario; no surveyed tool alerts on bytes.
|
||||||
- R5 temporary then permanent: CrowdSec only. BunkerWeb has fixed or permanent,
|
- R5 temporary then permanent: CrowdSec only. BunkerWeb's ban is either one
|
||||||
not both by history.
|
fixed length or permanent, and does not change with a client's history.
|
||||||
- R6 reputation: CrowdSec, BunkerWeb, go-away, caddy-waf (static lists).
|
- R6 reputation: CrowdSec, BunkerWeb, go-away, caddy-waf (static lists).
|
||||||
- R7 AS number lookup: CrowdSec (enrichment), BunkerWeb and caddy-waf (allow or
|
- R7 AS number lookup: CrowdSec (enrichment), BunkerWeb and caddy-waf (allow or
|
||||||
deny), Anubis (hosted service).
|
deny), Anubis (hosted service).
|
||||||
- R8 biased thresholds: none.
|
- R8 biased thresholds: none.
|
||||||
- R9 attack detection: Coraza or ModSecurity with the Core Rule Set (open,
|
- R9 attack detection: Coraza or ModSecurity with the Core Rule Set (open,
|
||||||
standard), CrowdSec AppSec, SafeLine (closed engine), BunkerWeb (wraps the
|
standard), CrowdSec AppSec, SafeLine (closed engine), BunkerWeb (wraps Coraza
|
||||||
first).
|
or ModSecurity).
|
||||||
- R10 env-var sidecar: `owasp/modsecurity-crs`, `coraza-crs-docker`, Anubis.
|
- R10 env-var sidecar: `owasp/modsecurity-crs`, `coraza-crs-docker`, Anubis.
|
||||||
None of these covers anything beyond its one job.
|
None of these covers anything beyond its one job.
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker hooks build \
|
.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker publish \
|
||||||
run example-app
|
hooks build run example-app
|
||||||
|
|
||||||
# Makefile targets are thin shims; the implementations live in script/
|
# Makefile targets are thin shims; the implementations live in script/
|
||||||
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
# per the scripts-to-rule-them-all pattern (see the Entrypoints section
|
||||||
# of README.md). tidy writes go.mod and go.sum as `go mod tidy` does,
|
# of README.md). tidy writes go.mod and go.sum as `go mod tidy` does,
|
||||||
# which test checks. build and run are for working on the code by hand;
|
# which test checks. build and run are for working on the code by hand;
|
||||||
# example-app checks the image with an app built on it.
|
# example-app checks the image with an app built on it; publish builds
|
||||||
|
# the image on the Docker host and pushes it to the registry.
|
||||||
|
|
||||||
bootstrap:
|
bootstrap:
|
||||||
@script/bootstrap
|
@script/bootstrap
|
||||||
@@ -34,6 +35,9 @@ check:
|
|||||||
docker:
|
docker:
|
||||||
@script/docker
|
@script/docker
|
||||||
|
|
||||||
|
publish:
|
||||||
|
@script/publish
|
||||||
|
|
||||||
hooks:
|
hooks:
|
||||||
@script/install-precommit
|
@script/install-precommit
|
||||||
|
|
||||||
|
|||||||
@@ -68,8 +68,9 @@ Not built, and not planned:
|
|||||||
|
|
||||||
### The image
|
### The image
|
||||||
|
|
||||||
No image is published. Build it from a clone. `make docker` runs the tests and
|
The image is published at `git.eeqj.de/sneak/smallwebwaf`. You can also build it
|
||||||
the linter, then builds the image, tagged `smallwebwaf`.
|
from a clone. `make docker` runs the tests and the linter, then builds the
|
||||||
|
image, tagged `smallwebwaf`.
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git clone https://git.eeqj.de/sneak/smallwebwaf.git
|
git clone https://git.eeqj.de/sneak/smallwebwaf.git
|
||||||
@@ -209,9 +210,10 @@ account, a key, a token or an alert destination stays off until you give one.
|
|||||||
percentage is a whole number from 0 to 100. A path prefix starts with `/`.
|
percentage is a whole number from 0 to 100. A path prefix starts with `/`.
|
||||||
- `off` switches a limit, a threshold or a timeout off where the table says a
|
- `off` switches a limit, a threshold or a timeout off where the table says a
|
||||||
setting takes it.
|
setting takes it.
|
||||||
- The byte limits and the anomaly thresholds on bytes count body bytes: the
|
- The byte limits and the byte anomaly thresholds count only body bytes: the
|
||||||
app's response bodies, the visitors' request bodies, or both, as
|
app's response bodies, the visitors' request bodies, or both, as
|
||||||
`SWW_BYTES_COUNT` says. Headers are never counted.
|
`SWW_BYTES_COUNT` says. Headers are never counted. What the visitor sent over
|
||||||
|
a WebSocket counts as request bytes, and what the app sent as response bytes.
|
||||||
- An invalid setting stops the process at start with one log line naming the
|
- An invalid setting stops the process at start with one log line naming the
|
||||||
variable and what is wrong with the value. The settings in effect are logged
|
variable and what is wrong with the value. The settings in effect are logged
|
||||||
on one line at start, with tokens, keys and webhook paths shown as `********`.
|
on one line at start, with tokens, keys and webhook paths shown as `********`.
|
||||||
@@ -219,7 +221,7 @@ account, a key, a token or an alert destination stays off until you give one.
|
|||||||
Every setting the code reads, with its default:
|
Every setting the code reads, with its default:
|
||||||
|
|
||||||
| Variable | Default | What it does |
|
| Variable | Default | What it does |
|
||||||
| ---------------------------------------- | ------------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. |
|
| `SWW_COMMAND` | `rproxy` | Which command the image runs, and its health check checks: `rproxy` or `discriminator`. |
|
||||||
| `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. |
|
| `SWRP_WAF_BACKEND` | required | `discriminator`'s URL, such as `http://discriminator:8080`. |
|
||||||
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
|
| `SWRP_UPSTREAM_URL` | `http://127.0.0.1:8081` | The app's URL: a scheme, a host and an optional port, nothing more. |
|
||||||
@@ -244,11 +246,11 @@ Every setting the code reads, with its default:
|
|||||||
| `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; the next request is refused and bans the client. |
|
| `SWW_RATE_LIMIT_PER_HOUR` | `1500` | The most requests a client may make in an hour, a count or `off`; the next request is refused and bans the client. |
|
||||||
| `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; the next request is refused and bans the client. |
|
| `SWW_RATE_LIMIT_PER_DAY` | `6000` | The most requests a client may make in a day, a count or `off`; the next request is refused and bans the client. |
|
||||||
| `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes whose requests the request and byte limits neither count nor refuse, such as `/assets/`. |
|
| `SWW_RATE_LIMIT_EXEMPT_PATHS` | empty | Path prefixes whose requests the request and byte limits neither count nor refuse, such as `/assets/`. |
|
||||||
| `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most body bytes a client's requests may total in a minute, a size or `off`; the request that takes the total past a byte limit is passed on whole, and bans the client once its response has ended. |
|
| `SWW_BYTES_LIMIT_PER_MINUTE` | `off` | The most bytes counted for a client in a minute, a size or `off`: response bodies, request bodies or both, as `SWW_BYTES_COUNT` says. The request that passes the limit is not cut off, and the client is banned when its response ends. |
|
||||||
| `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most body bytes a client's requests may total in an hour, a size or `off`. |
|
| `SWW_BYTES_LIMIT_PER_HOUR` | `50G` | The most bytes counted for a client in an hour, a size or `off`: response bodies, request bodies or both, as `SWW_BYTES_COUNT` says. The request that passes the limit is not cut off, and the client is banned when its response ends. |
|
||||||
| `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most body bytes a client's requests may total in a day, a size or `off`. |
|
| `SWW_BYTES_LIMIT_PER_DAY` | `100G` | The most bytes counted for a client in a day, a size or `off`: response bodies, request bodies or both, as `SWW_BYTES_COUNT` says. The request that passes the limit is not cut off, and the client is banned when its response ends. |
|
||||||
| `SWW_BYTES_COUNT` | `both` | Which body bytes the byte limits and the anomaly thresholds on bytes count: `response`, the app's response bodies; `request`, the visitors' request bodies; or `both`. |
|
| `SWW_BYTES_COUNT` | `both` | What the byte limits and the byte anomaly thresholds count: `response`, the app's response bodies; `request`, the visitors' request bodies; or `both`. What the visitor sent over a WebSocket counts as request bytes, and what the app sent as response bytes. |
|
||||||
| `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals by a rule file, a trap path or the Core Rule Set a client may get in a minute, a count or `off`; the next such refusal bans the client. |
|
| `SWW_ERROR_BURST_THRESHOLD` | `30` | The most refusals a client may get in a minute from a rule file, a trap path or the Core Rule Set, a count or `off`. The next one bans the client. |
|
||||||
| `SWW_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
|
| `SWW_BAN_RESPONSE` | `403` | How a refused client is answered: `403`, `429` or `close`, which drops the connection. |
|
||||||
| `SWW_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
|
| `SWW_LIMIT_BAN_DURATION` | `1h` | The ban for a first broken limit. |
|
||||||
| `SWW_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A limit broken again within this time after the last such ban ended gets a ban three times as long. |
|
| `SWW_LIMIT_BAN_REPEAT_WINDOW` | `24h` | A limit broken again within this time after the last such ban ended gets a ban three times as long. |
|
||||||
@@ -313,27 +315,27 @@ Every setting the code reads, with its default:
|
|||||||
| `SWW_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
|
| `SWW_ALERT_MAX_PER_HOUR` | `60` | The most alerts sent in an hour, a number or `off`; the rest are counted in the hour's `summary`. |
|
||||||
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
|
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_MINUTE` | `off` | Alert when one client makes more requests than this in a minute, a count. |
|
||||||
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
|
| `SWW_ANOMALY_CLIENT_REQUESTS_PER_HOUR` | `off` | Alert when one client makes more requests than this in an hour, a count. |
|
||||||
| `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when one client's body bytes total more than this in a minute, a size. |
|
| `SWW_ANOMALY_CLIENT_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in a minute, a size. |
|
||||||
| `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when one client's body bytes total more than this in an hour, a size. |
|
| `SWW_ANOMALY_CLIENT_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one client in an hour, a size. |
|
||||||
| `SWW_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
|
| `SWW_ANOMALY_NET_REQUESTS_PER_MINUTE` | `off` | Alert when the netblock around a client makes more requests than this in a minute, a count. |
|
||||||
| `SWW_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
|
| `SWW_ANOMALY_NET_REQUESTS_PER_HOUR` | `off` | Alert when the netblock around a client makes more requests than this in an hour, a count. |
|
||||||
| `SWW_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when the body bytes of the netblock around a client total more than this in a minute, a size. |
|
| `SWW_ANOMALY_NET_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in a minute, a size. |
|
||||||
| `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when the body bytes of the netblock around a client total more than this in an hour, a size. |
|
| `SWW_ANOMALY_NET_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the netblock around a client in an hour, a size. |
|
||||||
| `SWW_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 32. |
|
| `SWW_ANOMALY_NET_V4_PREFIX` | `24` | The prefix length of the netblock around an IPv4 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 32. |
|
||||||
| `SWW_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 128. |
|
| `SWW_ANOMALY_NET_V6_PREFIX` | `48` | The prefix length of the netblock around an IPv6 client that the `SWW_ANOMALY_NET_` thresholds count, from 0 to 128. |
|
||||||
| `SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
|
| `SWW_ANOMALY_ASN_REQUESTS_PER_MINUTE` | `off` | Alert when one AS number makes more requests than this in a minute, a count. |
|
||||||
| `SWW_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
|
| `SWW_ANOMALY_ASN_REQUESTS_PER_HOUR` | `off` | Alert when one AS number makes more requests than this in an hour, a count. |
|
||||||
| `SWW_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when the body bytes of one AS number total more than this in a minute, a size. |
|
| `SWW_ANOMALY_ASN_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in a minute, a size. |
|
||||||
| `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when the body bytes of one AS number total more than this in an hour, a size. |
|
| `SWW_ANOMALY_ASN_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for one AS number in an hour, a size. |
|
||||||
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
|
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_MINUTE` | `off` | Alert when the whole service gets more requests than this in a minute, a count. |
|
||||||
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
|
| `SWW_ANOMALY_TOTAL_REQUESTS_PER_HOUR` | `off` | Alert when the whole service gets more requests than this in an hour, a count. |
|
||||||
| `SWW_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when the body bytes of the whole service total more than this in a minute, a size. |
|
| `SWW_ANOMALY_TOTAL_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in a minute, a size. |
|
||||||
| `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when the body bytes of the whole service total more than this in an hour, a size. |
|
| `SWW_ANOMALY_TOTAL_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for the whole service in an hour, a size. |
|
||||||
| `SWW_WATCH_NETS` | empty | Named netblocks the `SWW_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
|
| `SWW_WATCH_NETS` | empty | Named netblocks the `SWW_WATCH_` thresholds count each as a whole, such as `office=203.0.113.0/24,scraper-x=198.51.100.0/22`. |
|
||||||
| `SWW_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
|
| `SWW_WATCH_REQUESTS_PER_MINUTE` | `off` | Alert when a watched netblock makes more requests than this in a minute, a count. |
|
||||||
| `SWW_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
|
| `SWW_WATCH_REQUESTS_PER_HOUR` | `off` | Alert when a watched netblock makes more requests than this in an hour, a count. |
|
||||||
| `SWW_WATCH_BYTES_PER_MINUTE` | `off` | Alert when the body bytes of a watched netblock total more than this in a minute, a size. |
|
| `SWW_WATCH_BYTES_PER_MINUTE` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in a minute, a size. |
|
||||||
| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when the body bytes of a watched netblock total more than this in an hour, a size. |
|
| `SWW_WATCH_BYTES_PER_HOUR` | `off` | Alert when more than this many bytes, as `SWW_BYTES_COUNT` counts them, are counted for a watched netblock in an hour, a size. |
|
||||||
|
|
||||||
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in
|
[`docs/settings.md`](docs/settings.md) gives the values each setting takes in
|
||||||
full, and the limits that are fixed rather than settings.
|
full, and the limits that are fixed rather than settings.
|
||||||
@@ -669,6 +671,15 @@ on the host.
|
|||||||
`make check`.
|
`make check`.
|
||||||
- `script/docker`: builds the image, whose build runs the tests and the linter
|
- `script/docker`: builds the image, whose build runs the tests and the linter
|
||||||
first. `make docker`.
|
first. `make docker`.
|
||||||
|
- `script/publish`: runs `script/docker`, whose build happens on the Docker host
|
||||||
|
named by `DOCKER_HOST` (`ssh://root@fsn1app1` unless set). It then pushes the
|
||||||
|
image to `PUBLISH_IMAGE` (`git.eeqj.de/sneak/smallwebwaf` unless set). It
|
||||||
|
refuses a working tree with uncommitted changes. `make publish`. It pushes
|
||||||
|
four tags:
|
||||||
|
- the UTC date as `YYYY-MM-DD`;
|
||||||
|
- the short commit hash;
|
||||||
|
- the branch name;
|
||||||
|
- `latest`.
|
||||||
- `script/cibuild`: what CI runs: `script/bootstrap`, `script/check`, then the
|
- `script/cibuild`: what CI runs: `script/bootstrap`, `script/check`, then the
|
||||||
image build.
|
image build.
|
||||||
- `script/precommit`: run by the git pre-commit hook; runs `script/check`.
|
- `script/precommit`: run by the git pre-commit hook; runs `script/check`.
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ to them.
|
|||||||
key or a token stays off until the operator supplies it.
|
key or a token stays off until the operator supplies it.
|
||||||
- It keeps a site answering while the part that decides is down, slow or
|
- It keeps a site answering while the part that decides is down, slow or
|
||||||
restarting (see "Without an answer from `discriminator`").
|
restarting (see "Without an answer from `discriminator`").
|
||||||
- It limits each client's requests and bytes per minute, hour and day. It bounds
|
- It limits each client's requests and body bytes per minute, hour and day. It
|
||||||
the size and duration of every request and answer. It detects attacks.
|
bounds the size and duration of every request and answer. It detects attacks.
|
||||||
- It bans abusers: briefly at first, for seven days on a clear sign of attack,
|
- It bans abusers: briefly at first, for seven days on a clear sign of attack,
|
||||||
and permanently when they keep at it.
|
and permanently when they keep at it.
|
||||||
- It looks up each client's AS number and country. It can refuse whole countries
|
- It looks up each client's AS number and country. It can refuse whole countries
|
||||||
@@ -252,7 +252,8 @@ to a ban, each by its own rule:
|
|||||||
clear sign of attack bans it permanently at once.
|
clear sign of attack bans it permanently at once.
|
||||||
- A broken limit, one of:
|
- A broken limit, one of:
|
||||||
- a request over a rate limit;
|
- a request over a rate limit;
|
||||||
- a request whose body bytes take the client's total past a byte limit;
|
- the bytes counted for a request and its response taking the client's total
|
||||||
|
past a byte limit;
|
||||||
- more than `SWW_ERROR_BURST_THRESHOLD` refusals within a minute.
|
- more than `SWW_ERROR_BURST_THRESHOLD` refusals within a minute.
|
||||||
|
|
||||||
The bans for a broken limit:
|
The bans for a broken limit:
|
||||||
@@ -441,8 +442,10 @@ the same way.
|
|||||||
### Byte limits, per client
|
### Byte limits, per client
|
||||||
|
|
||||||
The byte limits count body bytes: the app's response bodies, the visitors'
|
The byte limits count body bytes: the app's response bodies, the visitors'
|
||||||
request bodies, or both, as `SWW_BYTES_COUNT` says. Headers are never counted. A
|
request bodies, or both, as `SWW_BYTES_COUNT` says. Headers are never counted.
|
||||||
request's bytes are counted once its response has ended (see step 15). So the
|
What the visitor sent over a WebSocket counts as request bytes, and what the app
|
||||||
|
sent as response bytes. The bytes counted for a request and its response are
|
||||||
|
added to the client's total once the response has ended (see step 15). So the
|
||||||
request that takes a client past a limit is passed on whole, and the ban refuses
|
request that takes a client past a limit is passed on whole, and the ban refuses
|
||||||
the client's requests from then on. By default there is no minute limit, so a
|
the client's requests from then on. By default there is no minute limit, so a
|
||||||
client can download in a burst at full speed. The hour limit defaults to 50 GiB
|
client can download in a burst at full speed. The hour limit defaults to 50 GiB
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ What `GET /_sww/metrics` answers with, at `discriminator`'s own address.
|
|||||||
keeps the `instance` label as `exported_instance` unless the scrape sets
|
keeps the `instance` label as `exported_instance` unless the scrape sets
|
||||||
`honor_labels: true`. `rproxy` has no metrics.
|
`honor_labels: true`. `rproxy` has no metrics.
|
||||||
|
|
||||||
|
Every `sww_*request_bytes_total` and `sww_*response_bytes_total` series counts
|
||||||
|
body bytes, never headers, whatever `SWW_BYTES_COUNT` says.
|
||||||
|
|
||||||
## Traffic
|
## Traffic
|
||||||
|
|
||||||
- `sww_requests_total`, `sww_request_bytes_total` and
|
- `sww_requests_total`, `sww_request_bytes_total` and
|
||||||
|
|||||||
+8
-6
@@ -144,20 +144,22 @@ Every one of these lines has:
|
|||||||
included. It is left out when rate limiting did not count the request. It
|
included. It is left out when rate limiting did not count the request. It
|
||||||
holds:
|
holds:
|
||||||
- `minute`, `hour` and `day`: requests;
|
- `minute`, `hour` and `day`: requests;
|
||||||
- `minute_bytes`, `hour_bytes` and `day_bytes`: bytes.
|
- `minute_bytes`, `hour_bytes` and `day_bytes`: the body bytes
|
||||||
|
`SWW_BYTES_COUNT` names, in that window.
|
||||||
|
|
||||||
A count can have a fraction (see "Counting method" in
|
A count can have a fraction (see "Counting method" in
|
||||||
[`SPEC.md`](../SPEC.md)). The bytes of an allowed request are counted when
|
[`SPEC.md`](../SPEC.md)). The bytes counted for an allowed request and its
|
||||||
its report comes. So on the `decision` line, the bytes are without this
|
response are added when its report comes. So on the `decision` line, the
|
||||||
request's.
|
three byte totals leave them out.
|
||||||
|
|
||||||
- `limit_hit`: the limit broken:
|
- `limit_hit`: the limit broken:
|
||||||
- `minute`, `hour` or `day` for a rate limit;
|
- `minute`, `hour` or `day` for a rate limit;
|
||||||
- `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit;
|
- `minute_bytes`, `hour_bytes` or `day_bytes` for a byte limit;
|
||||||
- `error_burst`.
|
- `error_burst`.
|
||||||
|
|
||||||
A request whose bytes broke a limit was passed on whole, so its `action`
|
When the bytes counted for a request and its response break a limit, the
|
||||||
stays `forward`. Only its `report` line gives that limit.
|
request has been passed on whole, so its `action` stays `forward`. Only its
|
||||||
|
`report` line gives that limit.
|
||||||
|
|
||||||
- `rule_ids`: the rule file rules that matched, in order, up to the one that
|
- `rule_ids`: the rule file rules that matched, in order, up to the one that
|
||||||
refused.
|
refused.
|
||||||
|
|||||||
+14
-8
@@ -122,16 +122,21 @@ The request past one of these limits is refused, and the client is banned.
|
|||||||
|
|
||||||
## Byte limits, per client
|
## Byte limits, per client
|
||||||
|
|
||||||
These count body bytes, never headers. A request's bytes are counted once its
|
These count body bytes, never headers. What the visitor sent over a WebSocket
|
||||||
|
counts as request bytes, and what the app sent as response bytes. The bytes
|
||||||
|
counted for a request and its response are added to the client's total once the
|
||||||
response has ended. The request that takes a client's total past a limit is not
|
response has ended. The request that takes a client's total past a limit is not
|
||||||
cut off; the client is banned.
|
cut off; the client is banned.
|
||||||
|
|
||||||
- `SWW_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWW_BYTES_LIMIT_PER_HOUR`
|
- `SWW_BYTES_LIMIT_PER_MINUTE` (default `off`), `SWW_BYTES_LIMIT_PER_HOUR`
|
||||||
(default `50G`) and `SWW_BYTES_LIMIT_PER_DAY` (default `100G`): the most body
|
(default `50G`) and `SWW_BYTES_LIMIT_PER_DAY` (default `100G`): the most bytes
|
||||||
bytes a client's requests may total in each window. A size or `off`.
|
counted for a client in each window, a size or `off`: response bodies, request
|
||||||
- `SWW_BYTES_COUNT` (default `both`): which body bytes the byte limits and the
|
bodies or both, as `SWW_BYTES_COUNT` says. The request that passes the limit
|
||||||
anomaly thresholds on bytes count: `response`, the app's response bodies;
|
is not cut off, and the client is banned when its response ends.
|
||||||
`request`, the visitors' request bodies; or `both`.
|
- `SWW_BYTES_COUNT` (default `both`): what the byte limits and the byte anomaly
|
||||||
|
thresholds count: `response`, the app's response bodies; `request`, the
|
||||||
|
visitors' request bodies; or `both`. What the visitor sent over a WebSocket
|
||||||
|
counts as request bytes, and what the app sent as response bytes.
|
||||||
|
|
||||||
## Lookup of AS number and country
|
## Lookup of AS number and country
|
||||||
|
|
||||||
@@ -205,8 +210,9 @@ cut off; the client is banned.
|
|||||||
a range. So does a directive that removes or changes rules as the file loads,
|
a range. So does a directive that removes or changes rules as the file loads,
|
||||||
such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded
|
such as `SecRuleRemoveById`, since none of the Core Rule Set's rules is loaded
|
||||||
yet. It has no `_FILE` form.
|
yet. It has no `_FILE` form.
|
||||||
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals of one client in
|
- `SWW_ERROR_BURST_THRESHOLD` (default `30`): the most refusals a client may get
|
||||||
a minute (see "Attack detection" in `SPEC.md`). A count or `off`.
|
in a minute from a rule file, a trap path or the Core Rule Set, a count or
|
||||||
|
`off`. The next one bans the client (see "Attack detection" in `SPEC.md`).
|
||||||
|
|
||||||
## Ban lengths, response and scope
|
## Ban lengths, response and scope
|
||||||
|
|
||||||
|
|||||||
+80
-28
@@ -397,18 +397,21 @@ var (
|
|||||||
errNotPathPrefix = errors.New(
|
errNotPathPrefix = errors.New(
|
||||||
"is not a path prefix starting with /, such as /assets/")
|
"is not a path prefix starting with /, such as /assets/")
|
||||||
errNotTrapPath = errors.New(
|
errNotTrapPath = errors.New(
|
||||||
"is not a path starting with / and without a ?, such as /wp-login.php")
|
"is not a path starting with / and without a ?, such as /wp-login.php",
|
||||||
|
)
|
||||||
errNotWAFMode = errors.New("is not off, detect or block")
|
errNotWAFMode = errors.New("is not off, detect or block")
|
||||||
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
|
errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4")
|
||||||
errNotRuleID = errors.New(
|
errNotRuleID = errors.New(
|
||||||
"is not the id of a Core Rule Set rule, a whole number such as 942100")
|
"is not the id of a Core Rule Set rule, a whole number such as 942100",
|
||||||
|
)
|
||||||
errSetupRuleID = errors.New(
|
errSetupRuleID = errors.New(
|
||||||
"is from 900000 to 900999, the ids of the rules that set the Core Rule Set " +
|
"is from 900000 to 900999, the ids of the rules that set the Core Rule Set " +
|
||||||
"up and of sww's own, which cannot be switched off")
|
"up and of sww's own, which cannot be switched off")
|
||||||
errNotBoolean = errors.New("is not true or false")
|
errNotBoolean = errors.New("is not true or false")
|
||||||
errNotLogRemoteURL = errors.New(
|
errNotLogRemoteURL = errors.New(
|
||||||
"is not syslog+udp, syslog+tcp or syslog+tls with a host and a port, " +
|
"is not syslog+udp, syslog+tcp or syslog+tls with a host and a port, " +
|
||||||
"and nothing more, such as syslog+tls://logs.example:6514")
|
"and nothing more, such as syslog+tls://logs.example:6514",
|
||||||
|
)
|
||||||
errNoCertificate = errors.New("holds no PEM certificate")
|
errNoCertificate = errors.New("holds no PEM certificate")
|
||||||
errNotFacility = errors.New("is not a syslog facility such as local0 or daemon")
|
errNotFacility = errors.New("is not a syslog facility such as local0 or daemon")
|
||||||
errNotAppName = errors.New(
|
errNotAppName = errors.New(
|
||||||
@@ -421,16 +424,21 @@ var (
|
|||||||
"is not a header name followed by : and the header's value, " +
|
"is not a header name followed by : and the header's value, " +
|
||||||
"such as Authorization:Bearer <token>")
|
"such as Authorization:Bearer <token>")
|
||||||
errControlCharacter = errors.New(
|
errControlCharacter = errors.New(
|
||||||
"holds a control character, such as the carriage return of a Windows line end")
|
"holds a control character, such as the carriage return of a Windows line end",
|
||||||
|
)
|
||||||
errNotAlertEvent = errors.New(
|
errNotAlertEvent = errors.New(
|
||||||
"is not ban, permanent_ban, waf_block, anomaly, reputation_hit, " +
|
"is not ban, permanent_ban, waf_block, anomaly, reputation_hit, " +
|
||||||
"source_failure or file_error")
|
"source_failure or file_error")
|
||||||
errNotNumberOrOff = errors.New("is not a whole number above zero, such as 60, or off")
|
errNotNumberOrOff = errors.New(
|
||||||
|
"is not a whole number above zero, such as 60, or off",
|
||||||
|
)
|
||||||
errNotUTF8 = errors.New("is not valid UTF-8")
|
errNotUTF8 = errors.New("is not valid UTF-8")
|
||||||
errNotASN = errors.New("is not an AS number such as AS64496")
|
errNotASN = errors.New("is not an AS number such as AS64496")
|
||||||
errNotPercentItem = errors.New(
|
errNotPercentItem = errors.New(
|
||||||
"is not a code, : and a percentage, such as AS64496:50 or cn:25")
|
"is not a code, : and a percentage, such as AS64496:50 or cn:25")
|
||||||
errNotPercent = errors.New("is not a percentage, a whole number from 0 to 100")
|
errNotPercent = errors.New(
|
||||||
|
"is not a percentage, a whole number from 0 to 100",
|
||||||
|
)
|
||||||
errListedTwice = errors.New("is listed twice")
|
errListedTwice = errors.New("is listed twice")
|
||||||
errNotListURL = errors.New(
|
errNotListURL = errors.New(
|
||||||
"is not an http or https URL without a user or a fragment, " +
|
"is not an http or https URL without a user or a fragment, " +
|
||||||
@@ -439,11 +447,17 @@ var (
|
|||||||
errNotAnHourOrMore = errors.New("is not a duration of 1h or more, such as 24h")
|
errNotAnHourOrMore = errors.New("is not a duration of 1h or more, such as 24h")
|
||||||
errNotAction = errors.New(
|
errNotAction = errors.New(
|
||||||
"is not deny, limit:<percent> such as limit:25, or log")
|
"is not deny, limit:<percent> such as limit:25, or log")
|
||||||
errNotZone = errors.New("is not a DNS zone such as dnsbl.dronebl.org")
|
errNotZone = errors.New(
|
||||||
errZoneTooLong = errors.New("is longer than 189 characters, too long for the " +
|
"is not a DNS zone such as dnsbl.dronebl.org",
|
||||||
"names IPv6 clients are asked about by")
|
)
|
||||||
errNotResolver = errors.New("is not an IP address with an optional port, " +
|
errZoneTooLong = errors.New(
|
||||||
"such as 192.0.2.53 or [2001:db8::53]:5353")
|
"is longer than 189 characters, too long for the " +
|
||||||
|
"names IPv6 clients are asked about by",
|
||||||
|
)
|
||||||
|
errNotResolver = errors.New(
|
||||||
|
"is not an IP address with an optional port, " +
|
||||||
|
"such as 192.0.2.53 or [2001:db8::53]:5353",
|
||||||
|
)
|
||||||
errNotLAPIURL = errors.New(
|
errNotLAPIURL = errors.New(
|
||||||
"is not an http or https URL without a user or a fragment, " +
|
"is not an http or https URL without a user or a fragment, " +
|
||||||
"such as http://172.17.0.1:8080")
|
"such as http://172.17.0.1:8080")
|
||||||
@@ -460,7 +474,9 @@ var (
|
|||||||
// setting that is set but invalid is an error that names it.
|
// setting that is set but invalid is an error that names it.
|
||||||
//
|
//
|
||||||
//nolint:funlen // one line for each setting, a list that grows with them
|
//nolint:funlen // one line for each setting, a list that grows with them
|
||||||
func FromEnvironment(lookupEnv func(string) (string, bool)) (*Config, error) {
|
func FromEnvironment(
|
||||||
|
lookupEnv func(string) (string, bool),
|
||||||
|
) (*Config, error) {
|
||||||
env := &environment{lookupEnv: lookupEnv}
|
env := &environment{lookupEnv: lookupEnv}
|
||||||
cfg := &Config{
|
cfg := &Config{
|
||||||
ListenAddr: env.address("SWW_LISTEN_ADDR"),
|
ListenAddr: env.address("SWW_LISTEN_ADDR"),
|
||||||
@@ -658,7 +674,10 @@ func (e *environment) lookup(name string) (string, bool) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if set {
|
if set {
|
||||||
e.check(name, fmt.Errorf("is set, and so is %s; %w", fileName, errSetTwice))
|
e.check(
|
||||||
|
name,
|
||||||
|
fmt.Errorf("is set, and so is %s; %w", fileName, errSetTwice),
|
||||||
|
)
|
||||||
|
|
||||||
return value, set
|
return value, set
|
||||||
}
|
}
|
||||||
@@ -916,7 +935,10 @@ func (e *environment) zones(name string) []string {
|
|||||||
logged[i] = MaskZoneKey(zone)
|
logged[i] = MaskZoneKey(zone)
|
||||||
}
|
}
|
||||||
|
|
||||||
e.settings = append(e.settings, slog.String(name, strings.Join(logged, ",")))
|
e.settings = append(
|
||||||
|
e.settings,
|
||||||
|
slog.String(name, strings.Join(logged, ",")),
|
||||||
|
)
|
||||||
|
|
||||||
return zones
|
return zones
|
||||||
}
|
}
|
||||||
@@ -1058,7 +1080,9 @@ func (e *environment) headerNames(name, defaultValue string) []string {
|
|||||||
|
|
||||||
// durationNotOff reads a setting that is a duration and, unlike a
|
// durationNotOff reads a setting that is a duration and, unlike a
|
||||||
// timeout, cannot be off.
|
// timeout, cannot be off.
|
||||||
func (e *environment) durationNotOff(name, defaultValue string) time.Duration {
|
func (e *environment) durationNotOff(
|
||||||
|
name, defaultValue string,
|
||||||
|
) time.Duration {
|
||||||
duration, err := ParseDurationNotOff(e.value(name, defaultValue))
|
duration, err := ParseDurationNotOff(e.value(name, defaultValue))
|
||||||
e.check(name, err)
|
e.check(name, err)
|
||||||
|
|
||||||
@@ -1257,7 +1281,10 @@ func (e *environment) instanceName() string {
|
|||||||
// appName reads the setting that is the APP-NAME of the records the log
|
// appName reads the setting that is the APP-NAME of the records the log
|
||||||
// lines are sent in, by default the instance name. Its value is checked
|
// lines are sent in, by default the instance name. Its value is checked
|
||||||
// when it is set, and, while lines are sent, when it is the instance name.
|
// when it is set, and, while lines are sent, when it is the instance name.
|
||||||
func (e *environment) appName(name, instanceName string, sending bool) string {
|
func (e *environment) appName(
|
||||||
|
name, instanceName string,
|
||||||
|
sending bool,
|
||||||
|
) string {
|
||||||
value, set := e.lookup(name)
|
value, set := e.lookup(name)
|
||||||
if !set {
|
if !set {
|
||||||
value = instanceName
|
value = instanceName
|
||||||
@@ -1280,7 +1307,10 @@ func (e *environment) appName(name, instanceName string, sending bool) string {
|
|||||||
// checkInstanceNameForNtfy refuses an instance name that holds a control
|
// checkInstanceNameForNtfy refuses an instance name that holds a control
|
||||||
// character while ntfySet, SWW_ALERT_NTFY_URL being set: ntfy is sent
|
// character while ntfySet, SWW_ALERT_NTFY_URL being set: ntfy is sent
|
||||||
// the instance name in a header, which cannot hold one.
|
// the instance name in a header, which cannot hold one.
|
||||||
func (e *environment) checkInstanceNameForNtfy(instanceName string, ntfySet bool) {
|
func (e *environment) checkInstanceNameForNtfy(
|
||||||
|
instanceName string,
|
||||||
|
ntfySet bool,
|
||||||
|
) {
|
||||||
if ntfySet && strings.ContainsFunc(instanceName, unicode.IsControl) {
|
if ntfySet && strings.ContainsFunc(instanceName, unicode.IsControl) {
|
||||||
e.check("SWW_INSTANCE_NAME", fmt.Errorf(
|
e.check("SWW_INSTANCE_NAME", fmt.Errorf(
|
||||||
"%q %w, and is sent to ntfy in a header while SWW_ALERT_NTFY_URL is set",
|
"%q %w, and is sent to ntfy in a header while SWW_ALERT_NTFY_URL is set",
|
||||||
@@ -1604,7 +1634,10 @@ func parseNamedNetblocks(value string) ([]anomaly.NamedNetblock, error) {
|
|||||||
return nil, fmt.Errorf("%q %w", name, errListedTwice)
|
return nil, fmt.Errorf("%q %w", name, errListedTwice)
|
||||||
}
|
}
|
||||||
|
|
||||||
named = append(named, anomaly.NamedNetblock{Name: name, Netblock: netblock})
|
named = append(
|
||||||
|
named,
|
||||||
|
anomaly.NamedNetblock{Name: name, Netblock: netblock},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return named, nil
|
return named, nil
|
||||||
@@ -1843,8 +1876,11 @@ func parseHeaderNames(value string) ([]string, error) {
|
|||||||
header := strings.ToLower(item)
|
header := strings.ToLower(item)
|
||||||
switch header {
|
switch header {
|
||||||
case "host":
|
case "host":
|
||||||
return nil, fmt.Errorf("%q %w; the request's host is the field host",
|
return nil, fmt.Errorf(
|
||||||
item, errHeaderTakenOut)
|
"%q %w; the request's host is the field host",
|
||||||
|
item,
|
||||||
|
errHeaderTakenOut,
|
||||||
|
)
|
||||||
case "transfer-encoding":
|
case "transfer-encoding":
|
||||||
return nil, fmt.Errorf("%q %w", item, errHeaderTakenOut)
|
return nil, fmt.Errorf("%q %w", item, errHeaderTakenOut)
|
||||||
}
|
}
|
||||||
@@ -1881,9 +1917,11 @@ func parseUpstreamURL(value string) (*url.URL, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onlySchemeAndHost := (upstream.Scheme == "http" || upstream.Scheme == "https") &&
|
onlySchemeAndHost := (upstream.Scheme == "http" || upstream.Scheme == "https") &&
|
||||||
upstream.Hostname() != "" && upstream.User == nil && upstream.Opaque == "" &&
|
upstream.Hostname() != "" && upstream.User == nil &&
|
||||||
|
upstream.Opaque == "" &&
|
||||||
(upstream.Path == "" || upstream.Path == "/") &&
|
(upstream.Path == "" || upstream.Path == "/") &&
|
||||||
upstream.RawQuery == "" && upstream.Fragment == ""
|
upstream.RawQuery == "" &&
|
||||||
|
upstream.Fragment == ""
|
||||||
if !onlySchemeAndHost {
|
if !onlySchemeAndHost {
|
||||||
return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL)
|
return nil, fmt.Errorf("%q %w", value, errNotUpstreamURL)
|
||||||
}
|
}
|
||||||
@@ -1907,7 +1945,11 @@ func parseLogRemoteURL(value string) (*url.URL, error) {
|
|||||||
return nil, fmt.Errorf("%q %w", value, errNotLogRemoteURL)
|
return nil, fmt.Errorf("%q %w", value, errNotLogRemoteURL)
|
||||||
}
|
}
|
||||||
|
|
||||||
schemes := []string{remotelog.SchemeUDP, remotelog.SchemeTCP, remotelog.SchemeTLS}
|
schemes := []string{
|
||||||
|
remotelog.SchemeUDP,
|
||||||
|
remotelog.SchemeTCP,
|
||||||
|
remotelog.SchemeTLS,
|
||||||
|
}
|
||||||
port, err := strconv.ParseUint(remote.Port(), 10, 16)
|
port, err := strconv.ParseUint(remote.Port(), 10, 16)
|
||||||
|
|
||||||
onlySchemeHostAndPort := slices.Contains(schemes, remote.Scheme) &&
|
onlySchemeHostAndPort := slices.Contains(schemes, remote.Scheme) &&
|
||||||
@@ -1970,7 +2012,9 @@ func isHTTPURL(u *url.URL) bool {
|
|||||||
|
|
||||||
return (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" &&
|
return (u.Scheme == "http" || u.Scheme == "https") && u.Hostname() != "" &&
|
||||||
(u.Port() == "" || (err == nil && port != 0)) &&
|
(u.Port() == "" || (err == nil && port != 0)) &&
|
||||||
u.User == nil && u.Opaque == "" && u.Fragment == ""
|
u.User == nil &&
|
||||||
|
u.Opaque == "" &&
|
||||||
|
u.Fragment == ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// isListURL reports whether value is a URL a list can be fetched from, as
|
// isListURL reports whether value is a URL a list can be fetched from, as
|
||||||
@@ -2037,9 +2081,12 @@ func parseZones(value string) ([]string, error) {
|
|||||||
|
|
||||||
for i, zone := range zones {
|
for i, zone := range zones {
|
||||||
shown := MaskZoneKey(zone)
|
shown := MaskZoneKey(zone)
|
||||||
listedBefore := slices.ContainsFunc(zones[:i], func(earlier string) bool {
|
listedBefore := slices.ContainsFunc(
|
||||||
|
zones[:i],
|
||||||
|
func(earlier string) bool {
|
||||||
return strings.EqualFold(MaskZoneKey(earlier), shown)
|
return strings.EqualFold(MaskZoneKey(earlier), shown)
|
||||||
})
|
},
|
||||||
|
)
|
||||||
|
|
||||||
switch {
|
switch {
|
||||||
case len(zone) > maxZoneLength:
|
case len(zone) > maxZoneLength:
|
||||||
@@ -2126,8 +2173,13 @@ func parseWebhookHeaders(value string) (http.Header, string, error) {
|
|||||||
name, headerValue, found := strings.Cut(item, ":")
|
name, headerValue, found := strings.Cut(item, ":")
|
||||||
name = strings.TrimSpace(name)
|
name = strings.TrimSpace(name)
|
||||||
|
|
||||||
if !found || !IsHeaderName(name) || strings.ContainsAny(headerValue, "\r\n\x00") {
|
if !found || !IsHeaderName(name) ||
|
||||||
return nil, "", fmt.Errorf("item %d %w", i+1, errNotWebhookHeader)
|
strings.ContainsAny(headerValue, "\r\n\x00") {
|
||||||
|
return nil, "", fmt.Errorf(
|
||||||
|
"item %d %w",
|
||||||
|
i+1,
|
||||||
|
errNotWebhookHeader,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
headers.Add(name, strings.TrimSpace(headerValue))
|
headers.Add(name, strings.TrimSpace(headerValue))
|
||||||
|
|||||||
+14
-4
@@ -133,8 +133,11 @@ func (f *File) Watch(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
f.params.ProcessLog.Error("cannot watch the lookup database for replacements",
|
f.params.ProcessLog.Error(
|
||||||
"error", err.Error())
|
"cannot watch the lookup database for replacements",
|
||||||
|
"error",
|
||||||
|
err.Error(),
|
||||||
|
)
|
||||||
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -194,7 +197,10 @@ func (f *File) readAgain() {
|
|||||||
f.params.Alerts.Raise(alerts.Alert{
|
f.params.Alerts.Raise(alerts.Alert{
|
||||||
Event: alerts.EventFileError,
|
Event: alerts.EventFileError,
|
||||||
Reason: kept,
|
Reason: kept,
|
||||||
Detail: map[string]any{"file": f.params.Path, "error": err.Error()},
|
Detail: map[string]any{
|
||||||
|
"file": f.params.Path,
|
||||||
|
"error": err.Error(),
|
||||||
|
},
|
||||||
})
|
})
|
||||||
f.params.ProcessLog.Error(kept, "error", err.Error())
|
f.params.ProcessLog.Error(kept, "error", err.Error())
|
||||||
|
|
||||||
@@ -212,7 +218,11 @@ func (f *File) use(reader *maxminddb.Reader) {
|
|||||||
f.lastRead = f.params.Now()
|
f.lastRead = f.params.Now()
|
||||||
f.mu.Unlock()
|
f.mu.Unlock()
|
||||||
|
|
||||||
f.params.ProcessLog.Info("read the lookup database", "file", f.params.Path)
|
f.params.ProcessLog.Info(
|
||||||
|
"read the lookup database",
|
||||||
|
"file",
|
||||||
|
f.params.Path,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// read reads the lookup database at path. The whole file is read into
|
// read reads the lookup database at path. The whole file is read into
|
||||||
|
|||||||
@@ -19,27 +19,34 @@ const (
|
|||||||
// ActionForward is a request discriminator allowed, which rproxy passes to
|
// ActionForward is a request discriminator allowed, which rproxy passes to
|
||||||
// the app.
|
// the app.
|
||||||
ActionForward = "forward"
|
ActionForward = "forward"
|
||||||
|
|
||||||
// ActionTooLarge is a request over the header limits.
|
// ActionTooLarge is a request over the header limits.
|
||||||
ActionTooLarge = "too_large"
|
ActionTooLarge = "too_large"
|
||||||
|
|
||||||
// ActionRateLimited is a request refused because it took its client
|
// ActionRateLimited is a request refused because it took its client
|
||||||
// over a rate limit, which bans the client.
|
// over a rate limit, which bans the client.
|
||||||
ActionRateLimited = "rate_limited"
|
ActionRateLimited = "rate_limited"
|
||||||
|
|
||||||
// ActionBanned is a request refused because a ban covers its client,
|
// ActionBanned is a request refused because a ban covers its client,
|
||||||
// or because it matched a ban rule, asked for a trap path or the
|
// or because it matched a ban rule, asked for a trap path or the
|
||||||
// CrowdSec decision list lists its client, each of which bans the
|
// CrowdSec decision list lists its client, each of which bans the
|
||||||
// client.
|
// client.
|
||||||
ActionBanned = "banned"
|
ActionBanned = "banned"
|
||||||
|
|
||||||
// ActionRuleBlocked is a request refused because it matched a block
|
// ActionRuleBlocked is a request refused because it matched a block
|
||||||
// rule.
|
// rule.
|
||||||
ActionRuleBlocked = "rule_blocked"
|
ActionRuleBlocked = "rule_blocked"
|
||||||
|
|
||||||
// ActionWAFBlocked is a request refused because the Core Rule Set
|
// ActionWAFBlocked is a request refused because the Core Rule Set
|
||||||
// scored it at or over SWW_WAF_ANOMALY_THRESHOLD.
|
// scored it at or over SWW_WAF_ANOMALY_THRESHOLD.
|
||||||
ActionWAFBlocked = "waf_blocked"
|
ActionWAFBlocked = "waf_blocked"
|
||||||
|
|
||||||
// ActionDenied is a request refused because its client is in
|
// ActionDenied is a request refused because its client is in
|
||||||
// SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny,
|
// SWW_DENY_NETS, in a blocklist while SWW_BLOCKLIST_ACTION is deny,
|
||||||
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
|
// or listed by a DNSBL zone, or scored a hit by AbuseIPDB, while
|
||||||
// SWW_REPUTATION_ACTION is deny.
|
// SWW_REPUTATION_ACTION is deny.
|
||||||
ActionDenied = "denied"
|
ActionDenied = "denied"
|
||||||
|
|
||||||
// ActionCountryDenied is a request refused for its client's country.
|
// ActionCountryDenied is a request refused for its client's country.
|
||||||
ActionCountryDenied = "country_denied"
|
ActionCountryDenied = "country_denied"
|
||||||
// ActionAdmin is a request discriminator answered at one of its own
|
// ActionAdmin is a request discriminator answered at one of its own
|
||||||
@@ -255,7 +262,11 @@ func FormatTime(t time.Time) string {
|
|||||||
|
|
||||||
// Milliseconds is d in milliseconds, to the microsecond.
|
// Milliseconds is d in milliseconds, to the microsecond.
|
||||||
func Milliseconds(d time.Duration) float64 {
|
func Milliseconds(d time.Duration) float64 {
|
||||||
return float64(d.Microseconds()) / float64(time.Millisecond/time.Microsecond)
|
return float64(
|
||||||
|
d.Microseconds(),
|
||||||
|
) / float64(
|
||||||
|
time.Millisecond/time.Microsecond,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProcessLogger returns the logger for the process's own messages:
|
// NewProcessLogger returns the logger for the process's own messages:
|
||||||
@@ -263,17 +274,25 @@ func Milliseconds(d time.Duration) float64 {
|
|||||||
// as a request line's, and instanceName, SWW_INSTANCE_NAME, as instance.
|
// as a request line's, and instanceName, SWW_INSTANCE_NAME, as instance.
|
||||||
// It writes only the messages at level, SWW_LOG_LEVEL, or more severe;
|
// It writes only the messages at level, SWW_LOG_LEVEL, or more severe;
|
||||||
// the request lines Write writes are never held back.
|
// the request lines Write writes are never held back.
|
||||||
func NewProcessLogger(w io.Writer, instanceName string, level slog.Level) *slog.Logger {
|
func NewProcessLogger(
|
||||||
|
w io.Writer,
|
||||||
|
instanceName string,
|
||||||
|
level slog.Level,
|
||||||
|
) *slog.Logger {
|
||||||
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
|
handler := slog.NewJSONHandler(w, &slog.HandlerOptions{
|
||||||
Level: level,
|
Level: level,
|
||||||
ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr {
|
ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr {
|
||||||
if attr.Key == slog.TimeKey && len(groups) == 0 {
|
if attr.Key == slog.TimeKey && len(groups) == 0 {
|
||||||
return slog.String(slog.TimeKey, FormatTime(attr.Value.Time()))
|
return slog.String(
|
||||||
|
slog.TimeKey,
|
||||||
|
FormatTime(attr.Value.Time()),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return attr
|
return attr
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
return slog.New(handler).With("type", "process", "instance", instanceName)
|
return slog.New(handler).
|
||||||
|
With("type", "process", "instance", instanceName)
|
||||||
}
|
}
|
||||||
Executable
+54
@@ -0,0 +1,54 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# script/publish: build the image on the Docker host and push it to the
|
||||||
|
# registry under four tags: the UTC date (YYYY-MM-DD), the short commit
|
||||||
|
# hash, the branch name and latest. The build is script/docker's, so the
|
||||||
|
# tests and the linter run first. The host and the registry name are
|
||||||
|
# environment variables with defaults, for running it against another.
|
||||||
|
#
|
||||||
|
# The working tree must be clean: the hash tag names a commit, and an
|
||||||
|
# image built from uncommitted changes would carry a hash that does not
|
||||||
|
# describe it.
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd -P)"
|
||||||
|
ROOT="$(cd "$SCRIPT_DIR/.." && pwd -P)"
|
||||||
|
|
||||||
|
: "${DOCKER_HOST:=ssh://root@fsn1app1}"
|
||||||
|
: "${PUBLISH_IMAGE:=git.eeqj.de/sneak/smallwebwaf}"
|
||||||
|
export DOCKER_HOST
|
||||||
|
|
||||||
|
main() {
|
||||||
|
cd "$ROOT"
|
||||||
|
|
||||||
|
# Own line: a failing command substitution inside an argument does
|
||||||
|
# not trip `set -e`.
|
||||||
|
changes="$(git status --porcelain)"
|
||||||
|
if [ -n "$changes" ]; then
|
||||||
|
echo "script/publish: the working tree is not clean; commit or stash first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
date="$(date -u +%Y-%m-%d)"
|
||||||
|
hash="$(git rev-parse --short HEAD)"
|
||||||
|
branch="$(git rev-parse --abbrev-ref HEAD)"
|
||||||
|
if [ "$branch" = "HEAD" ]; then
|
||||||
|
echo "script/publish: detached HEAD; check out a branch first" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
# A tag is [A-Za-z0-9_.-]; a branch name such as feature/x is not.
|
||||||
|
branch="$(printf '%s' "$branch" | tr -c 'A-Za-z0-9_.-' '-')"
|
||||||
|
|
||||||
|
local_tag="$("$SCRIPT_DIR/projectname")"
|
||||||
|
"$SCRIPT_DIR/docker"
|
||||||
|
|
||||||
|
for tag in "$date" "$hash" "$branch" latest; do
|
||||||
|
docker tag "$local_tag" "$PUBLISH_IMAGE:$tag"
|
||||||
|
done
|
||||||
|
for tag in "$date" "$hash" "$branch" latest; do
|
||||||
|
docker push "$PUBLISH_IMAGE:$tag"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo "pushed $PUBLISH_IMAGE with tags: $date $hash $branch latest"
|
||||||
|
}
|
||||||
|
|
||||||
|
main "$@"
|
||||||
Reference in new issue
Block a user