diff --git a/Makefile b/Makefile index 6f3d484..b99e841 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,12 @@ -.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker hooks build \ - run example-app +.PHONY: bootstrap setup test lint fmt fmt-check tidy check docker publish \ + hooks build run example-app # Makefile targets are thin shims; the implementations live in script/ # 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, # 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: @script/bootstrap @@ -34,6 +35,9 @@ check: docker: @script/docker +publish: + @script/publish + hooks: @script/install-precommit diff --git a/README.md b/README.md index 5795df3..e38335b 100644 --- a/README.md +++ b/README.md @@ -68,8 +68,9 @@ Not built, and not planned: ### The image -No image is published. Build it from a clone. `make docker` runs the tests and -the linter, then builds the image, tagged `smallwebwaf`. +The image is published at `git.eeqj.de/sneak/smallwebwaf`. You can also build it +from a clone. `make docker` runs the tests and the linter, then builds the +image, tagged `smallwebwaf`. ```sh git clone https://git.eeqj.de/sneak/smallwebwaf.git @@ -665,6 +666,15 @@ on the host. `make check`. - `script/docker`: builds the image, whose build runs the tests and the linter 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 image build. - `script/precommit`: run by the git pre-commit hook; runs `script/check`. diff --git a/internal/config/config.go b/internal/config/config.go index 3a5ad3e..d19a26c 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -397,18 +397,21 @@ var ( errNotPathPrefix = errors.New( "is not a path prefix starting with /, such as /assets/") 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") errNotParanoiaLevel = errors.New("is not a paranoia level, from 1 to 4") 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( "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") errNotBoolean = errors.New("is not true or false") errNotLogRemoteURL = errors.New( "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") errNotFacility = errors.New("is not a syslog facility such as local0 or daemon") errNotAppName = errors.New( @@ -421,16 +424,21 @@ var ( "is not a header name followed by : and the header's value, " + "such as Authorization:Bearer ") 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( "is not ban, permanent_ban, waf_block, anomaly, reputation_hit, " + "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") errNotASN = errors.New("is not an AS number such as AS64496") errNotPercentItem = errors.New( "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") errNotListURL = errors.New( "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") errNotAction = errors.New( "is not deny, limit: such as limit:25, or log") - errNotZone = errors.New("is not a DNS zone such as dnsbl.dronebl.org") - errZoneTooLong = errors.New("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") + errNotZone = errors.New( + "is not a DNS zone such as dnsbl.dronebl.org", + ) + errZoneTooLong = errors.New( + "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( "is not an http or https URL without a user or a fragment, " + "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. // //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} cfg := &Config{ ListenAddr: env.address("SWW_LISTEN_ADDR"), @@ -658,7 +674,10 @@ func (e *environment) lookup(name string) (string, bool) { } 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 } @@ -916,7 +935,10 @@ func (e *environment) zones(name string) []string { 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 } @@ -1058,7 +1080,9 @@ func (e *environment) headerNames(name, defaultValue string) []string { // durationNotOff reads a setting that is a duration and, unlike a // 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)) 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 // 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. -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) if !set { 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 // character while ntfySet, SWW_ALERT_NTFY_URL being set: ntfy is sent // 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) { e.check("SWW_INSTANCE_NAME", fmt.Errorf( "%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) } - named = append(named, anomaly.NamedNetblock{Name: name, Netblock: netblock}) + named = append( + named, + anomaly.NamedNetblock{Name: name, Netblock: netblock}, + ) } return named, nil @@ -1843,8 +1876,11 @@ func parseHeaderNames(value string) ([]string, error) { header := strings.ToLower(item) switch header { case "host": - return nil, fmt.Errorf("%q %w; the request's host is the field host", - item, errHeaderTakenOut) + return nil, fmt.Errorf( + "%q %w; the request's host is the field host", + item, + errHeaderTakenOut, + ) case "transfer-encoding": 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") && - upstream.Hostname() != "" && upstream.User == nil && upstream.Opaque == "" && + upstream.Hostname() != "" && upstream.User == nil && + upstream.Opaque == "" && (upstream.Path == "" || upstream.Path == "/") && - upstream.RawQuery == "" && upstream.Fragment == "" + upstream.RawQuery == "" && + upstream.Fragment == "" if !onlySchemeAndHost { 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) } - schemes := []string{remotelog.SchemeUDP, remotelog.SchemeTCP, remotelog.SchemeTLS} + schemes := []string{ + remotelog.SchemeUDP, + remotelog.SchemeTCP, + remotelog.SchemeTLS, + } port, err := strconv.ParseUint(remote.Port(), 10, 16) 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() != "" && (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 @@ -2037,9 +2081,12 @@ func parseZones(value string) ([]string, error) { for i, zone := range zones { shown := MaskZoneKey(zone) - listedBefore := slices.ContainsFunc(zones[:i], func(earlier string) bool { - return strings.EqualFold(MaskZoneKey(earlier), shown) - }) + listedBefore := slices.ContainsFunc( + zones[:i], + func(earlier string) bool { + return strings.EqualFold(MaskZoneKey(earlier), shown) + }, + ) switch { case len(zone) > maxZoneLength: @@ -2126,8 +2173,13 @@ func parseWebhookHeaders(value string) (http.Header, string, error) { name, headerValue, found := strings.Cut(item, ":") name = strings.TrimSpace(name) - if !found || !IsHeaderName(name) || strings.ContainsAny(headerValue, "\r\n\x00") { - return nil, "", fmt.Errorf("item %d %w", i+1, errNotWebhookHeader) + if !found || !IsHeaderName(name) || + strings.ContainsAny(headerValue, "\r\n\x00") { + return nil, "", fmt.Errorf( + "item %d %w", + i+1, + errNotWebhookHeader, + ) } headers.Add(name, strings.TrimSpace(headerValue)) diff --git a/internal/lookup/file.go b/internal/lookup/file.go index 841f5cc..5cf7c72 100644 --- a/internal/lookup/file.go +++ b/internal/lookup/file.go @@ -133,8 +133,11 @@ func (f *File) Watch(ctx context.Context) { } if err != nil { - f.params.ProcessLog.Error("cannot watch the lookup database for replacements", - "error", err.Error()) + f.params.ProcessLog.Error( + "cannot watch the lookup database for replacements", + "error", + err.Error(), + ) return } @@ -194,7 +197,10 @@ func (f *File) readAgain() { f.params.Alerts.Raise(alerts.Alert{ Event: alerts.EventFileError, 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()) @@ -212,7 +218,11 @@ func (f *File) use(reader *maxminddb.Reader) { f.lastRead = f.params.Now() 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 diff --git a/internal/requestlog/requestlog.go b/internal/requestlog/requestlog.go index cb33185..5ad8208 100644 --- a/internal/requestlog/requestlog.go +++ b/internal/requestlog/requestlog.go @@ -19,27 +19,34 @@ const ( // ActionForward is a request discriminator allowed, which rproxy passes to // the app. ActionForward = "forward" + // ActionTooLarge is a request over the header limits. ActionTooLarge = "too_large" + // ActionRateLimited is a request refused because it took its client // over a rate limit, which bans the client. ActionRateLimited = "rate_limited" + // 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 // CrowdSec decision list lists its client, each of which bans the // client. ActionBanned = "banned" + // ActionRuleBlocked is a request refused because it matched a block // rule. ActionRuleBlocked = "rule_blocked" + // ActionWAFBlocked is a request refused because the Core Rule Set // scored it at or over SWW_WAF_ANOMALY_THRESHOLD. ActionWAFBlocked = "waf_blocked" + // ActionDenied is a request refused because its client is in // 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 // SWW_REPUTATION_ACTION is deny. ActionDenied = "denied" + // ActionCountryDenied is a request refused for its client's country. ActionCountryDenied = "country_denied" // 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. 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: @@ -263,17 +274,25 @@ func Milliseconds(d time.Duration) float64 { // 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; // 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{ Level: level, ReplaceAttr: func(groups []string, attr slog.Attr) slog.Attr { 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 slog.New(handler).With("type", "process", "instance", instanceName) + return slog.New(handler). + With("type", "process", "instance", instanceName) } diff --git a/script/publish b/script/publish new file mode 100755 index 0000000..614ff19 --- /dev/null +++ b/script/publish @@ -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 "$@"