diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..32123e0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2026, Pim van Pelt +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..b408ac4 --- /dev/null +++ b/README.md @@ -0,0 +1,64 @@ +# maglevd + +Health checker and gRPC control plane for VPP Maglev load balancing. + +## Build + +```sh +make # builds bin/maglevd +make test # runs all tests +make proto # regenerates gRPC stubs from proto/maglev.proto +``` + +Requires Go 1.25+ and (for `make proto`) `protoc` with `protoc-gen-go` and `protoc-gen-go-grpc`. + +## Run + +```sh +maglevd --config /etc/maglev/maglev.yaml --grpc-addr :9090 +``` + +| Flag | Env | Default | +|---|---|---| +| `--config` | `MAGLEV_CONFIG` | `/etc/maglev/frontend.yaml` | +| `--grpc-addr` | `MAGLEV_GRPC_ADDR` | `:9090` | +| `--log-level` | `MAGLEV_LOG_LEVEL` | `info` | + +Send `SIGHUP` to reload the config without restarting. Backends whose health-check config is +unchanged continue probing uninterrupted. + +`maglevd` requires `CAP_NET_RAW` to open raw ICMP sockets. + +## Minimal config + +```yaml +maglev: + healthchecks: + http: + type: http + port: 80 + params: + path: /healthz + interval: 2s + timeout: 3s + + backends: + web0: {address: 192.0.2.10, healthcheck: http} + web1: {address: 192.0.2.11, healthcheck: http} + + frontends: + web: + address: 192.0.2.1 + protocol: tcp + port: 80 + backends: [web0, web1] +``` + +See [docs/config-guide.md](docs/config-guide.md) for the full configuration reference. + +## Docker + +```sh +docker build -t maglevd . +docker run --cap-add NET_RAW -v /etc/maglev:/etc/maglev maglevd +``` diff --git a/cmd/maglevd/main.go b/cmd/maglevd/main.go index f1dbc9a..d393fcd 100644 --- a/cmd/maglevd/main.go +++ b/cmd/maglevd/main.go @@ -1,3 +1,5 @@ +// Copyright (c) 2026, Pim van Pelt + package main import ( diff --git a/docs/config-guide.md b/docs/config-guide.md new file mode 100644 index 0000000..74754f1 --- /dev/null +++ b/docs/config-guide.md @@ -0,0 +1,354 @@ +# maglevd Configuration Guide + +## Overview + +`maglevd` consumes a YAML configuration file of a specific format. Validation is performed +in two stages: + +1. **Structural parsing**: the YAML is unmarshalled into typed Go structs. Unknown fields and + type mismatches are rejected immediately. +1. **Semantic validation**: cross-field and cross-object rules are enforced, for example + ensuring that every backend referenced by a frontend exists, that address families are + consistent within a frontend, and that IP source addresses are the correct family. + +If you want to get started quickly, take a look at the [example config](#example). + +## Basic structure + +The YAML configuration file has the following top-level structure: + +```yaml +maglev: + healthchecker: + [ Global health checker settings ] + + healthchecks: + my-check: + [ Health check definition ] + + backends: + my-backend: + [ Backend definition ] + + frontends: + my-frontend: + [ Frontend (VIP) definition ] +``` + +All four sections live under the top-level `maglev:` key. The `healthchecks`, `backends`, +and `frontends` sections are maps keyed by an arbitrary name of your choosing. Names must be +unique within their section and are case-sensitive. + +--- + +## healthchecker + +Global settings for the health checker engine. + +* ***transition-history***: An integer >= 1 that controls how many state transitions are + retained per backend for display via the gRPC API. Defaults to `5`. +* ***netns***: The name of a Linux network namespace in which probes are executed. When + empty or omitted, probes run in the current (default) network namespace. Useful when + backends are reachable only through a dedicated dataplane namespace. + +Example: +```yaml +maglev: + healthchecker: + transition-history: 10 + netns: dataplane +``` + +--- + +## healthchecks + +A named map of health check definitions. Each health check describes *how* to probe a backend. +Backends reference health checks by name. The same health check can be reused across any number +of backends; each backend is probed exactly once regardless of how many frontends reference it. + +Common fields (all types): + +* ***type***: Required. One of `icmp`, `tcp`, `http`, or `https`. +* ***port***: The destination port to probe. Required for `tcp`, `http`, and `https`. + Must be omitted for `icmp`. +* ***probe-ipv4-src***: An optional IPv4 source address used when probing IPv4 backends. + Must be an IPv4 address. When omitted, the OS chooses the source address. +* ***probe-ipv6-src***: An optional IPv6 source address used when probing IPv6 backends. + Must be an IPv6 address. When omitted, the OS chooses the source address. +* ***interval***: Required. A positive Go duration string (e.g. `2s`, `500ms`) controlling + how often a probe is sent when the backend is fully healthy or in the initial unknown state. +* ***fast-interval***: Optional. A positive duration used instead of `interval` while the + backend's health counter is degraded (between down and up). When omitted, `interval` is used. +* ***down-interval***: Optional. A positive duration used instead of `interval` while the + backend is fully down. When omitted, `interval` is used. Setting this to a longer value + reduces probe traffic to backends that are known to be offline. +* ***timeout***: Required. A positive duration after which an in-flight probe is abandoned + and counted as a failure. +* ***rise***: The number of consecutive successes required to transition from down to up. + Defaults to `2`. Must be >= 1. +* ***fall***: The number of consecutive failures required to transition from up to down. + Defaults to `3`. Must be >= 1. + +### type: icmp + +Sends an ICMP echo request (ping) to the backend address. Requires `CAP_NET_RAW`. No `port` +may be specified. No `params` block is used. + +```yaml +healthchecks: + ping: + type: icmp + probe-ipv4-src: 10.0.0.1 + probe-ipv6-src: 2001:db8::1 + interval: 2s + timeout: 1s + rise: 2 + fall: 3 +``` + +### type: tcp + +Opens a TCP connection to the backend and immediately closes it upon success. Use `params` to +optionally wrap the connection in TLS. + +* ***params.ssl***: A boolean. When `true`, a TLS handshake is performed after the TCP + connection is established. Defaults to `false`. +* ***params.server-name***: The TLS SNI hostname sent during the handshake. When omitted, + the backend IP address is used. +* ***params.insecure-skip-verify***: A boolean. When `true`, the TLS certificate presented + by the server is not verified. Defaults to `false`. + +```yaml +healthchecks: + imaps-check: + type: tcp + port: 993 + params: + ssl: true + server-name: imaps.example.com + interval: 5s + timeout: 3s + rise: 2 + fall: 3 +``` + +### type: http / https + +Opens a TCP (or TLS for `https`) connection, sends an HTTP request, and evaluates the response +code. An optional regexp can additionally match against the response body. + +* ***params.path***: Required. The HTTP request path, e.g. `/healthz`. +* ***params.host***: The `Host` header value sent in the request. When omitted, the backend + IP address is used. +* ***params.response-code***: The expected HTTP response code. Can be a single value (`"200"`) + or an inclusive range (`"200-299"`). Defaults to `"200"`. +* ***params.response-regexp***: An optional Go regular expression matched against the response + body. If specified, the body must match for the probe to succeed. +* ***params.server-name***: The TLS SNI hostname (`https` only). Defaults to the value of + `params.host` if not set. +* ***params.insecure-skip-verify***: A boolean. Skip TLS certificate verification (`https` + only). Defaults to `false`. + +```yaml +healthchecks: + nginx-http: + type: http + port: 80 + params: + path: /healthz + host: nginx.example.com + response-code: "200-204" + interval: 2s + fast-interval: 500ms + down-interval: 30s + timeout: 3s + rise: 2 + fall: 3 + + nginx-https: + type: https + port: 443 + params: + path: /healthz + host: nginx.example.com + server-name: nginx.example.com + insecure-skip-verify: false + interval: 5s + timeout: 3s +``` + +--- + +## backends + +A named map of individual backend servers. Each backend has a single IP address and optionally +references a health check by name. Backends are probed exactly once, even if they appear in +multiple frontends. + +* ***address***: Required. The IPv4 or IPv6 address of this backend server. +* ***healthcheck***: The name of a health check defined in the `healthchecks` section. + When empty or omitted, no probing is performed and the backend is assumed permanently + healthy. This is useful for backends that are always available or managed by other means. +* ***enabled***: A boolean controlling whether this backend participates in any frontend. + When `false`, the backend is excluded entirely and no probe goroutine is started. + Defaults to `true`. +* ***weight***: An integer between 0 and 100 (inclusive) expressing the relative weight of + this backend in a frontend's pool. `0` keeps the backend in the pool but assigns it no + traffic. Defaults to `100`. + +Examples: +```yaml +backends: + nginx0-ams: + address: 198.51.100.10 + healthcheck: nginx-http + nginx0-lon: + address: 198.51.100.11 + healthcheck: nginx-http + weight: 50 + nginx0-draining: + address: 198.51.100.12 + healthcheck: nginx-http + enabled: false + static-backend: + address: 198.51.100.20 + # no healthcheck: assumed always healthy +``` + +--- + +## frontends + +A named map of virtual IPs (VIPs). Each frontend ties together a listener address with a set +of backends. The gRPC API exposes frontends by name. + +* ***description***: An optional free-text string for documentation purposes. +* ***address***: Required. The IPv4 or IPv6 address of the VIP. +* ***protocol***: The IP protocol, either `tcp` or `udp`. When omitted, the frontend matches + all traffic to the VIP address regardless of protocol. If `port` is specified, `protocol` + must also be set. +* ***port***: The destination port of the VIP, an integer between 1 and 65535. Requires + `protocol` to be set. When omitted, the frontend matches all ports. Note that the + frontend port is independent of the healthcheck port: a frontend on port 443 may use + a healthcheck that probes port 80. +* ***backends***: Required. A non-empty list of backend names. All backends in a frontend + must have addresses of the same address family (all IPv4 or all IPv6). Every name must + refer to an existing entry in the `backends` section. + +Examples: +```yaml +frontends: + nginx-v4-http: + description: "IPv4 HTTP VIP" + address: 198.51.100.1 + protocol: tcp + port: 80 + backends: [nginx0-ams, nginx0-lon] + + nginx-v4-https: + description: "IPv4 HTTPS VIP — reuses the same backends as HTTP" + address: 198.51.100.1 + protocol: tcp + port: 443 + backends: [nginx0-ams, nginx0-lon] + + maildrop-imaps: + description: "IMAPS VIP" + address: 2001:db8::1 + protocol: tcp + port: 993 + backends: [maildrop0-ams, maildrop0-lon] + + catchall: + description: "Match all traffic to this VIP regardless of protocol or port" + address: 198.51.100.2 + backends: [static-backend] +``` + +--- + +## Example + +A complete configuration tying all sections together: + +```yaml +maglev: + healthchecker: + transition-history: 5 + netns: dataplane + + healthchecks: + nginx: + type: http + port: 80 + params: + path: /healthz + host: nginx.example.com + response-code: "200" + interval: 2s + fast-interval: 500ms + down-interval: 30s + timeout: 3s + rise: 2 + fall: 3 + + dovecot: + type: tcp + port: 993 + params: + ssl: true + server-name: imaps.example.com + interval: 5s + fast-interval: 1s + down-interval: 30s + timeout: 3s + rise: 2 + fall: 3 + + ping6: + type: icmp + probe-ipv6-src: 2001:db8:probe::1 + interval: 2s + timeout: 1s + + backends: + nginx0-ams: + address: 198.51.100.10 + healthcheck: nginx + nginx0-lon: + address: 198.51.100.11 + healthcheck: nginx + nginx0-fra: + address: 198.51.100.12 + healthcheck: nginx + weight: 50 + maildrop0-ams: + address: 2001:db8:1::10 + healthcheck: dovecot + maildrop0-lon: + address: 2001:db8:1::11 + healthcheck: dovecot + + frontends: + nginx-http: + description: "HTTP VIP" + address: 198.51.100.1 + protocol: tcp + port: 80 + backends: [nginx0-ams, nginx0-lon, nginx0-fra] + + nginx-https: + description: "HTTPS VIP — same backends, different port" + address: 198.51.100.1 + protocol: tcp + port: 443 + backends: [nginx0-ams, nginx0-lon, nginx0-fra] + + maildrop-imaps: + description: "IMAPS VIP" + address: 2001:db8::1 + protocol: tcp + port: 993 + backends: [maildrop0-ams, maildrop0-lon] +``` diff --git a/internal/checker/checker.go b/internal/checker/checker.go index 9f10807..249864a 100644 --- a/internal/checker/checker.go +++ b/internal/checker/checker.go @@ -1,3 +1,5 @@ +// Copyright (c) 2026, Pim van Pelt + package checker import ( diff --git a/internal/checker/checker_test.go b/internal/checker/checker_test.go index 748a9e1..92c3ae2 100644 --- a/internal/checker/checker_test.go +++ b/internal/checker/checker_test.go @@ -1,3 +1,5 @@ +// Copyright (c) 2026, Pim van Pelt + package checker import ( diff --git a/internal/config/config.go b/internal/config/config.go index 7e2cbc2..113e18f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1,3 +1,5 @@ +// Copyright (c) 2026, Pim van Pelt + package config import ( diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 646593f..9997686 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -1,3 +1,5 @@ +// Copyright (c) 2026, Pim van Pelt + package config import ( diff --git a/internal/grpcapi/server.go b/internal/grpcapi/server.go index 2c32853..dc08832 100644 --- a/internal/grpcapi/server.go +++ b/internal/grpcapi/server.go @@ -1,3 +1,5 @@ +// Copyright (c) 2026, Pim van Pelt + package grpcapi import ( diff --git a/internal/grpcapi/server_test.go b/internal/grpcapi/server_test.go index b20d9be..97a5b06 100644 --- a/internal/grpcapi/server_test.go +++ b/internal/grpcapi/server_test.go @@ -1,3 +1,5 @@ +// Copyright (c) 2026, Pim van Pelt + package grpcapi import ( diff --git a/internal/health/state.go b/internal/health/state.go index 9ccfaac..4f1601b 100644 --- a/internal/health/state.go +++ b/internal/health/state.go @@ -1,3 +1,5 @@ +// Copyright (c) 2026, Pim van Pelt + package health import ( diff --git a/internal/health/state_test.go b/internal/health/state_test.go index ecd4560..946d963 100644 --- a/internal/health/state_test.go +++ b/internal/health/state_test.go @@ -1,3 +1,5 @@ +// Copyright (c) 2026, Pim van Pelt + package health import ( diff --git a/internal/prober/http.go b/internal/prober/http.go index 9c5d715..fe2f8c4 100644 --- a/internal/prober/http.go +++ b/internal/prober/http.go @@ -1,3 +1,5 @@ +// Copyright (c) 2026, Pim van Pelt + package prober import ( diff --git a/internal/prober/http_test.go b/internal/prober/http_test.go index afcdc5a..c95a97c 100644 --- a/internal/prober/http_test.go +++ b/internal/prober/http_test.go @@ -1,3 +1,5 @@ +// Copyright (c) 2026, Pim van Pelt + package prober import ( diff --git a/internal/prober/icmp.go b/internal/prober/icmp.go index cf8efa9..b8c39ea 100644 --- a/internal/prober/icmp.go +++ b/internal/prober/icmp.go @@ -1,3 +1,5 @@ +// Copyright (c) 2026, Pim van Pelt + package prober import ( diff --git a/internal/prober/netns.go b/internal/prober/netns.go index 7e820cc..72afe31 100644 --- a/internal/prober/netns.go +++ b/internal/prober/netns.go @@ -1,3 +1,5 @@ +// Copyright (c) 2026, Pim van Pelt + package prober import ( diff --git a/internal/prober/prober.go b/internal/prober/prober.go index 7778d1b..3bb656b 100644 --- a/internal/prober/prober.go +++ b/internal/prober/prober.go @@ -1,3 +1,5 @@ +// Copyright (c) 2026, Pim van Pelt + package prober import ( diff --git a/internal/prober/tcp.go b/internal/prober/tcp.go index da8c68c..ad4d6f9 100644 --- a/internal/prober/tcp.go +++ b/internal/prober/tcp.go @@ -1,3 +1,5 @@ +// Copyright (c) 2026, Pim van Pelt + package prober import ( diff --git a/internal/prober/tls.go b/internal/prober/tls.go index 2344d89..8f93083 100644 --- a/internal/prober/tls.go +++ b/internal/prober/tls.go @@ -1,3 +1,5 @@ +// Copyright (c) 2026, Pim van Pelt + package prober import (