Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fb647e8be | ||
|
|
cff7b07bbc | ||
|
|
e628dfe6cf | ||
|
|
cbcde098dc |
@@ -230,6 +230,14 @@ the numbers: its file, if it was created, is removed. The file stays, counted
|
|||||||
toward `DATA_DIR_MAX_BYTES` from the next start, only if removing it fails too.
|
toward `DATA_DIR_MAX_BYTES` from the next start, only if removing it fails too.
|
||||||
Files are created with `O_EXCL` to prevent overwrites.
|
Files are created with `O_EXCL` to prevent overwrites.
|
||||||
|
|
||||||
|
The reports of a failed write stay in memory, and the next flush, within a
|
||||||
|
minute, tries again to write them. From the failed write until a write succeeds,
|
||||||
|
`POST /api/v1/reports` refuses every report with 500 and keeps nothing of it, so
|
||||||
|
the page keeps its samples and sends them again at its next report. Reports
|
||||||
|
already answered 200 are lost only if the server stops before a write succeeds;
|
||||||
|
the final flush at stop tries them too, and if it fails, the process exits
|
||||||
|
non-zero.
|
||||||
|
|
||||||
#### Report limits
|
#### Report limits
|
||||||
|
|
||||||
`POST /api/v1/reports` takes reports from anyone who can reach it, without
|
`POST /api/v1/reports` takes reports from anyone who can reach it, without
|
||||||
@@ -254,16 +262,16 @@ credentials, so it is bounded instead. Each refusal below answers with the same
|
|||||||
`X-RateLimit-Reset` headers.
|
`X-RateLimit-Reset` headers.
|
||||||
- **Size cap.** The report files in `DATA_DIR` may total at most
|
- **Size cap.** The report files in `DATA_DIR` may total at most
|
||||||
`DATA_DIR_MAX_BYTES`, counting the files already there at start. Reports
|
`DATA_DIR_MAX_BYTES`, counting the files already there at start. Reports
|
||||||
waiting in memory count at their uncompressed size until they are written;
|
waiting in memory count at their uncompressed size until they are written,
|
||||||
those lost to a failed write stop counting, and the part of its file written
|
those of a failed write included; the part of its file written is removed.
|
||||||
is removed. When a report would take the total past the cap, the oldest report
|
When a report would take the total past the cap, the oldest report files are
|
||||||
files are deleted to make room, and each deletion is logged with the file's
|
deleted to make room, and each deletion is logged with the file's name and
|
||||||
name and size; a file still being written is never deleted. A report is
|
size; a file still being written is never deleted. A report is refused with
|
||||||
refused with 507, and nothing of it is stored, only when the reports waiting
|
507, and nothing of it is stored, only when the reports waiting to be written
|
||||||
to be written fill the cap on their own, and then no file is deleted. At
|
fill the cap on their own, and then no file is deleted. At start, report files
|
||||||
start, report files past the cap, as after lowering it, are deleted the same
|
past the cap, as after lowering it, are deleted the same way. So the cap is
|
||||||
way. So the cap is how much of the newest reports is kept: the default of 1
|
how much of the newest reports is kept: the default of 1 GiB is small enough
|
||||||
GiB is small enough for any host; set it to the space you can give `DATA_DIR`.
|
for any host; set it to the space you can give `DATA_DIR`.
|
||||||
|
|
||||||
#### CORS
|
#### CORS
|
||||||
|
|
||||||
@@ -414,9 +422,10 @@ address nginx passes on and no other. The image:
|
|||||||
|
|
||||||
- Listens on port 8080 by default (override with `PORT` env var)
|
- Listens on port 8080 by default (override with `PORT` env var)
|
||||||
- Takes the client address from `X-Forwarded-For` only on requests from the
|
- Takes the client address from `X-Forwarded-For` only on requests from the
|
||||||
reverse proxies named in `TRUSTED_PROXIES`, and by default from none.
|
reverse proxies named in `TRUSTED_PROXIES`, by default from any address in
|
||||||
`bin/entrypoint.sh` checks each entry with `netwatch-server check-cidr`, which
|
`10.0.0.0/8`, `172.16.0.0/12` or `192.168.0.0/16`. `bin/entrypoint.sh` checks
|
||||||
parses it as the backend parses its own `TRUSTED_PROXIES`
|
each entry with `netwatch-server check-cidr`, which parses it as the backend
|
||||||
|
parses its own `TRUSTED_PROXIES`
|
||||||
- Sends access logs to stdout
|
- Sends access logs to stdout
|
||||||
- Caches static assets with immutable headers
|
- Caches static assets with immutable headers
|
||||||
- Sends the security headers `REPO_POLICIES.md` requires on every response, as
|
- Sends the security headers `REPO_POLICIES.md` requires on every response, as
|
||||||
@@ -435,9 +444,9 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for netwatch needs:
|
|||||||
|
|
||||||
- **Port:** container port `8080`.
|
- **Port:** container port `8080`.
|
||||||
- **Volume:** container path `/data`; the reports are kept in `/data/reports`.
|
- **Volume:** container path `/data`; the reports are kept in `/data/reports`.
|
||||||
- **Environment variables:** none is required. An empty one counts as unset, and
|
- **Environment variables:** none is required. An empty one counts as unset,
|
||||||
one set to a value netwatch cannot use stops the container at start, with the
|
except `TRUSTED_PROXIES`, and one set to a value netwatch cannot use stops the
|
||||||
reason in its log.
|
container at start, with the reason in its log.
|
||||||
- `PORT`, default `8080`: the container port, from 1 to 65535. `8081` cannot
|
- `PORT`, default `8080`: the container port, from 1 to 65535. `8081` cannot
|
||||||
be used: the backend listens on it inside the container
|
be used: the backend listens on it inside the container
|
||||||
- `REPORTS_PER_MINUTE`, default `60`: reports each client address may send a
|
- `REPORTS_PER_MINUTE`, default `60`: reports each client address may send a
|
||||||
@@ -451,16 +460,17 @@ What the [upaas](https://git.eeqj.de/sneak/upaas) app for netwatch needs:
|
|||||||
in: `/data` or a path below it, with no `.` or `..` part and no extra `/`.
|
in: `/data` or a path below it, with no `.` or `..` part and no extra `/`.
|
||||||
The container also stops if the path goes through a symbolic link that
|
The container also stops if the path goes through a symbolic link that
|
||||||
leads out of `/data` or is written as a full path
|
leads out of `/data` or is written as a full path
|
||||||
- `TRUSTED_PROXIES`, default empty: set it to the address the reverse proxy
|
- `TRUSTED_PROXIES`, default `10.0.0.0/8,172.16.0.0/12,192.168.0.0/16`: the
|
||||||
in front of the container connects from, as an IP address or CIDR; several
|
addresses the reverse proxy in front of the container connects from, as IP
|
||||||
are separated by commas. nginx takes the client address from
|
addresses or CIDRs separated by commas. nginx takes the client address
|
||||||
`X-Forwarded-For` only on a request from one of them, and the rate limit
|
from `X-Forwarded-For` only on a request from one of them, and the rate
|
||||||
counts that address. Unset, `X-Forwarded-For` is ignored and every client
|
limit counts that address. A list replaces the default. Set and empty,
|
||||||
behind the proxy shares the proxy's one allowance of `REPORTS_PER_MINUTE`.
|
`X-Forwarded-For` is ignored and every client behind the proxy shares the
|
||||||
Name only addresses nothing but the proxy connects from: any client that
|
proxy's one allowance of `REPORTS_PER_MINUTE`. Any client that connects
|
||||||
connects from one can write its own `X-Forwarded-For`, and through a port
|
from one of these addresses can write its own `X-Forwarded-For`, and
|
||||||
Docker publishes, every client may connect from the Docker network's
|
through a port Docker publishes, every client may connect from the Docker
|
||||||
gateway, such as `172.17.0.1`.
|
network's gateway, such as `172.17.0.1`, which the default includes; where
|
||||||
|
that matters, name only the addresses nothing but the proxy connects from.
|
||||||
- `METRICS_USERNAME` and `METRICS_PASSWORD`, default empty: with both set,
|
- `METRICS_USERNAME` and `METRICS_PASSWORD`, default empty: with both set,
|
||||||
the backend records Prometheus metrics of its requests and serves them at
|
the backend records Prometheus metrics of its requests and serves them at
|
||||||
`/metrics` on the container port, to requests with this user name and
|
`/metrics` on the container port, to requests with this user name and
|
||||||
|
|||||||
@@ -29,6 +29,29 @@ Run `make frontend-viewport-test` in CI as its own step; it is not part of
|
|||||||
parse still stops the start, and the image still starts the backend with
|
parse still stops the start, and the image still starts the backend with
|
||||||
`TRUSTED_PROXIES=127.0.0.1/32`. Tests load the config with it unset, empty and
|
`TRUSTED_PROXIES=127.0.0.1/32`. Tests load the config with it unset, empty and
|
||||||
set to a list
|
set to a list
|
||||||
|
- 2026-10-14: while report files cannot be written, `POST /api/v1/reports`
|
||||||
|
answers 500, and storing recovers once writing works again, with no restart
|
||||||
|
([#123](https://git.eeqj.de/sneak/netwatch/issues/123)). A failed write puts
|
||||||
|
its reports back in the report buffer, still counted against
|
||||||
|
`DATA_DIR_MAX_BYTES`, where they used to be lost; the next flush, within a
|
||||||
|
minute, tries them again. Until a write succeeds, every report is refused and
|
||||||
|
nothing of it is kept, so the buffer does not grow while writes fail. A test
|
||||||
|
posts reports through the report handler before, during and after a failure;
|
||||||
|
others check that a report appended while a write runs is kept, behind that
|
||||||
|
write's own, when the write fails, and that the cap still holds exactly once a
|
||||||
|
failed write's reports are written
|
||||||
|
- 2026-10-14: a request whose handler panics is logged with status 500 and
|
||||||
|
counted with `code` 500 in the metrics
|
||||||
|
([#125](https://git.eeqj.de/sneak/netwatch/issues/125)), not 200. `Recoverer`
|
||||||
|
is still the first middleware; `Write500OnPanic`, after the request log and
|
||||||
|
after the metrics, writes the 500 as the panic passes and lets it go on to
|
||||||
|
Sentry and `Recoverer`, which now writes a 500 only when no status was written
|
||||||
|
- 2026-10-14: with `TRUSTED_PROXIES` unset, the container's nginx trusts the
|
||||||
|
three private ranges of RFC 1918, `10.0.0.0/8`, `172.16.0.0/12` and
|
||||||
|
`192.168.0.0/16` ([#119](https://git.eeqj.de/sneak/netwatch/issues/119)), as
|
||||||
|
sneak ruled on 2026-09-22 for every app; it trusted no proxy before. A list
|
||||||
|
replaces them, and set and empty it trusts none. The backend inside the
|
||||||
|
container still trusts only `127.0.0.1/32`
|
||||||
- 2026-10-14: `POST /api/v1/reports` takes only a report sent with
|
- 2026-10-14: `POST /api/v1/reports` takes only a report sent with
|
||||||
`Content-Type: application/json`, parameters such as `charset` allowed, and
|
`Content-Type: application/json`, parameters such as `charset` allowed, and
|
||||||
refuses any other with 415 and the usual error body, storing nothing
|
refuses any other with 415 and the usual error body, storing nothing
|
||||||
|
|||||||
+6
-4
@@ -34,9 +34,11 @@ fi
|
|||||||
|
|
||||||
# TRUSTED_PROXIES names the reverse proxies in front of the container,
|
# TRUSTED_PROXIES names the reverse proxies in front of the container,
|
||||||
# as IP addresses or CIDRs separated by commas. nginx takes the client
|
# as IP addresses or CIDRs separated by commas. nginx takes the client
|
||||||
# address from X-Forwarded-For only on a request from one of them, so
|
# address from X-Forwarded-For only on a request from one of them.
|
||||||
# unset or empty, it trusts no one. configs/nginx.conf includes the file
|
# Unset, they are the three private ranges of RFC 1918; set and empty,
|
||||||
# written here, one set_real_ip_from line per entry.
|
# nginx trusts no one, which is why the default below follows - and not
|
||||||
|
# :-. configs/nginx.conf includes the file written here, one
|
||||||
|
# set_real_ip_from line per entry.
|
||||||
#
|
#
|
||||||
# nginx looks up an entry it cannot read as an address as a hostname,
|
# nginx looks up an entry it cannot read as an address as a hostname,
|
||||||
# and trusts what it finds (1.2.3 is found as 1.2.0.3). So each entry
|
# and trusts what it finds (1.2.3 is found as 1.2.0.3). So each entry
|
||||||
@@ -45,7 +47,7 @@ fi
|
|||||||
# TRUSTED_PROXIES. Its error, naming the CIDR, is dropped for the one
|
# TRUSTED_PROXIES. Its error, naming the CIDR, is dropped for the one
|
||||||
# below, naming the entry as written. set -f keeps a * in an entry from
|
# below, naming the entry as written. set -f keeps a * in an entry from
|
||||||
# becoming a list of file names.
|
# becoming a list of file names.
|
||||||
TRUSTED_PROXIES="${TRUSTED_PROXIES:-}"
|
TRUSTED_PROXIES="${TRUSTED_PROXIES-10.0.0.0/8,172.16.0.0/12,192.168.0.0/16}"
|
||||||
set -f
|
set -f
|
||||||
for proxy in $(printf '%s' "$TRUSTED_PROXIES" | tr ',' ' '); do
|
for proxy in $(printf '%s' "$TRUSTED_PROXIES" | tr ',' ' '); do
|
||||||
case "$proxy" in
|
case "$proxy" in
|
||||||
|
|||||||
+4
-3
@@ -18,9 +18,10 @@ server {
|
|||||||
|
|
||||||
# The client address comes from X-Forwarded-For only on a request
|
# The client address comes from X-Forwarded-For only on a request
|
||||||
# from the reverse proxies in TRUSTED_PROXIES: bin/entrypoint.sh
|
# from the reverse proxies in TRUSTED_PROXIES: bin/entrypoint.sh
|
||||||
# writes one set_real_ip_from line for each into this file, and
|
# writes one set_real_ip_from line for each into this file. Unset,
|
||||||
# leaves it empty when TRUSTED_PROXIES is unset, so that by default
|
# TRUSTED_PROXIES is the three private ranges of RFC 1918; set and
|
||||||
# the client address is the one each request comes from.
|
# empty, the file is empty and the client address is the one each
|
||||||
|
# request comes from.
|
||||||
include /etc/nginx/trusted-proxies.conf;
|
include /etc/nginx/trusted-proxies.conf;
|
||||||
real_ip_header X-Forwarded-For;
|
real_ip_header X-Forwarded-For;
|
||||||
real_ip_recursive on;
|
real_ip_recursive on;
|
||||||
|
|||||||
@@ -93,7 +93,7 @@ func (s *Handlers) decodeErrorStatus(err error) int {
|
|||||||
|
|
||||||
// appendErrorStatus logs a failure to store a report and returns
|
// appendErrorStatus logs a failure to store a report and returns
|
||||||
// the status to send: 507 when the reports waiting to be written fill
|
// the status to send: 507 when the reports waiting to be written fill
|
||||||
// the size cap, otherwise 500.
|
// the size cap, otherwise 500, as when report files cannot be written.
|
||||||
func (s *Handlers) appendErrorStatus(err error) int {
|
func (s *Handlers) appendErrorStatus(err error) int {
|
||||||
if errors.Is(err, reportbuf.ErrFull) {
|
if errors.Is(err, reportbuf.ErrFull) {
|
||||||
s.log.Warn("report refused: " +
|
s.log.Warn("report refused: " +
|
||||||
|
|||||||
@@ -297,12 +297,16 @@ func (s *Middleware) MaxBodyBytes(
|
|||||||
|
|
||||||
// Recoverer returns middleware that recovers from a panic in a
|
// Recoverer returns middleware that recovers from a panic in a
|
||||||
// downstream handler, logs the panic and stack trace through
|
// downstream handler, logs the panic and stack trace through
|
||||||
// slog, and responds 500 with no body. http.ErrAbortHandler is
|
// slog, and responds 500 with no body, unless a status was already
|
||||||
// re-panicked so the server can abort the response as intended.
|
// written, as Write500OnPanic writes one for a handler's panic.
|
||||||
|
// http.ErrAbortHandler is re-panicked so the server can abort the
|
||||||
|
// response as intended.
|
||||||
func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
|
func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(
|
return http.HandlerFunc(
|
||||||
func(w http.ResponseWriter, r *http.Request) {
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||||
|
|
||||||
defer func() {
|
defer func() {
|
||||||
rec := recover()
|
rec := recover()
|
||||||
if rec == nil {
|
if rec == nil {
|
||||||
@@ -320,10 +324,43 @@ func (s *Middleware) Recoverer() func(http.Handler) http.Handler {
|
|||||||
"stack", string(debug.Stack()),
|
"stack", string(debug.Stack()),
|
||||||
)
|
)
|
||||||
|
|
||||||
w.WriteHeader(http.StatusInternalServerError)
|
if ww.Status() == 0 {
|
||||||
|
ww.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
next.ServeHTTP(w, r)
|
next.ServeHTTP(ww, r)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write500OnPanic returns middleware that writes 500 when a downstream
|
||||||
|
// handler panics before writing a status, and lets the panic go on to
|
||||||
|
// Sentry and Recoverer. Recoverer, which comes first, would write its
|
||||||
|
// 500 only after the request log and the metrics had recorded the
|
||||||
|
// status they saw; placed after each of them, this makes them record
|
||||||
|
// the 500.
|
||||||
|
func (s *Middleware) Write500OnPanic() func(http.Handler) http.Handler {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(
|
||||||
|
func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
ww := middleware.NewWrapResponseWriter(w, r.ProtoMajor)
|
||||||
|
|
||||||
|
defer func() {
|
||||||
|
rec := recover()
|
||||||
|
if rec == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if ww.Status() == 0 {
|
||||||
|
ww.WriteHeader(http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
panic(rec)
|
||||||
|
}()
|
||||||
|
|
||||||
|
next.ServeHTTP(ww, r)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"bytes"
|
"bytes"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -16,7 +17,9 @@ import (
|
|||||||
"sneak.berlin/go/netwatch/internal/logger"
|
"sneak.berlin/go/netwatch/internal/logger"
|
||||||
"sneak.berlin/go/netwatch/internal/middleware"
|
"sneak.berlin/go/netwatch/internal/middleware"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
chimiddleware "github.com/go-chi/chi/v5/middleware"
|
||||||
|
"github.com/prometheus/client_golang/prometheus"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -323,6 +326,172 @@ func TestRecovererRepanicsOnAbortHandler(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestHandlerPanicIsLoggedAndCountedAs500 sends a request to a handler
|
||||||
|
// that panics through the middleware SetupRoutes puts around a route
|
||||||
|
// with metrics, in its order, leaving out those that write no status
|
||||||
|
// for a panic and Sentry, which TestSentry covers. The request log line
|
||||||
|
// and the metrics must give the 500 the client gets, and the panic must
|
||||||
|
// still reach Recoverer, which logs it.
|
||||||
|
func TestHandlerPanicIsLoggedAndCountedAs500(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var logbuf bytes.Buffer
|
||||||
|
|
||||||
|
mw := middleware.NewWithLogger(
|
||||||
|
slog.New(slog.NewJSONHandler(&logbuf, nil)),
|
||||||
|
)
|
||||||
|
registry := prometheus.NewRegistry()
|
||||||
|
|
||||||
|
handler := chi.Chain(
|
||||||
|
mw.Recoverer(),
|
||||||
|
chimiddleware.RequestID,
|
||||||
|
mw.Logging(),
|
||||||
|
mw.Write500OnPanic(),
|
||||||
|
mw.Metrics(registry),
|
||||||
|
mw.Write500OnPanic(),
|
||||||
|
).HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||||
|
panic("boom")
|
||||||
|
})
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(), http.MethodGet, "/", http.NoBody)
|
||||||
|
handler.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("status = %d, want %d",
|
||||||
|
rec.Code, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
request := findLogLine(t, logbuf.Bytes(), "request")
|
||||||
|
if request.Status != http.StatusInternalServerError {
|
||||||
|
t.Errorf("request log status = %d, want %d",
|
||||||
|
request.Status, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
recovered := findLogLine(t, logbuf.Bytes(), "panic recovered")
|
||||||
|
if recovered.Panic != "boom" {
|
||||||
|
t.Errorf("panic recovered line has panic %q, want %q",
|
||||||
|
recovered.Panic, "boom")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, name := range []string{
|
||||||
|
"http_request_duration_seconds", "http_response_size_bytes",
|
||||||
|
} {
|
||||||
|
codes := metricCodes(t, registry, name)
|
||||||
|
if len(codes) != 1 || codes[0] != "500" {
|
||||||
|
t.Errorf("%s code labels = %v, want [500]", name, codes)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// loggedLine holds the fields the tests read from a JSON log line.
|
||||||
|
type loggedLine struct {
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Status int `json:"status"`
|
||||||
|
Panic string `json:"panic"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// findLogLine returns the first of the JSON log lines in logged whose
|
||||||
|
// message is msg.
|
||||||
|
func findLogLine(t *testing.T, logged []byte, msg string) loggedLine {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
dec := json.NewDecoder(bytes.NewReader(logged))
|
||||||
|
|
||||||
|
for {
|
||||||
|
var line loggedLine
|
||||||
|
|
||||||
|
err := dec.Decode(&line)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("no %q line in the log: %v (%q)", msg, err, logged)
|
||||||
|
}
|
||||||
|
|
||||||
|
if line.Msg == msg {
|
||||||
|
return line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// metricCodes returns the code label of each series of the metric
|
||||||
|
// family name in registry.
|
||||||
|
func metricCodes(
|
||||||
|
t *testing.T,
|
||||||
|
registry *prometheus.Registry,
|
||||||
|
name string,
|
||||||
|
) []string {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
families, err := registry.Gather()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("gather metrics: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var codes []string
|
||||||
|
|
||||||
|
for _, family := range families {
|
||||||
|
if family.GetName() != name {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, metric := range family.GetMetric() {
|
||||||
|
for _, label := range metric.GetLabel() {
|
||||||
|
if label.GetName() == "code" {
|
||||||
|
codes = append(codes, label.GetValue())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return codes
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestRecovererWritesNoSecondStatus checks, on a real server, that
|
||||||
|
// Recoverer does not write its 500 again after Write500OnPanic has
|
||||||
|
// written one, which the server would log as a superfluous
|
||||||
|
// WriteHeader call.
|
||||||
|
func TestRecovererWritesNoSecondStatus(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
var serverLog bytes.Buffer
|
||||||
|
|
||||||
|
mw := middleware.NewWithLogger(slog.New(slog.DiscardHandler))
|
||||||
|
|
||||||
|
server := httptest.NewUnstartedServer(chi.Chain(
|
||||||
|
mw.Recoverer(),
|
||||||
|
mw.Write500OnPanic(),
|
||||||
|
).HandlerFunc(func(http.ResponseWriter, *http.Request) {
|
||||||
|
panic("boom")
|
||||||
|
}))
|
||||||
|
server.Config.ErrorLog = log.New(&serverLog, "", 0)
|
||||||
|
server.Start()
|
||||||
|
|
||||||
|
req, err := http.NewRequestWithContext(t.Context(),
|
||||||
|
http.MethodGet, server.URL, http.NoBody)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("new request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := server.Client().Do(req)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("request: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_ = resp.Body.Close()
|
||||||
|
|
||||||
|
// Close waits for the handler to return, so whatever the server
|
||||||
|
// logs for this request is in serverLog after it.
|
||||||
|
server.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusInternalServerError {
|
||||||
|
t.Errorf("status = %d, want %d",
|
||||||
|
resp.StatusCode, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
if serverLog.Len() != 0 {
|
||||||
|
t.Errorf("server logged %q", serverLog.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestLoggingCutsRequestStringsToBound sends an over-long URL and
|
// TestLoggingCutsRequestStringsToBound sends an over-long URL and
|
||||||
// over-long header values, and checks the request log writes each
|
// over-long header values, and checks the request log writes each
|
||||||
// one cut to logger.MaxLoggedFieldBytes.
|
// one cut to logger.MaxLoggedFieldBytes.
|
||||||
|
|||||||
@@ -91,6 +91,11 @@ type Buffer struct {
|
|||||||
// of the report files in dataDir, plus the reports waiting to
|
// of the report files in dataDir, plus the reports waiting to
|
||||||
// be written to one at their uncompressed size.
|
// be written to one at their uncompressed size.
|
||||||
usedBytes int64
|
usedBytes int64
|
||||||
|
// writeErr is the error of the last write of a report file to
|
||||||
|
// finish, nil if it succeeded or none has. While it is set, Append
|
||||||
|
// refuses reports, and the reports of that write wait in buf for
|
||||||
|
// the next flush.
|
||||||
|
writeErr error
|
||||||
}
|
}
|
||||||
|
|
||||||
// New creates a Buffer and registers lifecycle hooks to
|
// New creates a Buffer and registers lifecycle hooks to
|
||||||
@@ -167,11 +172,12 @@ func New(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Append marshals v as a single JSON line and appends it to
|
// Append marshals v as a single JSON line and appends it to
|
||||||
// the buffer. If the line would take usedBytes past maxBytes, the
|
// the buffer. It stores nothing and returns an error if the last
|
||||||
// oldest report files are deleted to make room; it stores nothing
|
// write of a report file failed, until a write succeeds again. If the
|
||||||
// and returns ErrFull if that cannot make room. If the buffer
|
// line would take usedBytes past maxBytes, the oldest report files are
|
||||||
// reaches the size threshold, it is drained and written to disk
|
// deleted to make room; it stores nothing and returns ErrFull if that
|
||||||
// asynchronously.
|
// cannot make room. If the buffer reaches the size threshold, it is
|
||||||
|
// drained and written to disk asynchronously.
|
||||||
func (b *Buffer) Append(v any) error {
|
func (b *Buffer) Append(v any) error {
|
||||||
line, err := json.Marshal(v)
|
line, err := json.Marshal(v)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -182,6 +188,13 @@ func (b *Buffer) Append(v any) error {
|
|||||||
|
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
|
|
||||||
|
if b.writeErr != nil {
|
||||||
|
err = fmt.Errorf("report files cannot be written: %w", b.writeErr)
|
||||||
|
b.mu.Unlock()
|
||||||
|
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
b.deleteOldestFiles(lineBytes)
|
b.deleteOldestFiles(lineBytes)
|
||||||
|
|
||||||
if b.usedBytes+lineBytes > b.maxBytes {
|
if b.usedBytes+lineBytes > b.maxBytes {
|
||||||
@@ -291,7 +304,8 @@ func (b *Buffer) drainBuf() []byte {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// writeFile writes data, reports drained from the buffer, to a new
|
// writeFile writes data, reports drained from the buffer, to a new
|
||||||
// timestamped zstd-compressed JSONL file in the data directory.
|
// timestamped zstd-compressed JSONL file in the data directory. If the
|
||||||
|
// write fails, it puts the reports back in the buffer.
|
||||||
func (b *Buffer) writeFile(data []byte) error {
|
func (b *Buffer) writeFile(data []byte) error {
|
||||||
// The timestamp comes first, so the names sort by time; the number
|
// The timestamp comes first, so the names sort by time; the number
|
||||||
// after it tells apart files named in the same millisecond.
|
// after it tells apart files named in the same millisecond.
|
||||||
@@ -300,19 +314,27 @@ func (b *Buffer) writeFile(data []byte) error {
|
|||||||
|
|
||||||
size, err := b.createFile(filepath.Join(b.dataDir, name), data)
|
size, err := b.createFile(filepath.Join(b.dataDir, name), data)
|
||||||
|
|
||||||
// The reports no longer wait to be written, so they stop counting
|
|
||||||
// at their uncompressed size. If the write failed they are lost;
|
|
||||||
// otherwise they count as the file, which from here on may be
|
|
||||||
// deleted to make room.
|
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
|
|
||||||
b.usedBytes -= int64(len(data))
|
b.writeErr = err
|
||||||
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// The reports go back in front of any appended since, for the
|
||||||
|
// next flush to write, and still count at their uncompressed
|
||||||
|
// size.
|
||||||
|
appended := bytes.Clone(b.buf.Bytes())
|
||||||
|
b.buf.Reset()
|
||||||
|
b.buf.Write(data)
|
||||||
|
b.buf.Write(appended)
|
||||||
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The reports no longer wait to be written, so they stop counting
|
||||||
|
// at their uncompressed size and count as the file, which from here
|
||||||
|
// on may be deleted to make room.
|
||||||
|
b.usedBytes -= int64(len(data))
|
||||||
b.usedBytes += size
|
b.usedBytes += size
|
||||||
b.filesBytes += size
|
b.filesBytes += size
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"slices"
|
"slices"
|
||||||
@@ -17,6 +19,8 @@ import (
|
|||||||
|
|
||||||
"sneak.berlin/go/netwatch/internal/config"
|
"sneak.berlin/go/netwatch/internal/config"
|
||||||
"sneak.berlin/go/netwatch/internal/globals"
|
"sneak.berlin/go/netwatch/internal/globals"
|
||||||
|
"sneak.berlin/go/netwatch/internal/handlers"
|
||||||
|
"sneak.berlin/go/netwatch/internal/healthcheck"
|
||||||
"sneak.berlin/go/netwatch/internal/logger"
|
"sneak.berlin/go/netwatch/internal/logger"
|
||||||
"sneak.berlin/go/netwatch/internal/reportbuf"
|
"sneak.berlin/go/netwatch/internal/reportbuf"
|
||||||
|
|
||||||
@@ -507,13 +511,17 @@ func TestFileBeingWrittenIsNeverDeleted(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestFailedWriteStopsCounting makes a write fail once its file is
|
// TestFailedWriteKeepsItsReports makes a write fail once its file is
|
||||||
// created. Its reports are lost, so they stop counting, and the part of
|
// created, under a cap with room for one report. The part of the file
|
||||||
// the file written is removed, so it takes no room.
|
// written is removed, so it takes no room, and the report waits in
|
||||||
func TestFailedWriteStopsCounting(t *testing.T) {
|
// memory for the next flush, which writes it. Once written, it counts
|
||||||
report := map[string]string{"id": "failed"}
|
// as its file and no longer as a report waiting, so the cap still holds
|
||||||
|
// exactly: one more report is taken and the one after it is refused.
|
||||||
|
func TestFailedWriteKeepsItsReports(t *testing.T) {
|
||||||
|
report := map[string]string{"id": "kept"}
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
t.Setenv("DATA_DIR", t.TempDir())
|
t.Setenv("DATA_DIR", dir)
|
||||||
t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(lineBytes(t, report)))
|
t.Setenv("DATA_DIR_MAX_BYTES", strconv.Itoa(lineBytes(t, report)))
|
||||||
|
|
||||||
buf := startBuffer(t)
|
buf := startBuffer(t)
|
||||||
@@ -544,10 +552,213 @@ func TestFailedWriteStopsCounting(t *testing.T) {
|
|||||||
// Writes from here on, the final one at stop included, succeed.
|
// Writes from here on, the final one at stop included, succeed.
|
||||||
buf.OnFileCreated(func(*os.File) {})
|
buf.OnFileCreated(func(*os.File) {})
|
||||||
|
|
||||||
|
err = buf.Flush()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("flush after the failed write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := readReportFiles(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read report files: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := `{"id":"kept"}` + "\n"
|
||||||
|
if len(files) != 1 || files[0] != want {
|
||||||
|
t.Fatalf("report files = %q, want one holding %q", files, want)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The file is deleted to make room if need be.
|
||||||
err = buf.Append(report)
|
err = buf.Append(report)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("report after the failed write: %v", err)
|
t.Fatalf("report after the written one: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
err = buf.Append(report)
|
||||||
|
if !errors.Is(err, reportbuf.ErrFull) {
|
||||||
|
t.Fatalf("report past the cap: error = %v, want ErrFull", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestFailedWriteKeepsReportsAppendedDuringIt holds a write open,
|
||||||
|
// appends a report while it is held, then makes the held write fail.
|
||||||
|
// Both reports are kept, and the next flush writes them in the order
|
||||||
|
// they were appended, the held write's first.
|
||||||
|
func TestFailedWriteKeepsReportsAppendedDuringIt(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("DATA_DIR", dir)
|
||||||
|
|
||||||
|
buf := startBuffer(t)
|
||||||
|
|
||||||
|
created := make(chan *os.File)
|
||||||
|
release := make(chan struct{})
|
||||||
|
|
||||||
|
buf.OnFileCreated(func(f *os.File) {
|
||||||
|
created <- f
|
||||||
|
|
||||||
|
<-release
|
||||||
|
})
|
||||||
|
|
||||||
|
err := buf.Append(map[string]string{"id": "held"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("report of the held write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
flushed := make(chan error)
|
||||||
|
|
||||||
|
go func() { flushed <- buf.Flush() }()
|
||||||
|
|
||||||
|
held := <-created
|
||||||
|
|
||||||
|
// Errorf, not Fatalf, until the held write is released, so that a
|
||||||
|
// failure here does not leave it held.
|
||||||
|
err = buf.Append(map[string]string{"id": "appended"})
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf("report appended while the write was held: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Closing the file under the write makes the write fail.
|
||||||
|
_ = held.Close()
|
||||||
|
|
||||||
|
close(release)
|
||||||
|
|
||||||
|
err = <-flushed
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("held write succeeded, though its file was closed under it")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Writes from here on, the final one at stop included, succeed.
|
||||||
|
buf.OnFileCreated(func(*os.File) {})
|
||||||
|
|
||||||
|
err = buf.Flush()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("flush after the failed write: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := readReportFiles(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read report files: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
want := `{"id":"held"}` + "\n" + `{"id":"appended"}` + "\n"
|
||||||
|
if len(files) != 1 || files[0] != want {
|
||||||
|
t.Fatalf("report files = %q, want one holding %q", files, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestReportsRefusedWhileWritesFail posts reports through the report
|
||||||
|
// handler, as the page does. Once a write has failed, a report is
|
||||||
|
// refused with 500 until a write succeeds again; the reports taken
|
||||||
|
// before and after are stored, and the one refused is not.
|
||||||
|
func TestReportsRefusedWhileWritesFail(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
t.Setenv("DATA_DIR", dir)
|
||||||
|
|
||||||
|
buf, h := startBufferWithHandlers(t)
|
||||||
|
|
||||||
|
if code := postReport(t, h, "before"); code != http.StatusOK {
|
||||||
|
t.Fatalf("report before the failed write: status = %d, want %d",
|
||||||
|
code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
// With the data directory gone, the write has nowhere to go.
|
||||||
|
err := os.Remove(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("remove data dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = buf.Flush()
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("flush succeeded with the data directory gone")
|
||||||
|
}
|
||||||
|
|
||||||
|
if code := postReport(t, h, "refused"); code != http.StatusInternalServerError {
|
||||||
|
t.Fatalf("report after the failed write: status = %d, want %d",
|
||||||
|
code, http.StatusInternalServerError)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.Mkdir(dir, 0o750)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("make data dir: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = buf.Flush()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("flush with the data directory back: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if code := postReport(t, h, "after"); code != http.StatusOK {
|
||||||
|
t.Fatalf("report after a write succeeded: status = %d, want %d",
|
||||||
|
code, http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = buf.Flush()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("flush of the report after: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
files, err := readReportFiles(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read report files: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
stored := strings.Join(files, "")
|
||||||
|
|
||||||
|
for _, clientID := range []string{"before", "after"} {
|
||||||
|
if !strings.Contains(stored, `"clientId":"`+clientID+`"`) {
|
||||||
|
t.Errorf("report %q not stored", clientID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.Contains(stored, `"clientId":"refused"`) {
|
||||||
|
t.Error("refused report stored")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// startBufferWithHandlers starts a Buffer and the Handlers that store
|
||||||
|
// reports in it through fx, as main does, with the DATA_DIR the calling
|
||||||
|
// test has set.
|
||||||
|
func startBufferWithHandlers(
|
||||||
|
t *testing.T,
|
||||||
|
) (*reportbuf.Buffer, *handlers.Handlers) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
var (
|
||||||
|
buf *reportbuf.Buffer
|
||||||
|
h *handlers.Handlers
|
||||||
|
)
|
||||||
|
|
||||||
|
app := fxtest.New(t,
|
||||||
|
fx.Provide(
|
||||||
|
config.New,
|
||||||
|
globals.New,
|
||||||
|
handlers.New,
|
||||||
|
healthcheck.New,
|
||||||
|
logger.New,
|
||||||
|
reportbuf.New,
|
||||||
|
),
|
||||||
|
fx.Populate(&buf, &h),
|
||||||
|
)
|
||||||
|
|
||||||
|
app.RequireStart()
|
||||||
|
t.Cleanup(app.RequireStop)
|
||||||
|
|
||||||
|
return buf, h
|
||||||
|
}
|
||||||
|
|
||||||
|
// postReport posts a report from clientID to the report handler, as the
|
||||||
|
// page does, and returns the status of the answer.
|
||||||
|
func postReport(t *testing.T, h *handlers.Handlers, clientID string) int {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequestWithContext(t.Context(),
|
||||||
|
http.MethodPost, "/api/v1/reports",
|
||||||
|
strings.NewReader(`{"clientId":"`+clientID+`","hosts":[]}`),
|
||||||
|
)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
h.HandleReport().ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
return rec.Code
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestConcurrentAppendsStopAtCap appends from many goroutines at once
|
// TestConcurrentAppendsStopAtCap appends from many goroutines at once
|
||||||
|
|||||||
@@ -34,13 +34,16 @@ func (s *Server) SetupRoutes() {
|
|||||||
s.router.Use(s.mw.Recoverer())
|
s.router.Use(s.mw.Recoverer())
|
||||||
s.router.Use(middleware.RequestID)
|
s.router.Use(middleware.RequestID)
|
||||||
s.router.Use(s.mw.Logging())
|
s.router.Use(s.mw.Logging())
|
||||||
|
// s.mw.Recoverer would write a panic's 500 only after the panic has
|
||||||
|
// passed the request log, so it is written here, where the log sees it.
|
||||||
|
s.router.Use(s.mw.Write500OnPanic())
|
||||||
s.router.Use(s.mw.SecurityHeaders())
|
s.router.Use(s.mw.SecurityHeaders())
|
||||||
s.router.Use(s.mw.CORS(s.params.Config.CORSAllowedOrigins))
|
s.router.Use(s.mw.CORS(s.params.Config.CORSAllowedOrigins))
|
||||||
s.router.Use(s.mw.MaxBodyBytes(maxRequestBodyBytes))
|
s.router.Use(s.mw.MaxBodyBytes(maxRequestBodyBytes))
|
||||||
s.router.Use(middleware.Timeout(requestTimeout))
|
s.router.Use(middleware.Timeout(requestTimeout))
|
||||||
|
|
||||||
// Sentry reports a panic, then panics again, so that s.mw.Recoverer
|
// Sentry reports a panic, then panics again, so that it still
|
||||||
// still answers 500.
|
// reaches s.mw.Recoverer.
|
||||||
if s.params.Config.SentryDSN != "" {
|
if s.params.Config.SentryDSN != "" {
|
||||||
s.router.Use(sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle)
|
s.router.Use(sentryhttp.New(sentryhttp.Options{Repanic: true}).Handle)
|
||||||
}
|
}
|
||||||
@@ -64,6 +67,8 @@ func (s *Server) SetupRoutes() {
|
|||||||
// other.
|
// other.
|
||||||
if s.params.Config.MetricsUsername != "" {
|
if s.params.Config.MetricsUsername != "" {
|
||||||
r.Use(s.mw.Metrics(registry))
|
r.Use(s.mw.Metrics(registry))
|
||||||
|
// Likewise inside the metrics.
|
||||||
|
r.Use(s.mw.Write500OnPanic())
|
||||||
}
|
}
|
||||||
|
|
||||||
r.Get("/.well-known/healthcheck", s.h.HandleHealthCheck())
|
r.Get("/.well-known/healthcheck", s.h.HandleHealthCheck())
|
||||||
|
|||||||
@@ -64,8 +64,8 @@ main() {
|
|||||||
# configs/nginx.conf is a template: the image renders it over its own
|
# configs/nginx.conf is a template: the image renders it over its own
|
||||||
# default.conf, with the same port and limit bin/entrypoint.sh uses.
|
# default.conf, with the same port and limit bin/entrypoint.sh uses.
|
||||||
# The empty file it includes trusts no proxy, as bin/entrypoint.sh
|
# The empty file it includes trusts no proxy, as bin/entrypoint.sh
|
||||||
# writes it when TRUSTED_PROXIES is unset. It also includes the
|
# writes it when TRUSTED_PROXIES is set and empty. It also includes
|
||||||
# security headers, so the page runs under the shipped policy.
|
# the security headers, so the page runs under the shipped policy.
|
||||||
docker run -d --rm --name "$SERVER" \
|
docker run -d --rm --name "$SERVER" \
|
||||||
--network "$NETWORK" --network-alias netwatch \
|
--network "$NETWORK" --network-alias netwatch \
|
||||||
-e PORT=8080 -e NGINX_ENVSUBST_FILTER='^PORT$' \
|
-e PORT=8080 -e NGINX_ENVSUBST_FILTER='^PORT$' \
|
||||||
|
|||||||
Reference in New Issue
Block a user