Compare commits
1
Commits
next
..
2b2f035410
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b2f035410 |
No files matched your search
@@ -22,7 +22,7 @@ make setup
|
||||
# Serve the page from the Vite dev server
|
||||
make dev
|
||||
|
||||
# Build the page and netwatch-server, then run only the server, on port 8080
|
||||
# Build the page and netwatch-server, then run the server on port 8080
|
||||
make run
|
||||
|
||||
# Run the tests, both linters and the format check
|
||||
@@ -36,16 +36,9 @@ make docker
|
||||
docker run -p 8080:8080 netwatch
|
||||
```
|
||||
|
||||
After `make dev`, open the page at `http://localhost:5173/`. From the image, it
|
||||
is at `http://localhost:8080/`.
|
||||
|
||||
`make run` serves no page: `http://localhost:8080/` answers
|
||||
`404 page not found`. It starts `netwatch-server` on port 8080 with its
|
||||
defaults, which stores reports in `data/reports`. `make dev` passes the page's
|
||||
`/api` requests there, so run `make run` in a second terminal to store the
|
||||
page's reports.
|
||||
|
||||
`make check` and `make docker` need Docker.
|
||||
`make check` and `make docker` need Docker. `make run` starts `netwatch-server`
|
||||
on port 8080 with its defaults, so the page's reports are stored in
|
||||
`data/reports`. `make dev` passes the page's `/api` requests there.
|
||||
|
||||
## The page
|
||||
|
||||
@@ -64,7 +57,7 @@ The page checks 26 hosts on the internet, the WAN hosts:
|
||||
- the Anthropic API and the OpenAI API
|
||||
- the AWS Console, the Google Cloud Console and Microsoft Azure
|
||||
- Cloudflare, Fastly, Akamai, Google, GitHub and Backblaze B2
|
||||
- 8 S3 regional endpoints: Cape Town, London, Mumbai, Tokyo, Singapore, Sydney,
|
||||
- 8 S3 regional endpoints: Cape Town, London, Bahrain, Tokyo, Singapore, Sydney,
|
||||
Oregon and São Paulo
|
||||
- 6 Hetzner speed-test servers: Nuremberg, Falkenstein, Helsinki, Ashburn,
|
||||
Hillsboro and Singapore
|
||||
@@ -123,10 +116,9 @@ half second. As soon as one answers, a new round starts.
|
||||
### Rows, colors and controls
|
||||
|
||||
A figure and its line in the graph take the color of the legend in the footer,
|
||||
from green under 50 ms to red from 500 ms up. The dot beside the host's name
|
||||
takes the figure's color. A failed check has a gray figure, and a faint red band
|
||||
in the graph. The graph's axis ends at 1000 ms; a higher latency is drawn at the
|
||||
top, and the figure still shows its real value.
|
||||
from green under 50 ms to red from 500 ms up; gray is no answer. The dot beside
|
||||
the host's name takes the same color. The graph's axis ends at 1000 ms; a higher
|
||||
latency is drawn at the top, and the figure still shows its real value.
|
||||
|
||||
From time to time, the page sorts the WAN rows by their latest latency: fastest
|
||||
first, failed last. The pin button at the left of a row keeps the row at the
|
||||
@@ -151,9 +143,7 @@ fields:
|
||||
and `history`, its samples. A sample has the check's time `t` in Unix
|
||||
milliseconds, its `latency` (`null` for a failed check) and its `error`
|
||||
(`"timeout"`, `"unreachable"` or `null`).
|
||||
- `timestamp`: the time of the report, as an ISO 8601 string such as
|
||||
`2026-11-11T20:01:35.685Z`. A report whose `timestamp` is a number is refused
|
||||
with 400.
|
||||
- `timestamp`: the time of the report
|
||||
|
||||
Each report carries only the samples no earlier report has delivered. A sample
|
||||
waits as long as the history holds it: the last 100 rounds of each host. An
|
||||
@@ -231,9 +221,8 @@ reason is in its log.
|
||||
`netwatch-server` reads its settings from environment variables. It also reads a
|
||||
`.env` file in the directory it starts in, and a YAML file named
|
||||
`netwatch-server.yaml` in `/etc/netwatch-server/` or
|
||||
`~/.config/netwatch-server/`, when one exists. In the YAML file, each key is a
|
||||
variable's name, in upper or lower case: `PORT: 8090` sets the port. A variable
|
||||
in the environment wins over a file.
|
||||
`~/.config/netwatch-server/`, when one exists. A variable in the environment
|
||||
wins over a file.
|
||||
|
||||
The server does not start when a variable holds a value it cannot use. Its error
|
||||
names the variable. An empty variable counts as unset, except `TRUSTED_PROXIES`.
|
||||
@@ -350,10 +339,6 @@ The backend logs JSON to stdout, or plain text when stdout is a terminal. Each
|
||||
request is logged with its method, URL, status, client address and duration.
|
||||
Every value taken from a request is cut to 128 bytes before it is logged.
|
||||
|
||||
The steps `fx` takes to start and stop the backend are logged at debug level, so
|
||||
they show only with `DEBUG` set. When the backend refuses a setting, its last
|
||||
log line is one error that names the setting and the reason.
|
||||
|
||||
## Design
|
||||
|
||||
The repository holds the page, the backend and the image:
|
||||
|
||||
@@ -36,103 +36,16 @@ func TestMain(m *testing.M) {
|
||||
os.Exit(m.Run())
|
||||
}
|
||||
|
||||
// TestOutputIsJSONWithoutFxLines: off a terminal, every line the server
|
||||
// writes from start to stop is JSON, and without DEBUG none of them is
|
||||
// one of fx's.
|
||||
func TestOutputIsJSONWithoutFxLines(t *testing.T) {
|
||||
// TestOutputIsJSON: off a terminal, every line the server writes from
|
||||
// start to stop is JSON, fx's own lines included.
|
||||
func TestOutputIsJSON(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stdout, stderr := runServerUntilStopped(t)
|
||||
|
||||
requireJSONLines(t, stdout, stderr)
|
||||
|
||||
if !strings.Contains(stdout.String(), `"msg":"starting"`) {
|
||||
t.Fatalf("no startup line in stdout:\n%s", stdout)
|
||||
}
|
||||
|
||||
if strings.Contains(stdout.String(), `"msg":"started"`) {
|
||||
t.Fatalf("fx line in stdout without DEBUG:\n%s", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestDebugLogsFxLines: with DEBUG set, fx's lines are logged, as
|
||||
// JSON, from the start to the signal that stops the server.
|
||||
func TestDebugLogsFxLines(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
stdout, stderr := runServerUntilStopped(t, "DEBUG=1")
|
||||
|
||||
requireJSONLines(t, stdout, stderr)
|
||||
|
||||
for _, msg := range []string{"started", "received signal"} {
|
||||
if !strings.Contains(stdout.String(), `"msg":"`+msg+`"`) {
|
||||
t.Fatalf("no fx line %q in stdout with DEBUG set:\n%s",
|
||||
msg, stdout)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestRefusedSettingIsLastLine: a setting the server refuses stops the
|
||||
// start, and the one error line logged is the last, naming the setting
|
||||
// and why, without fx's text around it.
|
||||
func TestRefusedSettingIsLastLine(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), childTimeout)
|
||||
defer cancel()
|
||||
|
||||
child, stdout, stderr := startServer(ctx, t, t.TempDir(),
|
||||
freePort(ctx, t), "REPORTS_PER_MINUTE=0")
|
||||
|
||||
err := child.Wait()
|
||||
if child.ProcessState.ExitCode() != 1 {
|
||||
t.Fatalf("server exit = %v, want exit status 1", err)
|
||||
}
|
||||
|
||||
if stderr.Len() != 0 {
|
||||
t.Fatalf("stderr = %q, want nothing", stderr)
|
||||
}
|
||||
|
||||
lines := strings.Split(strings.TrimSpace(stdout.String()), "\n")
|
||||
|
||||
for _, line := range lines[:len(lines)-1] {
|
||||
if strings.Contains(line, `"level":"ERROR"`) {
|
||||
t.Fatalf("an error line before the last line:\n%s", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
var last struct {
|
||||
Level string `json:"level"`
|
||||
Error string `json:"error"`
|
||||
}
|
||||
|
||||
err = json.Unmarshal([]byte(lines[len(lines)-1]), &last)
|
||||
if err != nil {
|
||||
t.Fatalf("last line is not JSON: %v\n%s", err, stdout)
|
||||
}
|
||||
|
||||
want := `REPORTS_PER_MINUTE "0": must be a positive whole number`
|
||||
if last.Level != "ERROR" || last.Error != want {
|
||||
t.Fatalf("last line has level %q, error %q; want ERROR, %q\n%s",
|
||||
last.Level, last.Error, want, stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// runServerUntilStopped runs main() in a child process with env added
|
||||
// to its environment, waits until it answers its health check, stops it
|
||||
// with SIGTERM and returns its stdout and stderr. It fails the test if
|
||||
// the child does not exit cleanly.
|
||||
func runServerUntilStopped(
|
||||
t *testing.T,
|
||||
env ...string,
|
||||
) (*bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), childTimeout)
|
||||
defer cancel()
|
||||
|
||||
port := freePort(ctx, t)
|
||||
child, stdout, stderr := startServer(ctx, t, t.TempDir(), port, env...)
|
||||
child, stdout, stderr := startServer(ctx, t, t.TempDir(), port)
|
||||
|
||||
waitForHealthcheck(ctx, t, port)
|
||||
|
||||
@@ -159,7 +72,11 @@ func runServerUntilStopped(
|
||||
t.Fatalf("server exit = %v, want success", err)
|
||||
}
|
||||
|
||||
return stdout, stderr
|
||||
requireJSONLines(t, stdout, stderr)
|
||||
|
||||
if !strings.Contains(stdout.String(), `"msg":"starting"`) {
|
||||
t.Fatalf("no startup line in stdout:\n%s", stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// TestMalformedConfigFileStopsTheStart: a config file the server finds
|
||||
@@ -220,40 +137,9 @@ func TestRefusedSentryDSNStopsTheStart(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestPortInUseExitsWithStatus1: a server whose port is already taken
|
||||
// cannot listen, and stops with exit status 1.
|
||||
func TestPortInUseExitsWithStatus1(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
ctx, cancel := context.WithTimeout(t.Context(), childTimeout)
|
||||
defer cancel()
|
||||
|
||||
var lc net.ListenConfig
|
||||
|
||||
taken, err := lc.Listen(ctx, "tcp", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defer func() { _ = taken.Close() }()
|
||||
|
||||
_, port, err := net.SplitHostPort(taken.Addr().String())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
child, stdout, _ := startServer(ctx, t, t.TempDir(), port)
|
||||
|
||||
err = child.Wait()
|
||||
if child.ProcessState.ExitCode() != 1 {
|
||||
t.Fatalf("server exit = %v, want exit status 1\n%s", err, stdout)
|
||||
}
|
||||
}
|
||||
|
||||
// startServer runs main() in a child process listening on
|
||||
// 127.0.0.1:port, with home as its HOME and working directory and its
|
||||
// data directory in home, so it touches nothing outside home. env is
|
||||
// added to its environment, as NAME=value strings. Its
|
||||
// data directory in home, so it touches nothing outside home. Its
|
||||
// stdout and stderr go to the two buffers returned, which hold all of
|
||||
// it once child.Wait returns. The child is killed when ctx ends, and
|
||||
// killed and reaped when the test ends if nothing waited for it.
|
||||
@@ -261,7 +147,6 @@ func startServer(
|
||||
ctx context.Context,
|
||||
t *testing.T,
|
||||
home, port string,
|
||||
env ...string,
|
||||
) (*exec.Cmd, *bytes.Buffer, *bytes.Buffer) {
|
||||
t.Helper()
|
||||
|
||||
@@ -281,7 +166,6 @@ func startServer(
|
||||
"BIND_ADDRESS=127.0.0.1",
|
||||
"PORT="+port,
|
||||
)
|
||||
child.Env = append(child.Env, env...)
|
||||
child.Stdout = &stdout
|
||||
child.Stderr = &stderr
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ require (
|
||||
github.com/prometheus/client_golang v1.24.1
|
||||
github.com/slok/go-http-metrics v0.13.0
|
||||
github.com/spf13/viper v1.21.0
|
||||
go.uber.org/dig v1.19.0
|
||||
go.uber.org/fx v1.24.0
|
||||
golang.org/x/sys v0.47.0
|
||||
)
|
||||
@@ -39,6 +38,7 @@ require (
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/zeebo/xxh3 v1.0.2 // indirect
|
||||
go.uber.org/dig v1.19.0 // indirect
|
||||
go.uber.org/multierr v1.10.0 // indirect
|
||||
go.uber.org/zap v1.26.0 // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
|
||||
+24
-75
@@ -5,11 +5,8 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/user"
|
||||
|
||||
"sneak.berlin/go/netwatch/internal/config"
|
||||
@@ -21,7 +18,6 @@ import (
|
||||
"sneak.berlin/go/netwatch/internal/reportbuf"
|
||||
"sneak.berlin/go/netwatch/internal/server"
|
||||
|
||||
"go.uber.org/dig"
|
||||
"go.uber.org/fx"
|
||||
"go.uber.org/fx/fxevent"
|
||||
)
|
||||
@@ -33,17 +29,19 @@ const usageExitCode = 2
|
||||
// Main runs netwatch-server with args, its command line after the
|
||||
// program's name, and returns the exit code. With no args, Main runs
|
||||
// the server by calling serve with appname and version, the name and
|
||||
// version the server logs and reports, and returns serve's exit code.
|
||||
// With "check-cidr CIDR" or "prepare-data-dir DATA_DIR" it runs that
|
||||
// subcommand, which writes its errors to stderr. Any other args get a
|
||||
// one-line usage message on stderr and exit code 2. main passes
|
||||
// os.Stderr and RunServer; the tests pass a buffer and a function of
|
||||
// their own, so that no test starts the server.
|
||||
// version the server logs and reports. With "check-cidr CIDR" or
|
||||
// "prepare-data-dir DATA_DIR" it runs that subcommand, which writes its
|
||||
// errors to stderr. Any other args get a one-line usage message on
|
||||
// stderr and exit code 2. main passes os.Stderr and RunServer; the
|
||||
// tests pass a buffer and a function of their own, so that no test
|
||||
// starts the server. When serve is RunServer and the server fails to
|
||||
// start, to listen or to stop, fx's Run exits the process with status 1
|
||||
// and Main never returns.
|
||||
func Main(
|
||||
stderr io.Writer,
|
||||
appname, version string,
|
||||
args []string,
|
||||
serve func(appname, version string) int,
|
||||
serve func(appname, version string),
|
||||
) int {
|
||||
// "netwatch-server check-cidr CIDR" exits 1, with the error, if
|
||||
// this server would refuse CIDR in its TRUSTED_PROXIES.
|
||||
@@ -69,7 +67,9 @@ func Main(
|
||||
return usageExitCode
|
||||
}
|
||||
|
||||
return serve(appname, version)
|
||||
serve(appname, version)
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
// checkCIDR returns 0 if the server would take cidr in its
|
||||
@@ -109,30 +109,19 @@ func prepareDataDir(stderr io.Writer, volume, dataDir, username string) int {
|
||||
}
|
||||
|
||||
// RunServer runs the server until it is told to stop, with appname and
|
||||
// version as the name and version it logs and reports, and returns the
|
||||
// exit code. If the server fails to start, to listen or to stop, it
|
||||
// returns 1. A failed start logs one line with the error's cause, such
|
||||
// as the setting refused and why, as the server's last line.
|
||||
func RunServer(appname, version string) int {
|
||||
// version as the name and version it logs and reports. If the server
|
||||
// fails to start, to listen or to stop, fx's Run exits the process
|
||||
// with status 1 itself.
|
||||
func RunServer(appname, version string) {
|
||||
globals.Appname = appname
|
||||
globals.Version = version
|
||||
|
||||
var (
|
||||
log *logger.Logger
|
||||
fxLog *fxevent.SlogLogger
|
||||
)
|
||||
|
||||
app := fx.New(
|
||||
// fx logs each step of starting and stopping, errors included,
|
||||
// at debug level through the server's own logger: they show
|
||||
// only with DEBUG set, and off a terminal they are JSON like
|
||||
// every other line.
|
||||
fx.New(
|
||||
// fx logs each step of starting and stopping through the
|
||||
// server's own logger, so off a terminal those lines are
|
||||
// JSON like every other line.
|
||||
fx.WithLogger(func(log *logger.Logger) fxevent.Logger {
|
||||
fxLog = &fxevent.SlogLogger{Logger: log.Get()}
|
||||
fxLog.UseLogLevel(slog.LevelDebug)
|
||||
fxLog.UseErrorLevel(slog.LevelDebug)
|
||||
|
||||
return fxLog
|
||||
return &fxevent.SlogLogger{Logger: log.Get()}
|
||||
}),
|
||||
fx.Provide(
|
||||
config.New,
|
||||
@@ -144,51 +133,11 @@ func RunServer(appname, version string) int {
|
||||
reportbuf.New,
|
||||
server.New,
|
||||
),
|
||||
// First, so that log is set for the error below even when a
|
||||
// setting stops the start.
|
||||
fx.Populate(&log),
|
||||
fx.Invoke(
|
||||
// Before the server, so that the name and version are
|
||||
// logged even when a setting stops the start.
|
||||
// First, so the name and version are logged even when
|
||||
// a setting stops the start.
|
||||
func(log *logger.Logger) { log.Identify() },
|
||||
func(*server.Server) {},
|
||||
),
|
||||
)
|
||||
|
||||
startCtx, cancelStart := context.WithTimeout(
|
||||
context.Background(), app.StartTimeout())
|
||||
defer cancelStart()
|
||||
|
||||
err := app.Start(startCtx)
|
||||
if err != nil {
|
||||
// fx wraps a constructor's error in its own text; the cause
|
||||
// alone names the setting. log is nil only if fx failed
|
||||
// before it built the logger.
|
||||
cause := dig.RootCause(err)
|
||||
if log == nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, cause)
|
||||
} else {
|
||||
log.Get().Error("cannot start", "error", cause)
|
||||
}
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
// A signal, or the server asking to stop after a listen error.
|
||||
// fx's Run logs the signal here, so this does too.
|
||||
shutdown := <-app.Wait()
|
||||
fxLog.LogEvent(&fxevent.Stopping{Signal: shutdown.Signal})
|
||||
|
||||
stopCtx, cancelStop := context.WithTimeout(
|
||||
context.Background(), app.StopTimeout())
|
||||
defer cancelStop()
|
||||
|
||||
err = app.Stop(stopCtx)
|
||||
if err != nil {
|
||||
log.Get().Error("cannot stop cleanly", "error", err)
|
||||
|
||||
return 1
|
||||
}
|
||||
|
||||
return shutdown.ExitCode
|
||||
).Run()
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func TestMainRunsPrepareDataDir(t *testing.T) {
|
||||
|
||||
// TestMainRunsServerWithNoArguments: Main given no arguments starts the
|
||||
// server with the name and version it was given, writes nothing and
|
||||
// returns the server's exit code.
|
||||
// returns 0.
|
||||
func TestMainRunsServerWithNoArguments(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
@@ -73,13 +73,11 @@ func TestMainRunsServerWithNoArguments(t *testing.T) {
|
||||
)
|
||||
|
||||
code := cli.Main(&stderr, "netwatch", "test", []string{},
|
||||
func(appname, version string) int {
|
||||
func(appname, version string) {
|
||||
gotAppname, gotVersion = appname, version
|
||||
|
||||
return 3
|
||||
})
|
||||
if code != 3 || stderr.Len() != 0 {
|
||||
t.Errorf("exit %d, stderr %q; want exit 3 and no output",
|
||||
if code != 0 || stderr.Len() != 0 {
|
||||
t.Errorf("exit %d, stderr %q; want exit 0 and no output",
|
||||
code, stderr.String())
|
||||
}
|
||||
|
||||
@@ -212,12 +210,10 @@ func currentUsername(t *testing.T) string {
|
||||
|
||||
// failIfServerStarts is what the tests give Main in place of
|
||||
// cli.RunServer: it fails the test instead of starting the server.
|
||||
func failIfServerStarts(t *testing.T) func(appname, version string) int {
|
||||
func failIfServerStarts(t *testing.T) func(appname, version string) {
|
||||
t.Helper()
|
||||
|
||||
return func(string, string) int {
|
||||
return func(string, string) {
|
||||
t.Error("Main started the server")
|
||||
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -221,8 +221,8 @@ test("a target whose home page redirects is checked at /robots.txt on its own ho
|
||||
"https://s3.eu-west-2.amazonaws.com/robots.txt",
|
||||
],
|
||||
[
|
||||
"S3 ap-south-1 (Mumbai)",
|
||||
"https://s3.ap-south-1.amazonaws.com/robots.txt",
|
||||
"S3 me-south-1 (Bahrain)",
|
||||
"https://s3.me-south-1.amazonaws.com/robots.txt",
|
||||
],
|
||||
[
|
||||
"S3 ap-northeast-1 (Tokyo)",
|
||||
|
||||
+3
-3
@@ -99,8 +99,8 @@ const WAN_HOSTS = [
|
||||
method: "HEAD",
|
||||
},
|
||||
{
|
||||
name: "S3 ap-south-1 (Mumbai)",
|
||||
url: "https://s3.ap-south-1.amazonaws.com/robots.txt",
|
||||
name: "S3 me-south-1 (Bahrain)",
|
||||
url: "https://s3.me-south-1.amazonaws.com/robots.txt",
|
||||
method: "HEAD",
|
||||
},
|
||||
{
|
||||
@@ -924,7 +924,7 @@ export function buildUI(state) {
|
||||
</div>
|
||||
|
||||
<footer class="mt-8 text-center text-gray-600 text-xs">
|
||||
<p>Latency: the time until a target's response headers arrive</p>
|
||||
<p>Latency measured via GET requests | CORS restrictions may affect some measurements</p>
|
||||
<p class="mt-2">
|
||||
<span class="inline-block w-3 h-3 rounded-full bg-green-500 mr-1 align-middle"></span><50ms
|
||||
<span class="inline-block w-3 h-3 rounded-full bg-lime-500 mr-1 ml-3 align-middle"></span><100ms
|
||||
|
||||
Reference in new issue
Block a user