diff --git a/modules/netcheck/cmd/netcheck/checks.go b/modules/netcheck/cmd/netcheck/checks.go new file mode 100644 index 0000000..152b6e4 --- /dev/null +++ b/modules/netcheck/cmd/netcheck/checks.go @@ -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 +} diff --git a/modules/netcheck/cmd/netcheck/checks_test.go b/modules/netcheck/cmd/netcheck/checks_test.go new file mode 100644 index 0000000..8e2af86 --- /dev/null +++ b/modules/netcheck/cmd/netcheck/checks_test.go @@ -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) + } +} diff --git a/modules/netcheck/cmd/netcheck/main.go b/modules/netcheck/cmd/netcheck/main.go new file mode 100644 index 0000000..98cb507 --- /dev/null +++ b/modules/netcheck/cmd/netcheck/main.go @@ -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) + }, + }, + } +} diff --git a/modules/netcheck/go.mod b/modules/netcheck/go.mod new file mode 100644 index 0000000..83fdfe3 --- /dev/null +++ b/modules/netcheck/go.mod @@ -0,0 +1,5 @@ +module netcheck + +go 1.22 + +require git.novox.be/novox/mesh-sdk/go v0.1.6 diff --git a/modules/netcheck/go.sum b/modules/netcheck/go.sum new file mode 100644 index 0000000..0dd6061 --- /dev/null +++ b/modules/netcheck/go.sum @@ -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= diff --git a/modules/netcheck/http.ts b/modules/netcheck/http.ts new file mode 100644 index 0000000..0a49add --- /dev/null +++ b/modules/netcheck/http.ts @@ -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; + 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>, fetcher: typeof fetch = fetch): Promise { + 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; +} diff --git a/modules/netcheck/module.json b/modules/netcheck/module.json new file mode 100644 index 0000000..9ee8e8f --- /dev/null +++ b/modules/netcheck/module.json @@ -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" + ] + } + ] + } +} diff --git a/modules/netcheck/package.json b/modules/netcheck/package.json new file mode 100644 index 0000000..5d96e26 --- /dev/null +++ b/modules/netcheck/package.json @@ -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" + } +} diff --git a/modules/netcheck/test/http.test.ts b/modules/netcheck/test/http.test.ts new file mode 100644 index 0000000..bec2566 --- /dev/null +++ b/modules/netcheck/test/http.test.ts @@ -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((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((ok) => server.listen(0, "127.0.0.1", ok)); + const port = (server.address() as AddressInfo).port; + await new Promise((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"); +}); diff --git a/modules/netcheck/tools/index.ts b/modules/netcheck/tools/index.ts new file mode 100644 index 0000000..701089a --- /dev/null +++ b/modules/netcheck/tools/index.ts @@ -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()); diff --git a/modules/netcheck/tsconfig.json b/modules/netcheck/tsconfig.json new file mode 100644 index 0000000..e4f66ed --- /dev/null +++ b/modules/netcheck/tsconfig.json @@ -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"] +}