Distributed network diagnostics for running ping, traceroute, mtr, HTTP,
DNS, and TCP port probes from managed edge Agents.
Chinese version: README.zh-CN.md
cmd/server: REST API, gRPC Agent control plane, storage, policy, scheduling, and rate limiting.cmd/agent: edge worker. Ingrpcmode it keeps a long-lived control-plane connection to Server;httpexposes a streaming invoke endpoint, whileeventadapts synchronous cloud function events.
For a local SQLite-backed test run:
bootstrap_token_file="$(mktemp)"
chmod 0600 "$bootstrap_token_file"
openssl rand -hex 32 > "$bootstrap_token_file"
MTR_BOOTSTRAP_ADMIN_TOKEN_FILE="$bootstrap_token_file" \
go run ./cmd/server -config configs/server.sqlite.yamlThe file contains the initial admin API token. Use it to sign in and create the
narrower API and Agent register tokens you need. After the persistent SQLite
database has been seeded successfully, stop Server, save any token that must be
retained in an appropriate secret store, delete the bootstrap file, and restart
without MTR_BOOTSTRAP_ADMIN_TOKEN_FILE:
rm -f "$bootstrap_token_file"
unset bootstrap_token_file
go run ./cmd/server -config configs/server.sqlite.yamlServer reads /etc/mtr/server.yaml by default when present, otherwise
configs/server.yaml. Use -config to point at another file.
On first startup, Server creates the SQLite database automatically. With PostgreSQL, it also tries to create the target database and schema when the configured account has permission.
Agents read config from -config, matching Server startup:
./mtr-agent -config /etc/mtr/agent.yamlBoth binaries support -version. Build metadata can be injected with Go
ldflags, for example:
CGO_ENABLED=0 go build -trimpath -ldflags "-X github.com/ztelliot/mtr/internal/version.Version=v1.2.3 -X github.com/ztelliot/mtr/internal/version.Commit=$(git rev-parse --short HEAD) -X github.com/ztelliot/mtr/internal/version.BuiltAt=$(date -u +%Y-%m-%dT%H:%M:%SZ)" ./cmd/serverDocker images accept the same metadata through build args:
docker build -f Dockerfile.server -t mtr-server:v1.2.3 \
--build-arg VERSION=v1.2.3 \
--build-arg COMMIT=$(git rev-parse --short HEAD) \
--build-arg BUILT_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ) .
docker build -f Dockerfile.agent -t mtr-agent:v1.2.3 \
--build-arg VERSION=v1.2.3 \
--build-arg COMMIT=$(git rev-parse --short HEAD) \
--build-arg BUILT_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ) .Agent runtime settings live in the Agent YAML: mode, http_addr, identity,
register token, inbound HTTP authentication, capabilities, protocols, TLS, and
speed-test limits.
protocols is a bitmask: 1 means IPv4, 2 means IPv6, and 3 means both.
Jobs may set ip_version to 4 or 6; tasks are only dispatched to Agents
whose protocol mask supports the requested protocol. protocols and
capabilities must both be explicit and non-empty; zero or an empty list is
rejected instead of being expanded into broader access.
Agent control is intentionally versioned as one complete protocol, without
per-feature negotiation. Every hello, health response, and job request must
carry protocol_version: 1; UDP probes and every advertised HTTP version are
part of that version. Upgrade Server, Agent, and Web together.
Server and Agent config fields can also be supplied through environment
variables. Use the YAML path with an MTR_ prefix and uppercase underscores:
tls.ca_files becomes MTR_TLS_CA_FILES, and speedtest.max_bytes becomes
MTR_SPEEDTEST_MAX_BYTES. Config file values win when both sources set the same
field. String lists may be comma-separated or YAML/JSON arrays.
Unknown YAML keys, structured subfields, and MTR_ environment variable names
are rejected so misspelled security settings cannot silently fall back.
When Server runs behind a reverse proxy, set trusted_proxies to the proxy IPs
or CIDRs allowed to supply proxy headers; otherwise X-Forwarded-For,
X-Real-IP, and client_ip_headers are ignored for logging and rate limiting.
client_ip_headers is an ordered list of custom single-IP headers evaluated
before the standard proxy headers, but only for a peer in trusted_proxies.
Never broaden trusted_proxies unless a firewall or NetworkPolicy makes the
proxy the only process that can reach the listener.
Server runtime policy is stored in persisted managed settings, not in
server.yaml. This includes API tokens, Agent register tokens, rate limits,
global and label-based tool policies, label-based scheduler/runtime knobs,
probe counts, timeouts, and outbound Agents. Per-node tuning is label-based:
every node has the reserved agent and id:<agent-id> labels, so global,
grouped, and single-node rules share one mechanism. An empty store is initialized
only from the token in bootstrap_admin_token_file (or
MTR_BOOTSTRAP_ADMIN_TOKEN_FILE). The file must be regular, no larger than 4
KiB, non-executable, not group-writable, and inaccessible to world users;
modes 0600 and 0440 are accepted.
The token is never written to logs. After a persistent store has been seeded,
remove the file and unset the option. An in-memory store needs the file on every
start because it is not persistent. Use that token from a trusted operator
client with /v1/manage/tokens, /v1/manage/register-tokens, /v1/manage/rate-limit,
/v1/manage/labels, and /v1/manage/agents endpoints to create the tokens and
policy you want. Each Agent register token has a remaining_uses allowance
(new tokens default to 1). The allowance is consumed atomically only when a
previously unseen Agent ID is created; reconnecting an existing Agent does not
consume it and remains possible at zero.
Set allowed_agent_ids on a register token to restrict which exact Agent IDs
it may create. An empty list means the token is unrestricted;
allowlist failures do not consume a use, and already-bound Agents may still
reconnect after the allowlist changes.
An Agent's country, region, provider, and ISP are fixed by its first
registration; later connections may still refresh version, capabilities, and
IPv4/IPv6 protocol support. Use a new Agent ID when those fixed identity fields
must change. Set
log_level: debug in the Server or Agent config for verbose scheduling and
execution logs.
Server can actively invoke outbound Agents created through
/v1/manage/agents. The required transport selects one of two contracts:
http:urlis a base URL. Server readsGET <url>/healthzand streams NDJSON fromPOST <url>/invoke.event:urlis the complete synchronous function-invocation URL. Server postsaction: healthzandaction: invokeevents to that same URL and consumes one complete JSON response.
Authentication is independent of transport. auth.type is explicitly one of
none, bearer, header, or huaweicloud_aksk. The first three cover no
authentication, a static bearer token, and a static custom-header token.
huaweicloud_aksk is the native unattended option for Huawei Cloud
FunctionGraph: Server calculates a new SDK-HMAC-SHA256 signature from the
final method, URL, headers, and JSON body for every health or invocation
request. Set its access_key, secret, and project_id; secret is the Secret
Key and is write-only in management responses. When editing, leaving it empty
retains the existing value only if the transport, URL, authentication fields,
additional headers, and TLS settings are all unchanged; changing any part of
that credential scope requires the Secret Key to be entered again.
Both http:// and https:// outbound URLs are accepted; choosing cleartext
HTTP is a deployment decision that exposes authentication headers and payloads
to the network, so use it only on a suitably trusted path. Non-secret provider
options can be supplied through the separate headers map; authentication and
other sensitive values belong in auth, not in headers. Event responses use
event_response: direct when the function result is the HTTP body, or
result_field when the provider wraps the function result in a top-level
result object or JSON string. The latter matches synchronous Huawei Cloud
FunctionGraph invocation. This deliberately does not accept API Gateway
statusCode/body/isBase64Encoded envelopes.
FunctionGraph also accepts an IAM token in a static X-Auth-Token header, but
that token is valid for only 24 hours and is therefore suitable here only for
manual testing or when an external component refreshes it. The built-in AK/SK
authorizer currently supports long-lived AK/SK credentials only; temporary
credentials with a security token and automatic IAM username/password token
refresh are not supported. Keep Server time synchronized with NTP because
Huawei Cloud validates the generated X-Sdk-Date. See the official
FunctionGraph authentication
and API request signing
documentation.
An outbound Agent endpoint is trusted control-plane configuration and may be
public or private, so Agents inside a VPC or cluster remain reachable. Server
resolves and pins each connection itself, rejects redirects and environment
proxies, and limits every response. Loopback, link-local/metadata, unspecified,
and multicast endpoint addresses remain forbidden. This does not relax job targets: user
probe targets still always reject private, loopback, link-local, metadata, and
other special-use addresses. Event invocation is always single-attempt. HTTP invocation may
retry only a DNS, dial, or TLS failure that occurred before a connection was
obtained; once the request might have started, it is never replayed. A 429
response is treated as temporary backpressure and the job is atomically
requeued.
Server probes an outbound Agent once at startup, periodically refreshes its
metadata at the configured maximum health interval, and uses exponential
health probes only while recovering. Queue polling is separate, so Event
functions are not invoked on every scheduler tick. Listener TLS is controlled
by Agent http_tls; outbound TLS settings live on the managed Agent record.
Without tls.ca_files, TLS uses the system roots and verifies the certificate
hostname/SAN against the URL hostname. With explicit tls.ca_files, Server
instead verifies the chain, validity period, and ServerAuth usage against that
dedicated trust set and intentionally ignores certificate SANs. Use a CA
dedicated to these Agents, or a narrowly shared certificate pin, rather than a
general-purpose trust root. tls.server_name is only an optional SNI routing
hint and never changes certificate identity verification. Any custom CA, client
certificate, or SNI hint requires tls.enabled: true and an https:// URL.
Example Huawei Cloud FunctionGraph Event Agent record:
{
"id": "edge-event-1",
"transport": "event",
"enabled": true,
"url": "https://functiongraph.cn-north-4.myhuaweicloud.com/v2/<project-id>/fgs/functions/<function-urn>/invocations",
"auth": {
"type": "huaweicloud_aksk",
"access_key": "<access-key>",
"secret": "<secret-key>",
"project_id": "<project-id>"
},
"headers": {"X-CFF-Request-Version": "v1"},
"event_response": "result_field",
"labels": ["apac"]
}An Agent exposes the streaming contract when its mode includes http. That
mode requires explicit application-layer authentication: set http_auth.type
to bearer or header and provide a non-empty secret. Use
http_path_prefix to serve those endpoints below a prefix such as /api or
/v1, and include that prefix in the outbound Agent url.
HTTP Agent contract:
mode: "http"
id: "edge-fc-1"
http_addr: ":9000"
http_auth:
type: "bearer"
header: ""
secret: "change-me-http-auth-secret"
http_path_prefix: ""
http_tls:
enabled: true
ca_files:
- "/var/run/mtr/tls/http-client-ca.crt"
cert_file: "/var/run/mtr/tls/http-agent.crt"
key_file: "/var/run/mtr/tls/http-agent.key"To run both the long-lived gRPC Agent and HTTP Agent in one process, use
mode: "grpc,http".
curl -N -X POST http://localhost:9000/invoke \
-H 'Authorization: Bearer change-me-http-auth-secret' \
-H 'Accept: application/x-ndjson' \
-H 'Content-Type: application/json' \
-d '{"protocol_version":1,"id":"job-1","tool":"ping","target":"1.1.1.1","ip_version":4}'For a provider or gateway that uses a static custom header, configure both sides with the same header name and secret. For example, the Agent can use:
http_auth:
type: "header"
header: "X-MTR-Agent-Token"
secret: "change-me-shared-secret"The corresponding Server outbound Agent record must then contain
"auth":{"type":"header","header":"X-MTR-Agent-Token","secret":"change-me-shared-secret"}.
With bearer, Server instead sends Authorization: Bearer <secret>. Server
also accepts these authentication modes over cleartext HTTP, but doing so
exposes the credential and request contents on the network; HTTPS remains the
safe default outside a deliberately trusted path.
The response is compact newline-delimited JSON (application/x-ndjson): each
line is a structured progress, hop, metric, or final summary event. Server
outbound mode restores only the browser-facing envelope needed for routing and
replay, such as job_id and agent_id; it does not repeat task-level fields
like tool, target, or protocol in incremental events. gRPC mode uses the
same compact event shape on the Agent-to-Server path.
Use event mode when a function platform invokes the Agent one request at a
time and waits for a complete response. It listens on :8000 by default and
returns one JSON document from POST /invoke instead of an NDJSON stream. A
lightweight, unauthenticated POST /init endpoint returns {"status":"ok"}
for platforms that perform a container initialization call; platforms without
that lifecycle hook can ignore it. Event mode requires an explicit
http_auth.type: use none only when the function platform's invocation API
or IAM boundary already authenticates every request. bearer requires
Authorization: Bearer <secret>; header requires the configured header and
secret. Both authenticated forms cover every POST /invoke, including the
healthz action. Event mode does not support path prefixes or listener TLS.
configs/agent.event.yaml contains a complete example:
mode: "event"
id: "edge-event-1"
country: "CN"
region: "edge"
provider: "faas"
http_addr: ":8000"
http_auth:
type: "none" # only behind an authenticated invocation boundary
header: ""
secret: ""
http_path_prefix: ""
protocols: 1
capabilities: [ping, traceroute, mtr, http, dns, port]
http_tls:
enabled: false
speedtest:
max_bytes: 0The curl examples below assume an unexposed local test with
http_auth.type: none. For defense in depth, select bearer with
MTR_HTTP_AUTH_TYPE=bearer and MTR_HTTP_AUTH_SECRET=<secret>, or select
header and additionally set MTR_HTTP_AUTH_HEADER. Add the matching header
to both health and job requests. Authentication runs before event action
dispatch, so healthz cannot bypass it.
Send action: healthz to inspect the Agent and its runtime network privileges:
curl -X POST http://localhost:8000/invoke \
-H 'Content-Type: application/json' \
-d '{"protocol_version":1,"action":"healthz"}'Job requests use the existing JobSpec fields and require action: invoke:
curl -X POST http://localhost:8000/invoke \
-H 'Content-Type: application/json' \
-d '{"protocol_version":1,"action":"invoke","id":"job-1","tool":"ping","target":"1.1.1.1","ip_version":4,"args":{"protocol":"udp"},"timeout_seconds":20}'The function returns one application/json document after the job finishes.
Progress events are collected in events, while the final summary is placed in
result:
{
"job_id": "job-1",
"events": [
{"type": "target_resolved", "metric": {"target_ip": "1.1.1.1", "ip_version": 4}},
{"type": "metric", "metric": {"seq": 1, "latency_ms": 27.1}}
],
"result": {
"type": "summary",
"exit_code": 0,
"metric": {"protocol": "udp", "port": 33434, "packets_transmitted": 1, "packets_received": 1}
}
}The http and event modes use different /invoke contracts and therefore
cannot run together in one process.
The same image built from Dockerfile.agent supports grpc, http, and
event modes. It contains only the Agent binary and CA certificates; runtime
configuration is supplied through a mounted YAML file, MTR_* environment
variables, or both. The default config file is optional. The Agent and Server
scratch images run as the unprivileged UID/GID 65532:65532 by default.
Build the image and start an event-mode container locally:
docker buildx build \
--platform linux/amd64 \
--load \
-f Dockerfile.agent \
-t mtr-agent:latest .
docker run --rm -p 127.0.0.1:8000:8000 \
-e MTR_MODE=event \
-e MTR_HTTP_AUTH_TYPE=none \
-e MTR_HTTP_ADDR=:8000 \
-e MTR_ID=edge-event-1 \
-e MTR_COUNTRY=CN \
-e MTR_REGION=edge \
-e MTR_PROVIDER=faas \
-e MTR_PROTOCOLS=1 \
-e MTR_CAPABILITIES=ping,traceroute,mtr,http,dns,port \
mtr-agent:latestFor deployment, configure the platform to send invocation requests to
POST /invoke on port 8000. The same listener provides a lightweight
POST /init response for platforms that enable a separate container
initialization call; no extra adapter is required, and platforms that do not
use initialization can ignore it. The function timeout should exceed the
job's timeout_seconds and leave room for cold start. Provider-specific
envelopes added outside the container must be handled by the caller or a Server
adapter.
To push the image, replace --load with --push and use the desired registry
tag. Current Buildx versions may attach provenance or SBOM attestations as
additional manifests in the image index. If an older registry reports that it
cannot parse the manifest, retry the same single-platform build with
--provenance=false --sbom=false. These compatibility flags remove that
supply-chain metadata, so they should not be the default and do not fix an
architecture mismatch or every registry-side manifest error. Build for the
architecture used by the runtime, usually linux/amd64 or linux/arm64.
Function runtimes often choose the container UID/GID and restrict Linux
capabilities. A Dockerfile cannot restore a capability removed from the runtime
bounding set, and adding a cap_net_raw file capability may cause the binary to
fail at startup. Check network.cap_net_raw in the healthz response instead
of relying on image metadata.
When NET_RAW is unavailable, ping can use udp, tcp, or auto mode. For
traceroute and MTR, use udp: both ICMP probes and TCP probes that discover
intermediate hops need NET_RAW.
UDP ping sends a real UDP datagram. A UDP reply or an ICMP Port Unreachable
response counts as reachability; the default destination port is 33434 and
args.port may override it. Firewalls may silently discard UDP, so explicit
UDP never falls back. Only protocol: auto may try ICMP, then UDP, then TCP;
after the first successful probe it locks that protocol for the rest of the
job. The result summary always includes the actual protocol, and auto results
also include requested_protocol: auto.
healthz also reports network.ping_group_range and whether the kernel permits
unprivileged ICMP Echo datagram sockets for IPv4 and IPv6.
For example, to test ICMP mode on ordinary Docker, run the Agent as root while
dropping every capability except NET_RAW:
docker run --rm -p 8000:8000 \
--user 0:0 \
--cap-drop ALL \
--cap-add NET_RAW \
--security-opt no-new-privileges:true \
-e MTR_ID=edge-docker-1 \
-e MTR_MODE=event \
-e MTR_HTTP_AUTH_TYPE=none \
-e MTR_HTTP_ADDR=:8000 \
-e MTR_PROTOCOLS=1 \
-e MTR_CAPABILITIES=ping,traceroute,mtr,http,dns,port \
mtr-agent:latestIf the runtime forces a non-root user, --cap-add NET_RAW may still leave the
permitted and effective sets empty. The healthz result is authoritative.
Agent speed test endpoint:
curl -H 'Authorization: Bearer change-me-http-auth-secret' -o /dev/null 'http://localhost:9000/speedtest/random?bytes=10485760'The speed test endpoint is only available when HTTP mode is enabled, on
http_addr. It is protected by the Agent http_auth, using the configured
Bearer or custom header. Configure limits under speedtest in the Agent config, or set
speedtest.max_bytes: 0 to disable the endpoint. Speed-test rate limiting uses
the direct remote address and ignores X-Forwarded-For.
Create a job:
curl -X POST http://localhost:8080/v1/jobs \
-H 'Authorization: Bearer developer-token' \
-H 'Content-Type: application/json' \
-d '{"tool":"ping","target":"1.1.1.1"}'ping, traceroute, and mtr accept args.protocol as icmp, udp, tcp,
or auto. The default is strict icmp; an explicit auto request tries
icmp, then udp, then tcp, and locks the first protocol that responds. UDP
defaults to port 33434, while TCP defaults to port 80; args.port
overrides either. UDP route probes use the Linux socket error queue and do not
require a raw socket; UDP route probing is currently supported only on Linux.
TCP route probes correlate the quoted TCP flow in raw ICMP Time Exceeded
messages and treat either a completed connect or a destination RST as reached.
This implementation is Linux-only and needs NET_RAW to collect intermediate
hops.
http accepts args.http_version as 1.1, 2, 3, or auto. The default is
strict HTTP/1.1; auto tries HTTP/3, HTTP/2, then HTTP/1.1 and only falls back
before a response has started. HTTP/2 and HTTP/3 require HTTPS, and HTTP/3
requires outbound UDP access. Results report the selected protocol in
protocol or http_version, plus requested_protocol or
requested_http_version when auto was requested.
count and max_hops are controlled by Server runtime policy, not by user
requests. dns replaces the old nslookup tool name. port performs a native
TCP connect probe and requires args.port. By default, Server resolves target
hostnames before queuing jobs; set resolve_on_agent: true to defer DNS
resolution and resolved-IP policy checks to the Agent. Fan-out ping, dns,
port, and http jobs may set agent_tags to run only on Agents matching any
selected managed label; omitting it keeps the existing all-eligible-Agent
behavior. Tag filtering is intersected with the API token scope and the normal
online, capability, and per-Agent policy checks. Public agent_tags values
must not be agent or contain : or -; those forms are reserved for
server-managed labels and are rejected by the API.
Create a scheduled detection task:
curl -X POST http://localhost:8080/v1/schedules \
-H 'Authorization: Bearer developer-token' \
-H 'Content-Type: application/json' \
-d '{"name":"cf-ping","tool":"ping","target":"1.1.1.1","cron_expression":"*/5 * * * *","schedule_targets":[{"label":"agent"}]}'Scheduled jobs select nodes with schedule_targets. Every Agent gets the
server-managed agent label plus id:<agent-id>. Custom labels are set in
server-side Agent or outbound Agent config. Use agent for all nodes, a
custom label for a group, or id:<agent-id> for one node.
cron_expression uses the standard five fields minute hour day month weekday
and is evaluated in UTC. All selected targets run together at each cron tick.
The server stores the creator's Agent scope as selector rules on each schedule
target. Labels and unrestricted scopes are re-evaluated against the current
Agent set on every cron tick, so newly added matching Agents are included while
fixed allowlists and deny rules remain enforced. Schedule and job read endpoints
are intentionally shareable to any token with the corresponding read permission.
Query scheduled task history:
curl -H 'Authorization: Bearer developer-token' \
http://localhost:8080/v1/schedules/<schedule-id>/historyStream structured job events for browser UI:
const response = await fetch('/v1/jobs/<job-id>/stream', {
headers: {Authorization: 'Bearer developer-token', Accept: 'text/event-stream'}
})
for await (const chunk of response.body.pipeThrough(new TextDecoderStream())) {
console.log(chunk) // parse the standard SSE frames incrementally
}API tokens are accepted only through the Bearer header. The Web workbench uses
authenticated fetch streaming because native EventSource cannot attach that
header; tokens are never placed in stream URLs.
The project includes an independent React SPA in web/ for browser-based
diagnostics. It uses Vite, React, TypeScript, and pnpm.
Install dependencies:
pnpm --dir web installConfigure the runtime API connection in web/public/config.json:
{
"apiBaseUrl": "",
"apiToken": "REPLACE_WITH_PUBLIC_API_TOKEN",
"brand": "QwQ MTR",
"brandUrl": null
}An empty apiBaseUrl works with the Vite development proxy. Replace
REPLACE_WITH_PUBLIC_API_TOKEN with a Server-issued public diagnostics token
before opening the workbench. Initialize a fresh Server with the bootstrap-token
procedure in Quick Start (an initialized store does not need that file), then
start Server and the frontend in separate terminals:
go run ./cmd/server -config configs/server.sqlite.yaml
pnpm --dir web devProduction build:
pnpm --dir web typecheck
pnpm --dir web test
pnpm --dir web buildBuild the frontend container image:
docker build -f Dockerfile.web -t mtr-web:v1.2.3 --build-arg VERSION=v1.2.3 .Pass COMMIT to include the same short revision shown by the Server:
docker build -f Dockerfile.web -t mtr-web:v1.2.3 \
--build-arg VERSION=v1.2.3 \
--build-arg COMMIT=$(git rev-parse HEAD) .The frontend image serves the Vite build with Caddy on unprivileged port 8080
and runs as UID/GID 65532:65532. At runtime it loads /config.json, so
deployments can replace that single file without rebuilding the image. The
image proxies same-origin /v1 requests to MTR_SERVER_URL, which defaults to
http://mtr-server:8080. Leave apiBaseUrl empty for that recommended layout,
or set it to a browser-reachable Server API origin with the required
cross-origin policy:
{
"apiBaseUrl": "https://mtr-api.example.com",
"apiToken": "REPLACE_WITH_PUBLIC_API_TOKEN",
"brand": "QwQ MTR",
"brandUrl": "https://mtr.example.com"
}Omit brandUrl to keep the header brand as an in-app home link, set it to a
URL to send every deployment to one canonical brand address, or set it to
null/"" to render the brand without a link.
The Web client reads apiToken from config.json and automatically sends it as
an Authorization: Bearer header on API and streaming requests, so visitors do
not need to enter a token. This value is a public client credential: every
browser user can read it from /config.json or developer tools. Give it only
the minimum tools and Agent scope needed for public diagnostics, with
all: false, schedule_access: none, and manage_access: none. Never publish
the bootstrap/admin token, a management token, or a schedule-write token here.
Assume the public token can be copied and abused; Server authorization and rate
limits are the security boundary.
For a container deployment, prepare that JSON file outside the repository and
mount it over the image default; it must be readable by UID 65532:
docker run --rm -p 8081:8080 \
--mount type=bind,src=/absolute/path/config.json,dst=/usr/share/caddy/config.json,readonly \
ghcr.io/ztelliot/mtr-web:0.1.0The Kubernetes examples include deploy/k8s/web.yaml, which deploys
ghcr.io/ztelliot/mtr-web:0.1.0, exposes it as the mtr-web Service, and
mounts mtr-web-config over /usr/share/caddy/config.json. The 0.1.0 image
names are release targets, not an assertion that the public registry already
contains them. Publish those images or load locally built images into the
cluster first. Before exposing Web, replace
REPLACE_WITH_PUBLIC_API_TOKEN in the ConfigMap with a public diagnostics token.
It intentionally remains a ConfigMap rather than a Secret because the browser
must receive the value and cannot keep it confidential. For a local cluster
smoke test, create the Secrets described in the Kubernetes deployment section
and apply the baseline resources:
kubectl apply -k deploy/k8s
kubectl -n mtr port-forward svc/mtr-web 8081:80If you access the workbench at http://localhost:8081, leave apiBaseUrl
empty: the Web Pod proxies /v1 to the in-cluster mtr-server Service, and the
client uses the preconfigured public token automatically. Override
MTR_SERVER_URL on the Web container if the internal Server address differs.
The baseline Caddy proxy overwrites X-Mtr-Proxy-Client-Ip with its direct
peer address. Server trusts that header only because mtr-server-ingress
allows HTTP traffic solely from the Web Pod. This trust model is safe only when
the cluster CNI actually supports and enforces NetworkPolicy. If it does not,
replace the 0.0.0.0/0 and ::/0 trusted ranges with the real Web Pod CIDR or
another narrowly scoped proxy range before deployment. If you add an Ingress,
an external Agent, or another Server client, update the NetworkPolicy and
trusted proxy chain together; never expose this baseline broad trusted-proxy
setting without the network isolation. An Ingress is not a drop-in addition:
the baseline policy blocks a controller from reaching Server directly, and
Caddy derives the client header from its direct peer. To retain the end-user IP
behind an Ingress, explicitly configure Caddy to trust only that controller's
forwarding headers before extending the NetworkPolicy.
The deploy/k8s/agent.yaml DaemonSet can derive per-node Agent metadata from
Kubernetes Node annotations without teaching the Agent binary about Kubernetes.
An init container reads the current Node annotations, renders a normal
agent.yaml into an emptyDir, and the Agent container starts with that
generated config. The ServiceAccount only needs read-only get nodes permission
for the init container. By default, the init container builds the API address
from Kubernetes' injected KUBERNETES_SERVICE_HOST and
KUBERNETES_SERVICE_PORT_HTTPS variables. Set the optional
MTR_KUBERNETES_API_SERVER override only when the cluster requires a different
address. The example maps these annotations by default:
kubectl annotate node <node> \
mtr.ztelliot.dev/country=JP \
mtr.ztelliot.dev/region='Tokyo East' \
mtr.ztelliot.dev/provider=kubernetes \
mtr.ztelliot.dev/isp=example-net \
mtr.ztelliot.dev/protocols=3 \
mtr.ztelliot.dev/hide-first-hops=0 \
mtr.ztelliot.dev/capabilities=ping,traceroute,mtr,http,dns,portChange render-agent-config.sh in mtr-agent-config if your cluster already
uses different annotation names. Schedule labels are managed on the server, not
reported by the Agent. protocols uses the same bitmask as Agent config: 1
for IPv4, 2 for IPv6, and 3 for both. capabilities is a comma-separated
tool list. If an annotation is missing, the init container writes the fallback
value into the generated config.
The workbench can create ping, traceroute, mtr, http, dns, and
port jobs, list Agents, and stream structured job events from
/v1/jobs/<job-id>/stream.
Fan-out tools expose a node-set selector backed by managed Agent tags; an empty
selection means every eligible node, while multiple tags use OR semantics.
Ad-hoc traceroute and mtr jobs require an explicit Agent selection because
the server requires agent_id for those tools. Scheduled jobs use
schedule_targets labels instead of a single agent_id.
The Server writes one structured JSON access log after every HTTP request,
including public endpoints, authenticated APIs, rejected authentication,
rate-limited requests, unmatched routes, /metrics, and long-lived streams when
they close. Access records use the http access message and include:
request_id, method, route template, escaped path, status, and duration;- request bytes read, response bytes written, declared content length, and request/response content types;
- resolved client IP, remote address, host, protocol, user agent, authentication result, and the hashed API-token subject;
- a
panickedflag so failed handlers remain visible even if the HTTP server recovers the panic outside the router.
The Server accepts a safe X-Request-ID value and returns it in the response;
otherwise it generates a UUID. Query strings are intentionally excluded from
access records because arbitrary application parameters may contain sensitive
values. Set log_level: info or debug to emit access logs.
The Server exposes Prometheus metrics at /metrics so Watch history and alerts
can move to Prometheus and Grafana. Each scrape exports the latest completed
result for every Agent in the current schedule revision. A new run that is still
active does not temporarily hide the previous completed result. Agent
online/enabled state, schedule crontab metadata, and configured node labels are
also exported.
The same endpoint exports HTTP access statistics:
mtr_http_requests_totalby normalized method, route template, status code, and authentication result;mtr_http_request_duration_seconds,mtr_http_request_size_bytes, andmtr_http_response_size_byteshistograms by method and route;mtr_http_requests_in_flightby method.
Unknown methods are labeled OTHER and unmatched routes are labeled
unmatched, preventing raw paths from creating unbounded Prometheus series.
Standard go_* runtime and process_* process metrics are exported as well.
When API tokens are configured, Prometheus must send a token with
schedule_access: read (or write). For example:
scrape_configs:
- job_name: mtr-watch
metrics_path: /metrics
scheme: https
authorization:
type: Bearer
credentials_file: /etc/prometheus/mtr-watch.token
static_configs:
- targets: [mtr-server.example:8080]Set scrape_interval often enough to capture the Watch cron cadence. The
exporter exposes the latest completed state, so Prometheus cannot backfill
intermediate runs when several finish between two scrapes.
The primary metric groups are:
- Common result state:
mtr_watch_result_success,mtr_watch_result_timestamp_seconds, andmtr_watch_result_duration_seconds. - Ping packet loss/count/RTT, HTTP phase timing/status/download size, DNS record count, and TCP port state/connect timing.
- Traceroute/MTR per-hop RTT/loss/probe count plus complete route information.
Prometheus sample values are numeric only. Text is represented by info metrics
whose value is always 1: mtr_watch_dns_record_info stores DNS records,
mtr_watch_route_path_info stores complete paths, and
mtr_watch_route_hop_info stores hop addresses. Text labels include a stable
hash; values longer than 2048 characters are shortened and marked with
truncated="true". Per-run job_id values are intentionally never used as
labels.
register_token covers Agent registration authorization, while
tls.ca_files, tls.cert_file, and tls.key_file protect the gRPC control
plane between Agent and Server. To enable mutual TLS:
- Agent may set
tls.enabled: truewithoutca_filesto use the operating system trust store. This is useful when connecting to a public TLS endpoint such as a Cloudflare-proxied hostname. - Server must set
tls.ca_files,tls.cert_file, andtls.key_filetogether. If only the server certificate/key are configured, the channel is TLS but Agents are not required to present a client certificate. - Agent must also set
tls.ca_files,tls.cert_file, andtls.key_filetogether. If onlyca_filesis set, the Agent verifies the Server but does not present its own client certificate. - In production, use both
register_tokenand mTLS: token-based registration for logical authorization, mTLS for transport-level mutual authentication.
The current implementation verifies that the client certificate chains back to
the configured CA, but it does not bind one certificate to one fixed Agent ID.
That means multiple Agents may share the same client certificate, and the
Kubernetes example manifests do exactly that. Shared certificates are fine as
long as each Agent still has a unique id and you accept that certificate
rotation becomes an all-Agents operation if one pod is compromised.
Generate a CA, a Server certificate, and one shared client certificate with OpenSSL:
mkdir -p certs
openssl genrsa -out certs/ca.key 4096
openssl req -x509 -new -nodes -key certs/ca.key -sha256 -days 3650 \
-out certs/ca.crt -subj "/CN=mtr-ca"
cat > certs/server.ext <<'EOF'
subjectAltName=DNS:mtr-server,DNS:mtr-server.mtr.svc,DNS:mtr-server.mtr.svc.cluster.local
extendedKeyUsage=serverAuth
EOF
openssl genrsa -out certs/server.key 4096
openssl req -new -key certs/server.key -out certs/server.csr \
-subj "/CN=mtr-server.mtr.svc.cluster.local"
openssl x509 -req -in certs/server.csr -CA certs/ca.crt -CAkey certs/ca.key \
-CAcreateserial -out certs/server.crt -days 825 -sha256 -extfile certs/server.ext
cat > certs/agent.ext <<'EOF'
extendedKeyUsage=clientAuth
EOF
openssl genrsa -out certs/agent-shared.key 4096
openssl req -new -key certs/agent-shared.key -out certs/agent-shared.csr \
-subj "/CN=mtr-agent-shared"
openssl x509 -req -in certs/agent-shared.csr -CA certs/ca.crt -CAkey certs/ca.key \
-CAcreateserial -out certs/agent-shared.crt -days 825 -sha256 -extfile certs/agent.extIf Agents connect to the Server with a different DNS name or an IP address,
add the matching DNS: or IP: entries to subjectAltName.
Server and Agent TLS config example:
tls:
enabled: true
ca_files:
- "/var/run/mtr/tls/ca.crt"
cert_file: "/var/run/mtr/tls/tls.crt"
key_file: "/var/run/mtr/tls/tls.key"The gRPC control plane can be placed behind Cloudflare's proxied gRPC support
when the Cloudflare requirements are met: enable gRPC for the zone, use a
proxied hostname, keep SSL/TLS mode at least Full, and expose the origin gRPC
endpoint on port 443 with TLS and HTTP/2/ALPN. This project uses grpc-go over
HTTP/2 and sends application/grpc+json, which matches Cloudflare's accepted
gRPC content-type pattern.
One practical layout:
# server.yaml on the origin
grpc_addr: ":443"
tls:
enabled: true
cert_file: "/var/run/mtr/tls/tls.crt"
key_file: "/var/run/mtr/tls/tls.key"
# agent.yaml
server_addr: "grpc.example.com:443"
tls:
enabled: trueTo support both direct Agents and Cloudflare-proxied Agents on the same origin, configure Cloudflare Authenticated Origin Pulls (prefer a zone-level or per-hostname custom certificate for account-specific authentication), then trust both the direct Agent CA and the Cloudflare origin-pull CA:
# server.yaml on the origin
grpc_addr: ":443"
tls:
enabled: true
ca_files:
- "/var/run/mtr/tls/agent-ca.crt"
- "/var/run/mtr/tls/cloudflare-origin-pull-ca.crt"
cert_file: "/var/run/mtr/tls/tls.crt"
key_file: "/var/run/mtr/tls/tls.key"Direct Agents keep their client certificate config and connect to the origin
address. Cloudflare-proxied Agents connect to the proxied hostname and normally
do not set Agent cert_file/key_file; Cloudflare presents the origin-pull
client certificate to Server instead. Keep register_token strong because
Cloudflare terminates the Agent-facing TLS connection and normal Agent client
certificates are not passed through the reverse proxy. Cloudflare Access does
not protect gRPC traffic through this reverse-proxy mode; use another
authentication layer for sensitive origins. Cloudflare Tunnel public hostnames
are also not the same thing as proxied gRPC here; Cloudflare documents gRPC support for Tunnel
via private subnet routing, not public hostname deployments.
Production deployments should place Anubis or another browser-facing gateway in front of the REST API for PoW/challenge handling, set mTLS certificate paths in both configs, rotate API and Agent tokens, and tune per-tool policies before exposing the service.
Agents execute diagnostics with native Go implementations and only upload structured result events.
Connection-oriented probes reject targets that resolve to loopback, private,
link-local, multicast, carrier-grade NAT, documentation, benchmarking, or other
special-use address ranges at both Server and Agent. The dns tool is
deliberately different: it queries the requested record name without first
connecting to the returned address, so it can reveal private records visible to
the Agent's resolver. Treat DNS tool permission as access to that resolver's
namespace and scope public tokens accordingly.
The Agent image only needs the compiled Agent binary and CA certificates; diagnostic tools are implemented in Go.
For container deployments, prefer a non-root user, use a read-only root
filesystem, disable privilege escalation, and explicitly reduce Linux
capabilities. Server does not need extra capabilities. Agent needs NET_RAW
for ICMP ping and for ICMP or TCP traceroute/MTR; UDP probes and TCP ping do
not need it.
The Server example uses 65532:65532 as an unprivileged container UID/GID. The
ICMP Agent example uses UID 0 with every capability dropped except NET_RAW,
because Docker does not place --cap-add NET_RAW in a non-root process's
permitted/effective sets. An Agent restricted to UDP/TCP ping can instead use
the image default UID/GID 65532:65532 and omit --cap-add. Mounted config and
certificate files must be readable by the selected UID/GID. For private keys,
prefer mode 0640 with the matching group, or use Docker/Compose secrets. The
published container images do not include Server or Agent runtime config; mount
those files explicitly. Before the first Server start, place a random token in
/etc/mtr/bootstrap/admin-token, make it readable by UID/GID 65532 but not by
other users, and remove it after the persistent store is initialized. The
sample PostgreSQL DSN in configs/server.yaml names
/var/run/mtr/postgres/ca.crt as sslrootcert, so the example also mounts that
CA at the exact path; make the host file readable by UID/GID 65532. Omit this
mount only when using SQLite or a PostgreSQL setup whose DSN uses another
verified CA location.
docker network create mtr-net
docker run -d --name mtr-server \
--network mtr-net \
--user 65532:65532 \
--cap-drop ALL \
--security-opt no-new-privileges:true \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m \
--pids-limit 256 \
--cpus 1 \
--memory 512m \
-p 8080:8080 \
-p 8443:8443 \
-e MTR_BOOTSTRAP_ADMIN_TOKEN_FILE=/var/run/mtr/bootstrap/admin-token \
-v /etc/mtr/server.yaml:/etc/mtr/server.yaml:ro \
-v /etc/mtr/bootstrap:/var/run/mtr/bootstrap:ro \
-v /etc/mtr/postgres/ca.crt:/var/run/mtr/postgres/ca.crt:ro \
-v /etc/mtr/tls/server:/var/run/mtr/tls:ro \
ghcr.io/ztelliot/mtr-server:0.1.0
docker run -d --name mtr-agent \
--network mtr-net \
--user 0:0 \
--cap-drop ALL \
--cap-add NET_RAW \
--security-opt no-new-privileges:true \
--read-only \
--tmpfs /tmp:rw,noexec,nosuid,nodev,size=64m \
--pids-limit 256 \
--cpus 1 \
--memory 512m \
-v /etc/mtr/agent.yaml:/etc/mtr/agent.yaml:ro \
-v /etc/mtr/tls/agent:/var/run/mtr/tls:ro \
ghcr.io/ztelliot/mtr-agent:0.1.0If Server uses SQLite, add a writable state mount and point database_url at a
file under that directory:
-v /var/lib/mtr:/var/lib/mtr \
--workdir /var/lib/mtrThe Agent in gRPC mode initiates the long-lived connection to Server and does
not need an exposed port. Set the Agent's unique id, server_addr, and
register_token in /etc/mtr/agent.yaml; config file values take precedence
over MTR_ environment variables. Only add -p 9000:9000 when running
mode: http or mode: grpc,http and intentionally exposing /invoke, ideally
behind a controlled gateway. Avoid --network host unless you explicitly need
the host network perspective.
The units in systemd/mtr-server.service and systemd/mtr-agent.service now
include a baseline sandbox. Both run as User=mtr/Group=mtr, use read-only
system paths, private /tmp, no privilege escalation, restricted address
families, and a system call filter. Server keeps an empty capability set.
Agent keeps only CAP_NET_RAW through CapabilityBoundingSet and
AmbientCapabilities.
Create a dedicated system user and keep configs/certificates readable only by
root and the mtr group:
useradd --system --home /var/lib/mtr --shell /usr/sbin/nologin mtr
install -d -o mtr -g mtr -m 0750 /var/lib/mtr
install -d -o root -g mtr -m 0750 /etc/mtr /etc/mtr/tls /etc/mtr/bootstrap
install -o root -g mtr -m 0640 configs/server.yaml /etc/mtr/server.yaml
install -o root -g mtr -m 0640 configs/agent.yaml /etc/mtr/agent.yaml
install -o root -g mtr -m 0644 certs/ca.crt /etc/mtr/tls/ca.crt
install -o root -g mtr -m 0640 certs/server.crt /etc/mtr/tls/server.crt
install -o root -g mtr -m 0640 certs/server.key /etc/mtr/tls/server.key
install -o root -g mtr -m 0640 certs/agent-shared.crt /etc/mtr/tls/agent.crt
install -o root -g mtr -m 0640 certs/agent-shared.key /etc/mtr/tls/agent.key
install -o root -g root -m 0755 mtr-server /usr/local/bin/mtr-server
install -o root -g root -m 0755 mtr-agent /usr/local/bin/mtr-agent
install -o root -g root -m 0644 systemd/mtr-server.service /etc/systemd/system/mtr-server.service
install -o root -g root -m 0644 systemd/mtr-agent.service /etc/systemd/system/mtr-agent.service
umask 0077
openssl rand -hex 32 > /etc/mtr/bootstrap/admin-token
chown root:mtr /etc/mtr/bootstrap/admin-token
chmod 0640 /etc/mtr/bootstrap/admin-token
systemctl daemon-reload
systemctl enable --now mtr-server
systemctl enable --now mtr-agentThe Server unit points at this bootstrap file. After the persistent store is
initialized and the required managed tokens are saved, remove
/etc/mtr/bootstrap/admin-token; an initialized store continues to start
without it.
If Server uses SQLite, put the database under /var/lib/mtr/. The Server unit
sets WorkingDirectory=/var/lib/mtr and StateDirectory=mtr, making that the
intended persistent writable state directory.
Use systemd's analyzer to inspect the resulting sandbox score and remaining risk:
systemd-analyze security mtr-server.service
systemd-analyze security mtr-agent.serviceThe repository now includes a baseline manifest set under deploy/k8s/:
server.yaml: single-replica ServerDeploymentplusServiceagent.yaml: AgentDaemonSet, with each pod using its own pod name asMTR_IDnetworkpolicy.yaml: denies inbound Agent traffic and restricts Server HTTP and gRPC ingress to the baseline Web and Agent podssecrets.example.yaml: Secret templates with placeholders you should replacekustomization.yaml: baseline resources forkubectl apply -k deploy/k8s; it intentionally does not includesecrets.example.yaml
These manifests assume:
- PostgreSQL is provided externally. Its TLS-verified
database-urlis injected throughmtr-server-env, and its CA certificate is mounted frommtr-server-postgres-ca. - The gRPC control plane uses
mtr-server.mtr.svc.cluster.local:8443, so the example Server certificate SANs match that service DNS name. - All Agents share one client certificate, while each pod keeps a unique logical identity through
MTR_ID=metadata.name. - Agent pods read their registration token from the
mtr-agent-envSecret, and that value must match an Agent register token stored in Server managed settings. - Server stays at
replicas: 1. The scheduler hub holds in-process connection state, so this baseline does not claim horizontal control-plane scaling. - API tokens, Agent register tokens, rate limits, global settings, label-based scheduler/runtime settings, label policies, and outbound Agents are stored in Server managed settings. They are not read from the Kubernetes Server ConfigMap or Secret.
- A fresh store reads its initial admin API token once from the optional
mtr-server-bootstrapSecret. The token is never printed to Server logs. - The CNI supports and enforces NetworkPolicy. Without that enforcement, replace the Server ConfigMap's broad trusted-proxy ranges with the actual Web Pod CIDR or another narrowly scoped proxy range before applying the manifests.
Bootstrap is intentionally staged: create only the Server prerequisites and
start Server first. Use the initial admin token from a trusted local client to
create both a public Web diagnostics token and an Agent register token. Only
then configure Web and deploy the Web and Agent workloads. This avoids
publishing an admin token to browsers or requiring managed tokens before Server
exists. You can adapt the placeholders in deploy/k8s/secrets.example.yaml, or
create the first-stage Secrets directly:
kubectl apply -f deploy/k8s/namespace.yaml
kubectl -n mtr create secret generic mtr-server-env \
--from-literal=database-url='postgres://mtr:mtr@postgres:5432/mtr?sslmode=verify-full&sslrootcert=/var/run/mtr/postgres/ca.crt'
kubectl -n mtr create secret generic mtr-server-postgres-ca \
--from-file=ca.crt=certs/postgres-ca.crt
umask 077
openssl rand -hex 32 > admin-token
kubectl -n mtr create secret generic mtr-server-bootstrap \
--from-file=admin-token=admin-token
kubectl -n mtr create secret generic mtr-server-tls \
--from-file=ca.crt=certs/ca.crt \
--from-file=tls.crt=certs/server.crt \
--from-file=tls.key=certs/server.key
kubectl -n mtr create secret generic mtr-agent-tls \
--from-file=ca.crt=certs/ca.crt \
--from-file=tls.crt=certs/agent-shared.crt \
--from-file=tls.key=certs/agent-shared.keyApply only Server in the first stage, then keep a local port-forward open in one terminal:
kubectl apply -f deploy/k8s/server.yaml
kubectl -n mtr rollout status deployment/mtr-server
kubectl -n mtr port-forward service/mtr-server 8080:8080The value in the local admin-token file is the initial admin API token. In
another terminal, create a deliberately narrow public token (this example
allows only ping) and the rollout's Agent register token:
ADMIN_TOKEN="$(tr -d '\r\n' < admin-token)"
curl --fail-with-body -X POST http://127.0.0.1:8080/v1/manage/tokens \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-H 'Content-Type: application/json' \
-d '{"name":"public-web","all":false,"schedule_access":"none","manage_access":"none","agents":["*"],"tools":{"ping":{}}}' \
> /tmp/mtr-public-web-token.json
curl --fail-with-body -X POST http://127.0.0.1:8080/v1/manage/register-tokens \
-H "Authorization: Bearer ${ADMIN_TOKEN}" \
-H 'Content-Type: application/json' \
-d '{"name":"k8s-agents","remaining_uses":3}' \
> /tmp/mtr-agent-register-token.json
unset ADMIN_TOKENThe generated values are revealed once as .token.secret in the first response
and .token.token in the second. Adjust the public token's tools and Agent scope
to the deployment, and set remaining_uses and allowed_agent_ids for the
actual rollout. Put the public value into a private copy or overlay of
mtr-web-config; never put the admin token there. Store the register value in
the Agent Secret, then deploy the remaining workloads:
kubectl -n mtr create secret generic mtr-agent-env \
--from-literal=register-token='<managed-register-token>'
kubectl apply -f '<private-web-manifest>' \
-f deploy/k8s/agent.yaml \
-f deploy/k8s/networkpolicy.yaml
kubectl -n mtr rollout status deployment/mtr-webAfter the persistent database has been initialized and the required managed
tokens have been saved, delete the local admin and one-time response files and
the bootstrap Secret; the optional volume may remain absent on later restarts.
Future reconciliations may use a private Kustomize overlay based on
deploy/k8s; do not reapply the checked-in Web placeholder over the deployed
public token.
The Agent manifest is intentionally tight on privileges:
- the main Agent container does not mount a ServiceAccount token
- no host network/PID/IPC and
privileged: false - read-only root filesystem, with only read-only config/cert mounts and one
emptyDirmounted at/tmp - all Linux capabilities dropped except
NET_RAW - privilege escalation disabled and the runtime-default seccomp profile enabled
- a NetworkPolicy denies inbound traffic to Agent pods
NET_RAW is the one deliberate exception because the current ICMP-backed
implementations of ping, traceroute, and mtr need raw sockets. The main
Agent container runs as UID 0 so NET_RAW can enter the permitted and effective
capability sets, but it is not privileged and all other capabilities are
dropped. If your cluster enforces strict Pod Security Admission, plan for an
explicit exception for this Agent security context. The Agent logs CapEff,
CapBnd, NoNewPrivs, and Seccomp at startup to make the effective runtime
privileges visible.
The DaemonSet example has one narrow RBAC rule for the init container that
renders per-node config from Node annotations: get nodes. The generated config
is written to an emptyDir; the main Agent container then runs without the
ServiceAccount token mounted.
The baseline does not include a default-deny egress NetworkPolicy. The Agent
is supposed to probe arbitrary external targets, and standard Kubernetes
NetworkPolicy support for ICMP is limited; if your CNI offers richer ICMP or
egress controls, it is worth tightening that layer further.
FaaS deployment assets live in deploy/fc/. The checked-in targets.json
describes public target metadata and placeholder runtime settings; generate.mjs
turns that file into provider-specific deployment templates.
The public template selects header authentication with
X-MTR-Agent-Token. Set its secret only in your local targets file, then use
the same header name and secret in the Server outbound Agent's auth object.
Supported targets:
aliyun: Alibaba Cloud Function Compute, using the Serverless Devsfc3component.ctyun: CTYun Function Service, using the Serverless Devsfaas-cfcomponent.qcloud: Tencent Cloud SCF, using Serverless Cloud Framework.gcloud: Google Cloud Run, usinggcloud alpha run deploy.
Keep real deployment values out of targets.json. For production, copy it to a
local file such as .targets.json, fill in values like MTR_HTTP_AUTH_SECRET,
MTR_HTTP_PATH_PREFIX, Alibaba Cloud log project IDs, and private image
locations, then generate templates from that local file:
cd deploy/fc
node generate.mjs --config .targets.jsonYou can also select the config with FC_TARGETS_FILE. When the generator reads
the default targets.json, it rejects values that look like real tokens, path
prefixes, or Alibaba Cloud log project IDs. This keeps the public template safe
even if someone edits it by accident.
Set binary.path to point at a built local Agent binary, or configure
binary.fromImage to extract the binary from a Docker image. Generated output
is written under deploy/fc/output/:
output/binary/agent
output/s.aliyun.yaml
output/s.ctyun.yaml
output/qcloud/code/scf_bootstrap
output/qcloud/<target-key>/serverless.yml
output/qcloud/deploy.sh
output/gcloud/deploy.shThe FaaS Agent does not read config.yaml. It is configured entirely through
environment variables. Every generated target receives MTR_COUNTRY, MTR_ID,
MTR_ISP, TZ, and MTR_REGION; runtime settings come from top-level env
and may be overridden by provider-level or target-level env objects.
To extract the Agent binary from the published container image:
{
"binary": {
"fromImage": {
"image": "ghcr.io/ztelliot/mtr-agent:dev",
"path": "/usr/local/bin/mtr-agent",
"platform": "linux/amd64",
"pull": true
}
}
}When binary.fromImage is set, generate.mjs runs docker pull, creates a
temporary container, copies the binary out, and writes it to
output/binary/agent by default. Use binary.path when you already have a
local binary:
{
"binary": {
"path": ".cache/mtr-agent"
}
}binary.path and binary.fromImage are mutually exclusive. Image extraction
always stages the executable under output/binary; every provider output is
also required to remain below output/.
Set binary.fromImage.pull to false when the image is already present
locally.
Deploy Alibaba Cloud and CTYun templates with Serverless Devs:
cd output
s deploy -t s.aliyun.yaml
s deploy -t s.ctyun.yamlTencent Cloud SCF is generated as one directory per target because Serverless
Cloud Framework expects each function to own its serverless.yml. Shared code
lives in output/qcloud/code; the Agent binary is copied to scf_bootstrap,
the entry filename expected by SCF:
output/qcloud/code/scf_bootstrap
output/qcloud/example/serverless.yml
Deploy Tencent Cloud targets through the generated script, which enters each
target directory and runs scf deploy:
cd output
qcloud/deploy.shGoogle Cloud Run also uses a generated script:
cd output
gcloud/deploy.shAlibaba Cloud logging is opt-in per target. Set log: true and provide
providers.aliyun.logProjectId in the local config:
{
"env": {
"MTR_MODE": "http",
"MTR_LOG_LEVEL": "info",
"MTR_HTTP_AUTH_TYPE": "header",
"MTR_HTTP_AUTH_HEADER": "X-MTR-Agent-Token",
"MTR_HTTP_AUTH_SECRET": "<http-auth-secret>",
"MTR_HTTP_ADDR": ":9000",
"MTR_HTTP_PATH_PREFIX": "/<path-prefix>",
"MTR_PROTOCOLS": 1,
"MTR_HIDE_FIRST_HOPS": 0,
"MTR_CAPABILITIES": "ping,traceroute,mtr,http,dns,port",
"MTR_SPEEDTEST_MAX_BYTES": 0,
"MTR_HTTP_TLS_ENABLED": false
},
"providers": {
"aliyun": {
"name": "mtr-agent-aliyun",
"output": "s.aliyun.yaml",
"src": "./binary",
"access": "Aliyun",
"logProjectId": "<log-project-id>",
"targets": [
{
"key": "example",
"region": "<aliyun-region>",
"functionName": "mob-example",
"country": "CN",
"id": "ali.example.fc",
"label": "Example",
"log": true
}
]
}
}
}If log is omitted or false, no logConfig is generated. When enabled, the SLS
project name is serverless-{region}-{providers.aliyun.logProjectId}. Set
logStore on a target to override the default default-logs logstore.
Alibaba Cloud targets use the Go official layer by default:
acs:fc:{region}:official:layers/Go1/versions/1
Set layer to python-flask in regions where the Go layer is unavailable:
{
"key": "example",
"region": "<aliyun-region>",
"functionName": "mob-example",
"country": "XX",
"id": "ali.example.fc",
"label": "Example",
"layer": "python-flask"
}The runtime remains custom.debian10; only the generated layer ARN changes:
acs:fc:{region}:official:layers/Python3-Flask2x/versions/2
Set layer to null, false, or an empty string to omit the layers field:
{
"key": "example",
"region": "<aliyun-region>",
"functionName": "mob-example",
"country": "XX",
"id": "ali.example.fc",
"label": "Example",
"layer": null
}Minimal CTYun and Tencent Cloud targets look like this:
{
"key": "example",
"region": "<ctyun-resource-pool-id>",
"functionName": "mob-example",
"country": "CN",
"id": "cty.example.fc",
"label": "Example"
}{
"key": "example",
"region": "<qcloud-region>",
"functionName": "mob-example",
"country": "CN",
"id": "txc.example.fc",
"label": "Example"
}Google Cloud Run is image-based. A minimal provider block is:
{
"providers": {
"gcloud": {
"name": "mtr-agent-gcloud",
"output": "gcloud",
"image": {
"imageUrl": "asia-docker.pkg.dev/<project>/<repo>/mtr-agent:dev"
},
"env": {
"MTR_PROTOCOLS": 3
},
"targets": [
{
"key": "hongkong",
"region": "asia-east1",
"functionName": "mob-hongkong",
"country": "HK",
"id": "gcp.ap-east-1.fc",
"label": "Hong Kong"
}
]
}
}
}The generator expands that config into a gcloud alpha run deploy command with
the image, region, function name, runtime settings, and Agent env vars:
gcloud alpha run deploy mob-hongkong \
--image=asia-docker.pkg.dev/<project>/<repo>/mtr-agent:dev \
--allow-unauthenticated \
--public \
--port=9000 \
--concurrency=1 \
--timeout=60 \
--cpu=0.08 \
--memory=128Mi \
--min-instances=0 \
--max-instances=4 \
--set-env-vars=MTR_COUNTRY=HK \
--set-env-vars=MTR_ID=gcp.ap-east-1.fc \
--set-env-vars=MTR_ISP=GCP \
--set-env-vars='MTR_REGION=Hong Kong' \
--set-env-vars=MTR_MODE=http \
--set-env-vars=MTR_LOG_LEVEL=info \
--set-env-vars=MTR_HTTP_ADDR=:9000 \
--set-env-vars=MTR_HTTP_AUTH_TYPE=header \
--set-env-vars=MTR_HTTP_AUTH_HEADER=X-MTR-Agent-Token \
--set-env-vars=MTR_HTTP_AUTH_SECRET=<http-auth-secret> \
--set-env-vars=MTR_HTTP_PATH_PREFIX=/<path-prefix> \
--set-env-vars='^#^MTR_CAPABILITIES=ping,traceroute,mtr,http,dns,port' \
--set-env-vars=MTR_PROTOCOLS=3 \
--set-env-vars=MTR_HIDE_FIRST_HOPS=0 \
--set-env-vars=MTR_HTTP_TLS_ENABLED=false \
--set-env-vars=MTR_SPEEDTEST_MAX_BYTES=0 \
--no-cpu-boost \
--region=asia-east1Cloud Run defaults are port 9000, concurrency 1, timeout 60, CPU 0.08,
memory 128Mi, min instances 0, max instances 4, --allow-unauthenticated,
--public, and --no-cpu-boost. Override them with providers.gcloud.run or
target.run.
Tencent Cloud SCF can deploy from a container image instead of the copied binary.
Build and publish the referenced source tag first; it is not bundled by the
deployment generator. Set providers.qcloud.image to enable image mode for all
Tencent Cloud targets:
{
"providers": {
"qcloud": {
"name": "mtr-agent-qcloud",
"output": "qcloud",
"image": {
"sourceImage": "ghcr.io/ztelliot/mtr-agent:0.1.0",
"imageType": "personal",
"imageUrl": "{registry}/sls-scf/mtr-agent:latest",
"containerImageAccelerate": true
},
"targets": [
{
"key": "guangzhou",
"region": "ap-guangzhou",
"functionName": "mob-guangzhou",
"country": "CN",
"id": "txc.cn-south-1.fc",
"label": "Guangzhou"
}
]
}
}
}imageUrl supports {registry}, {key}, {region}, and {functionName}.
sourceImage supports {key}, {region}, and {functionName}. A target may
override the provider image object, or set "image": false to fall back to the
code package.
{registry} is selected from the Tencent Cloud region. Mainland China defaults
to ccr.ccs.tencentyun.com; overseas regions use local registry hosts such as
hkccr.ccs.tencentyun.com for ap-hongkong and
sgccr.ccs.tencentyun.com for ap-singapore. Use image.registry for one
image object, or image.registries for selected regions:
{
"image": {
"sourceImage": "ghcr.io/ztelliot/mtr-agent:0.1.0",
"imageUrl": "{registry}/sls-scf/mtr-agent:latest",
"registries": {
"ap-hongkong": "hkccr.ccs.tencentyun.com",
"ap-singapore": "sgccr.ccs.tencentyun.com"
}
}
}In image mode, generated Tencent Cloud serverless.yml files contain:
image:
imageType: personal
imageUrl: ccr.ccs.tencentyun.com/sls-scf/mtr-agent:latest
containerImageAccelerate: trueoutput/qcloud/deploy.sh mirrors the source image into the selected Tencent
Cloud registry before deployment:
docker pull ghcr.io/ztelliot/mtr-agent:0.1.0
docker tag ghcr.io/ztelliot/mtr-agent:0.1.0 ccr.ccs.tencentyun.com/sls-scf/mtr-agent:latest
docker push ccr.ccs.tencentyun.com/sls-scf/mtr-agent:latestEach target image is mirrored once per generated target tag. With the example
above, mainland targets share ccr.ccs.tencentyun.com/sls-scf/mtr-agent:latest;
an overseas target such as ap-singapore uses
sgccr.ccs.tencentyun.com/sls-scf/mtr-agent:latest.
Thanks to OpenAI Codex for collaborative support during development, refactoring, testing, and documentation.