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:
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
module netcheck
|
||||||
|
|
||||||
|
go 1.22
|
||||||
|
|
||||||
|
require git.novox.be/novox/mesh-sdk/go v0.1.6
|
||||||
@@ -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=
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
});
|
||||||
@@ -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());
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user