Merge pull request 'netcheck: one module, a Go tools bundle and a TypeScript one (hq ADR 0188, 0193)' (#246) from feat/netcheck-a-module-in-two-languages into main

This commit was merged in pull request #246.
This commit is contained in:
2026-10-03 22:35:32 +00:00
11 changed files with 548 additions and 0 deletions
+173
View File
@@ -0,0 +1,173 @@
package main
import (
"context"
"errors"
"fmt"
"math"
"net"
"sort"
"strconv"
"strings"
"time"
)
// Bounds on what a caller may ask: a check is a probe, never a wait anyone can make long.
const (
DefaultTimeout = 3 * time.Second
MostTimeout = 30 * time.Second
)
// TCPResult is what netcheck_tcp answers.
type TCPResult struct {
Host string `json:"host"`
Port int `json:"port"`
Address string `json:"address,omitempty"`
Reachable bool `json:"reachable"`
ElapsedMS int64 `json:"elapsed_ms"`
Error string `json:"error,omitempty"`
}
// CheckTCP opens one TCP connection and closes it, sending nothing. A port that refuses or a host
// that does not answer is a result, not a failure of the tool; only a malformed question is.
func CheckTCP(host string, port int, timeout time.Duration) (TCPResult, error) {
if port < 1 || port > 65535 {
return TCPResult{}, fmt.Errorf("port %d is not a TCP port (1-65535)", port)
}
out := TCPResult{Host: host, Port: port}
start := time.Now()
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), timeout)
out.ElapsedMS = time.Since(start).Milliseconds()
if err != nil {
out.Error = err.Error()
return out, nil
}
out.Address = conn.RemoteAddr().String()
out.Reachable = true
_ = conn.Close()
return out, nil
}
// DNSResult is what netcheck_dns answers.
type DNSResult struct {
Name string `json:"name"`
Type string `json:"type"`
Answers []string `json:"answers"`
ElapsedMS int64 `json:"elapsed_ms"`
Error string `json:"error,omitempty"`
}
// DNSTypes are the record types netcheck_dns looks up.
var DNSTypes = []string{"A", "AAAA", "CNAME", "TXT", "MX"}
// CheckDNS looks a name up with the machine's resolver. Built without cgo, Go's own resolver reads
// the machine's /etc/resolv.conf and /etc/hosts, which is the resolver this machine's programs use.
// A name that does not resolve is a result with its error; an unknown type is refused.
func CheckDNS(name, kind string, timeout time.Duration) (DNSResult, error) {
kind = strings.ToUpper(strings.TrimSpace(kind))
if kind == "" {
kind = "A"
}
known := false
for _, t := range DNSTypes {
known = known || t == kind
}
if !known {
return DNSResult{}, fmt.Errorf("type %q is not one netcheck_dns looks up (%s)", kind, strings.Join(DNSTypes, ", "))
}
out := DNSResult{Name: name, Type: kind, Answers: []string{}}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
r := net.DefaultResolver
start := time.Now()
var err error
switch kind {
case "A", "AAAA":
network := "ip4"
if kind == "AAAA" {
network = "ip6"
}
var ips []net.IP
if ips, err = r.LookupIP(ctx, network, name); err == nil {
for _, ip := range ips {
out.Answers = append(out.Answers, ip.String())
}
}
case "CNAME":
var cname string
if cname, err = r.LookupCNAME(ctx, name); err == nil {
out.Answers = append(out.Answers, cname)
}
case "TXT":
var txts []string
if txts, err = r.LookupTXT(ctx, name); err == nil {
out.Answers = append(out.Answers, txts...)
}
case "MX":
var mxs []*net.MX
if mxs, err = r.LookupMX(ctx, name); err == nil {
for _, mx := range mxs {
out.Answers = append(out.Answers, fmt.Sprintf("%d %s", mx.Pref, mx.Host))
}
}
}
out.ElapsedMS = time.Since(start).Milliseconds()
if err != nil {
out.Error = err.Error()
}
if kind != "MX" {
sort.Strings(out.Answers)
}
return out, nil
}
// text is a required string argument.
func text(args map[string]any, key string) (string, error) {
s, _ := args[key].(string)
s = strings.TrimSpace(s)
if s == "" {
return "", fmt.Errorf("%s is required", key)
}
return s, nil
}
// whole is an integer argument, given as a JSON number or a numeric string; fallback when absent.
func whole(args map[string]any, key string, fallback int) (int, error) {
v, given := args[key]
if !given || v == nil {
if fallback == 0 {
return 0, fmt.Errorf("%s is required", key)
}
return fallback, nil
}
switch n := v.(type) {
case float64:
if n != math.Trunc(n) {
return 0, fmt.Errorf("%s must be a whole number, not %v", key, n)
}
return int(n), nil
case string:
i, err := strconv.Atoi(strings.TrimSpace(n))
if err != nil {
return 0, fmt.Errorf("%s must be a whole number, not %q", key, n)
}
return i, nil
}
return 0, errors.New(key + " must be a whole number")
}
// timeoutOf is timeout_ms, defaulted and bounded.
func timeoutOf(args map[string]any) (time.Duration, error) {
ms, err := whole(args, "timeout_ms", int(DefaultTimeout/time.Millisecond))
if err != nil {
return 0, err
}
if ms < 1 {
return 0, fmt.Errorf("timeout_ms must be at least 1, not %d", ms)
}
d := time.Duration(ms) * time.Millisecond
if d > MostTimeout {
d = MostTimeout
}
return d, nil
}
@@ -0,0 +1,68 @@
package main
import (
"net"
"testing"
"time"
)
func TestATCPPortThatListensIsReachableAndOneThatDoesNotIsNot(t *testing.T) {
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
port := l.Addr().(*net.TCPAddr).Port
got, err := CheckTCP("127.0.0.1", port, time.Second)
if err != nil || !got.Reachable || got.Error != "" {
t.Fatalf("a listening port: %+v, %v", got, err)
}
l.Close()
got, err = CheckTCP("127.0.0.1", port, time.Second)
if err != nil || got.Reachable || got.Error == "" {
t.Fatalf("a closed port is reported as a result with its error, not a failure: %+v, %v", got, err)
}
}
func TestAPortOutsideTheRangeIsRefused(t *testing.T) {
for _, p := range []int{0, -1, 65536} {
if _, err := CheckTCP("127.0.0.1", p, time.Second); err == nil {
t.Errorf("port %d was accepted", p)
}
}
}
func TestDNSAnswersFromTheMachinesResolverAndRefusesAnUnknownType(t *testing.T) {
got, err := CheckDNS("localhost", "a", time.Second)
if err != nil || got.Type != "A" || len(got.Answers) == 0 {
t.Fatalf("localhost A: %+v, %v", got, err)
}
if _, err := CheckDNS("localhost", "SRV", time.Second); err == nil {
t.Fatal("an unknown record type was accepted")
}
got, err = CheckDNS("no-such-name.invalid", "A", time.Second)
if err != nil || got.Error == "" || len(got.Answers) != 0 {
t.Fatalf("a name that does not resolve is a result with its error: %+v, %v", got, err)
}
}
func TestTimeoutIsDefaultedAndBounded(t *testing.T) {
if d, _ := timeoutOf(map[string]any{}); d != DefaultTimeout {
t.Errorf("default: %v", d)
}
if d, _ := timeoutOf(map[string]any{"timeout_ms": float64(10 * 60 * 1000)}); d != MostTimeout {
t.Errorf("bounded: %v", d)
}
if _, err := timeoutOf(map[string]any{"timeout_ms": float64(0)}); err == nil {
t.Error("a zero timeout was accepted")
}
}
func TestBothToolsAreListedUnprefixed(t *testing.T) {
names := map[string]bool{}
for _, tool := range tools() {
names[tool.Name] = true
}
if !names["netcheck_tcp"] || !names["netcheck_dns"] || len(names) != 2 {
t.Fatalf("tools: %v", names)
}
}
+72
View File
@@ -0,0 +1,72 @@
// netcheck's Go tools bundle (novox/hq ADR 0188, ADR 0193): a process the node's runtime launches
// and speaks MCP over stdio to, through the Go SDK. It serves the two checks that are the machine's
// own sockets and resolver — a TCP connect and a DNS lookup — and nothing that changes anything.
// The module's HTTP check is its TypeScript bundle; the runtime serves both under one module.
package main
import (
"fmt"
"os"
stdio "git.novox.be/novox/mesh-sdk/go"
)
func main() {
// An empty name serves as the module the runtime names (MESH_SERVED_MODULE): netcheck.
if err := stdio.Serve("", tools()); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
}
func tools() []stdio.Tool {
return []stdio.Tool{
{
Name: "netcheck_tcp",
Description: "Check whether a TCP port is reachable from this machine: opens one connection " +
"and closes it at once, sending nothing. Answers reachable, elapsed_ms and the error when not.",
Input: map[string]any{
"host": map[string]any{"type": "string", "description": "host name or IP address"},
"port": map[string]any{"type": "integer", "description": "TCP port, 1-65535"},
"timeout_ms": map[string]any{"type": "integer", "description": "give up after this long (default 3000, at most 30000)"},
},
Run: func(args map[string]any) (any, error) {
host, err := text(args, "host")
if err != nil {
return nil, err
}
port, err := whole(args, "port", 0)
if err != nil {
return nil, err
}
timeout, err := timeoutOf(args)
if err != nil {
return nil, err
}
return CheckTCP(host, port, timeout)
},
},
{
Name: "netcheck_dns",
Description: "Look a name up with this machine's resolver (its /etc/resolv.conf and /etc/hosts). " +
"type is A, AAAA, CNAME, TXT or MX; answers the records found, or the error.",
Input: map[string]any{
"name": map[string]any{"type": "string", "description": "the name to look up"},
"type": map[string]any{"type": "string", "enum": []string{"A", "AAAA", "CNAME", "TXT", "MX"}, "description": "record type (default A)"},
"timeout_ms": map[string]any{"type": "integer", "description": "give up after this long (default 3000, at most 30000)"},
},
Run: func(args map[string]any) (any, error) {
name, err := text(args, "name")
if err != nil {
return nil, err
}
kind, _ := args["type"].(string)
timeout, err := timeoutOf(args)
if err != nil {
return nil, err
}
return CheckDNS(name, kind, timeout)
},
},
}
}
+5
View File
@@ -0,0 +1,5 @@
module netcheck
go 1.22
require git.novox.be/novox/mesh-sdk/go v0.1.6
+2
View File
@@ -0,0 +1,2 @@
git.novox.be/novox/mesh-sdk/go v0.1.6 h1:9qzdYONYbJdWcu6sxQcq9v1LI0JxcfkiKYkMUzJSkVQ=
git.novox.be/novox/mesh-sdk/go v0.1.6/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
+84
View File
@@ -0,0 +1,84 @@
// netcheck's HTTP check — the module's own code, in TypeScript (novox/hq ADR 0039, ADR 0188). One
// request, HEAD or GET, never a body sent and never a body read: the status, how long it took and
// a few headers that say what answered. Redirects are reported, not followed, so a check reaches
// exactly the address it was given.
export const METHODS = ["HEAD", "GET"] as const;
export type Method = (typeof METHODS)[number];
/** The headers worth reporting: what answered and what it says it is, nothing it set for a client. */
export const REPORTED_HEADERS = [
"content-type", "content-length", "server", "location", "date",
"cache-control", "last-modified", "etag",
] as const;
export const DEFAULT_TIMEOUT_MS = 5000;
export const MOST_TIMEOUT_MS = 30000;
export interface HttpResult {
url: string;
method: Method;
status?: number;
statusText?: string;
elapsed_ms: number;
headers: Record<string, string>;
error?: string;
}
/** Only http and https are checked; anything else — file:, data:, ftp: — is refused by name. */
export function checkedUrl(raw: unknown): URL {
const text = typeof raw === "string" ? raw.trim() : "";
if (!text) throw new Error("url is required");
let url: URL;
try {
url = new URL(text);
} catch {
throw new Error(`${JSON.stringify(text)} is not a URL`);
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error(`netcheck_http checks http and https URLs only, not ${url.protocol}`);
}
return url;
}
export function checkedMethod(raw: unknown): Method {
const m = (typeof raw === "string" && raw.trim() ? raw.trim() : "HEAD").toUpperCase();
if (!(METHODS as readonly string[]).includes(m)) {
throw new Error(`method ${m} is not one netcheck_http uses (${METHODS.join(", ")}): a check never changes anything`);
}
return m as Method;
}
export function checkedTimeout(raw: unknown): number {
if (raw === undefined || raw === null || raw === "") return DEFAULT_TIMEOUT_MS;
const n = Number(raw);
if (!Number.isInteger(n) || n < 1) throw new Error(`timeout_ms must be a whole number of at least 1, not ${String(raw)}`);
return Math.min(n, MOST_TIMEOUT_MS);
}
/** Make one request and report how it went. A refused connection or a timeout is a result with its
* error; only a malformed question throws. */
export async function checkHttp(args: Readonly<Record<string, unknown>>, fetcher: typeof fetch = fetch): Promise<HttpResult> {
const url = checkedUrl(args.url);
const method = checkedMethod(args.method);
const timeout = checkedTimeout(args.timeout_ms);
const started = performance.now();
const out: HttpResult = { url: url.toString(), method, elapsed_ms: 0, headers: {} };
try {
const res = await fetcher(url, { method, redirect: "manual", signal: AbortSignal.timeout(timeout) });
out.elapsed_ms = Math.round(performance.now() - started);
out.status = res.status;
out.statusText = res.statusText;
for (const h of REPORTED_HEADERS) {
const v = res.headers.get(h);
if (v !== null) out.headers[h] = v;
}
// The body is not read: a check asks whether something answers, not what it says.
await res.body?.cancel().catch(() => {});
} catch (err) {
out.elapsed_ms = Math.round(performance.now() - started);
const e = err as Error & { cause?: { message?: string; code?: string } };
out.error = e.name === "TimeoutError" ? `no answer within ${timeout} ms` : (e.cause?.code ?? e.cause?.message ?? e.message);
}
return out;
}
+35
View File
@@ -0,0 +1,35 @@
{
"module": "netcheck",
"version": "1",
"tools": [
"netcheck_tcp",
"netcheck_dns",
"netcheck_http"
],
"build": {
"artifacts": [
{
"name": "tools-go",
"kind": "bundle",
"language": "go",
"system": "arch",
"from": "cmd/netcheck",
"binary": "netcheck",
"loads": [
"netcheck"
]
},
{
"name": "tools-typescript",
"kind": "bundle",
"language": "typescript",
"entrypoints": [
"tools/index.js"
],
"loads": [
"tools/index.js"
]
}
]
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "@novox/module-netcheck",
"version": "0.1.0",
"description": "netcheck — read-only network checks from a machine, as one module carrying a Go tools bundle (TCP, DNS) and a TypeScript one (HTTP) (novox/hq ADR 0188, ADR 0193).",
"type": "module",
"private": true,
"scripts": {
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
},
"dependencies": {
"@novox/mesh-sdk": "^0.1.6"
},
"devDependencies": {
"@types/node": "^22.0.0",
"typescript": "^5.6.0"
}
}
+52
View File
@@ -0,0 +1,52 @@
// The HTTP check refuses what is not http(s) and what would change something, and reports a status,
// a refusal and a timeout as results (novox/hq ADR 0188: a tools bundle is read-only and harmless).
import { test } from "node:test";
import assert from "node:assert/strict";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { checkHttp, checkedMethod, checkedUrl } from "../http.ts";
test("only http and https URLs are checked", () => {
for (const bad of ["file:///etc/passwd", "ftp://example.org/", "data:text/plain,hi", "javascript:1", "", "not a url"]) {
assert.throws(() => checkedUrl(bad), `${bad} was accepted`);
}
assert.equal(checkedUrl("https://example.org/x").protocol, "https:");
});
test("only HEAD and GET are used", () => {
assert.equal(checkedMethod(undefined), "HEAD");
assert.equal(checkedMethod("get"), "GET");
for (const bad of ["POST", "PUT", "DELETE", "PATCH"]) assert.throws(() => checkedMethod(bad));
});
test("a status, its headers and a redirect not followed", async () => {
const server = createServer((req, res) => {
if (req.url === "/moved") { res.writeHead(302, { location: "/elsewhere" }); res.end(); return; }
res.writeHead(200, { "content-type": "text/plain", "x-secret": "not reported" });
res.end(req.method === "GET" ? "body" : undefined);
});
await new Promise<void>((ok) => server.listen(0, "127.0.0.1", ok));
const base = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
try {
const head = await checkHttp({ url: base + "/" });
assert.equal(head.status, 200);
assert.equal(head.method, "HEAD");
assert.equal(head.headers["content-type"], "text/plain");
assert.equal(head.headers["x-secret"], undefined);
const moved = await checkHttp({ url: base + "/moved", method: "GET" });
assert.equal(moved.status, 302);
assert.equal(moved.headers.location, "/elsewhere");
} finally {
server.close();
}
});
test("a refused connection is a result with its error", async () => {
const server = createServer();
await new Promise<void>((ok) => server.listen(0, "127.0.0.1", ok));
const port = (server.address() as AddressInfo).port;
await new Promise<void>((ok) => server.close(() => ok()));
const got = await checkHttp({ url: `http://127.0.0.1:${port}/`, timeout_ms: 2000 });
assert.equal(got.status, undefined);
assert.ok(got.error, "no error reported");
});
+28
View File
@@ -0,0 +1,28 @@
// netcheck's TypeScript tools bundle (novox/hq ADR 0188, ADR 0193): what the builder's launcher
// imports and serves over MCP on stdio. Its Go bundle serves the TCP and DNS checks; this one the
// HTTP check — one module, two languages, one runtime that knows neither.
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
import { checkHttp, DEFAULT_TIMEOUT_MS, METHODS, MOST_TIMEOUT_MS } from "../http.js";
export function getNetcheckHttpTools(): ToolDefinition[] {
return [
{
name: "netcheck_http",
description:
"Check whether an http(s) URL answers from this machine: one HEAD or GET, no body sent or read, " +
"redirects reported and not followed. Answers status, elapsed_ms and a few headers.",
input: {
url: { type: "string", description: "an http:// or https:// URL" },
method: { type: "string", enum: [...METHODS], description: "HEAD (default) or GET" },
timeout_ms: {
type: "integer",
description: `give up after this long (default ${DEFAULT_TIMEOUT_MS}, at most ${MOST_TIMEOUT_MS})`,
},
},
run: async (args) => checkHttp(args),
},
];
}
registerModuleTools("netcheck", () => getNetcheckHttpTools());
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["http.ts", "tools/index.ts"]
}