hosts in Go, with the machine's own name in its block (ADR 0199)
The tools were TypeScript; the mesh's modules are Go. The write to /etc/hosts is now staged and moved into place rather than written over the live file.
This commit is contained in:
@@ -1,178 +0,0 @@
|
||||
// The hosts file's own code (novox/hq ADR 0199): read /etc/hosts as the machine has it, and change the
|
||||
// operator's lines — every line outside a `# BEGIN … / # END …` block — leaving every block, the mesh's
|
||||
// and any other tool's, byte for byte. The mesh writes this module's block; these verbs never touch it.
|
||||
// Root is the module's concern (ADR 0175 §4): the runtime runs as the operator's account, so the file
|
||||
// is written through sudo without a prompt where the account is not root, as the packet filter's is.
|
||||
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { isIP } from "node:net";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileP = promisify(execFile);
|
||||
|
||||
/** Where the file is. The manifest's resource names the same path; a test holds the two together. */
|
||||
export const HOSTS_FILE = "/etc/hosts";
|
||||
|
||||
/** A command runner, so the writes can be tested without a machine. */
|
||||
export type Runner = (cmd: string, args: string[]) => Promise<string>;
|
||||
|
||||
export function escalated(cmd: string, args: string[], uid: number | undefined = process.getuid?.()): [string, string[]] {
|
||||
if (uid === 0) return [cmd, args];
|
||||
return ["sudo", ["-n", cmd, ...args]];
|
||||
}
|
||||
|
||||
const run: Runner = async (cmd, args) => {
|
||||
const [program, argv] = escalated(cmd, args);
|
||||
const { stdout } = await execFileP(program, argv);
|
||||
return stdout;
|
||||
};
|
||||
|
||||
/** One line of the file, as a reader sees it. */
|
||||
export interface Line {
|
||||
/** The line exactly as it is in the file. */
|
||||
text: string;
|
||||
/** Whose it is: the block's id (`mesh hosts.own`, or another tool's) or "operator". */
|
||||
owner: string;
|
||||
/** For an entry: its address and names. Absent for a comment or blank line. */
|
||||
address?: string;
|
||||
names?: string[];
|
||||
}
|
||||
|
||||
const BEGIN = /^#\s*BEGIN\s+(.+?)\s*$/;
|
||||
const END = /^#\s*END\s+(.+?)\s*$/;
|
||||
|
||||
/** Every line of a hosts file, each marked whose it is. */
|
||||
export function parse(text: string): Line[] {
|
||||
const out: Line[] = [];
|
||||
let block: string | null = null;
|
||||
for (const raw of text.split("\n")) {
|
||||
const begin = raw.match(BEGIN);
|
||||
if (!block && begin) {
|
||||
block = begin[1];
|
||||
out.push({ text: raw, owner: block });
|
||||
continue;
|
||||
}
|
||||
const owner = block ?? "operator";
|
||||
const entry = raw.replace(/#.*/, "").trim().split(/\s+/).filter(Boolean);
|
||||
const line: Line = { text: raw, owner };
|
||||
if (entry.length >= 2 && isIP(entry[0])) {
|
||||
line.address = entry[0];
|
||||
line.names = entry.slice(1);
|
||||
}
|
||||
out.push(line);
|
||||
const end = raw.match(END);
|
||||
if (block && end && end[1] === block) block = null;
|
||||
}
|
||||
// A trailing newline splits into one empty last element; it is the file's ending, not a line.
|
||||
if (out.length > 0 && out[out.length - 1].text === "" && text.endsWith("\n")) out.pop();
|
||||
return out;
|
||||
}
|
||||
|
||||
const NAME = /^(?=.{1,253}$)[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?(?:\.[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?)*\.?$/;
|
||||
|
||||
/** Refused input says why, so a caller is one edit from right. */
|
||||
function checkAddress(address: string): void {
|
||||
if (!isIP(address)) throw new Error(`${JSON.stringify(address)} is not an IPv4 or IPv6 address`);
|
||||
}
|
||||
function checkName(name: string): void {
|
||||
if (!NAME.test(name)) throw new Error(`${JSON.stringify(name)} is not a host name`);
|
||||
}
|
||||
|
||||
/** The file with one address and its names added to the operator's lines; unchanged when already there. */
|
||||
export function withAdded(text: string, address: string, names: string[]): string {
|
||||
checkAddress(address);
|
||||
if (names.length === 0) throw new Error("add names at least one name for the address");
|
||||
names.forEach(checkName);
|
||||
const lines = parse(text);
|
||||
const have = new Set(
|
||||
lines.filter((l) => l.owner === "operator" && l.address === address).flatMap((l) => l.names ?? []),
|
||||
);
|
||||
const missing = names.filter((n) => !have.has(n));
|
||||
if (missing.length === 0) return text;
|
||||
const body = text.endsWith("\n") || text === "" ? text : text + "\n";
|
||||
return body + `${address}\t${missing.join(" ")}\n`;
|
||||
}
|
||||
|
||||
/** The file with one name, or every line of one address, taken out of the operator's lines. Blocks are
|
||||
* never touched: a name only the mesh or another tool writes is refused, naming whose it is. */
|
||||
export function withRemoved(text: string, what: string): { text: string; removed: number } {
|
||||
const byAddress = isIP(what) !== 0;
|
||||
if (!byAddress) checkName(what);
|
||||
const lines = parse(text);
|
||||
let removed = 0;
|
||||
const kept: string[] = [];
|
||||
for (const l of lines) {
|
||||
if (l.owner !== "operator" || !l.address) {
|
||||
kept.push(l.text);
|
||||
continue;
|
||||
}
|
||||
if (byAddress && l.address === what) {
|
||||
removed++;
|
||||
continue;
|
||||
}
|
||||
if (!byAddress && l.names?.includes(what)) {
|
||||
removed++;
|
||||
const rest = l.names.filter((n) => n !== what);
|
||||
if (rest.length > 0) kept.push(`${l.address}\t${rest.join(" ")}`);
|
||||
continue;
|
||||
}
|
||||
kept.push(l.text);
|
||||
}
|
||||
if (removed === 0) {
|
||||
const elsewhere = lines.find((l) => l.owner !== "operator" && (byAddress ? l.address === what : l.names?.includes(what)));
|
||||
if (elsewhere) throw new Error(`${what} is written by ${elsewhere.owner}, not the operator; it is not this verb's to remove`);
|
||||
}
|
||||
return { text: kept.join("\n") + "\n", removed };
|
||||
}
|
||||
|
||||
export class HostsFile {
|
||||
private readonly path: string;
|
||||
private readonly runner: Runner;
|
||||
|
||||
constructor(path: string = HOSTS_FILE, runner: Runner = run) {
|
||||
this.path = path;
|
||||
this.runner = runner;
|
||||
}
|
||||
|
||||
static onThisMachine(): HostsFile {
|
||||
return new HostsFile();
|
||||
}
|
||||
|
||||
async read(): Promise<string> {
|
||||
return readFile(this.path, "utf8");
|
||||
}
|
||||
|
||||
async entries(): Promise<{ path: string; lines: Line[] }> {
|
||||
return { path: this.path, lines: parse(await this.read()) };
|
||||
}
|
||||
|
||||
async add(address: string, names: string[]): Promise<{ added: boolean; line?: string }> {
|
||||
const before = await this.read();
|
||||
const after = withAdded(before, address, names);
|
||||
if (after === before) return { added: false };
|
||||
await this.write(after);
|
||||
return { added: true, line: after.slice(before.length).trim() };
|
||||
}
|
||||
|
||||
async remove(what: string): Promise<{ removed: number }> {
|
||||
const before = await this.read();
|
||||
const { text, removed } = withRemoved(before, what);
|
||||
if (removed > 0) await this.write(text);
|
||||
return { removed };
|
||||
}
|
||||
|
||||
/** Written whole through a copy beside it, so a reader never sees half a file. */
|
||||
private async write(content: string): Promise<void> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "hosts-"));
|
||||
const staged = join(dir, "hosts");
|
||||
try {
|
||||
await writeFile(staged, content, { mode: 0o644 });
|
||||
await this.runner("install", ["-m", "0644", staged, this.path]);
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
// The hosts file's own code (novox/hq ADR 0199): read /etc/hosts as the machine has it, and change the
|
||||
// operator's lines — every line outside a `# BEGIN … / # END …` block — leaving every block, the mesh's
|
||||
// and any other tool's, byte for byte. The mesh writes this module's block; these verbs never touch it.
|
||||
//
|
||||
// Root is the module's concern (ADR 0175 §4): the runtime launching this binary runs as the operator's
|
||||
// account, so the file is written through sudo without a prompt where the account is not root, as the
|
||||
// packet filter's is.
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// HostsPath is where the file is. The manifest's resource names the same path; a test holds the two
|
||||
// together.
|
||||
const HostsPath = "/etc/hosts"
|
||||
|
||||
// Operator is the owner of every line outside a block.
|
||||
const Operator = "operator"
|
||||
|
||||
// Runner runs one command as root and answers what it printed, so the writes can be tested without a
|
||||
// machine.
|
||||
type Runner func(ctx context.Context, name string, args ...string) (string, error)
|
||||
|
||||
// escalated is the command as it is run: as given when this process is root, else through sudo
|
||||
// without a prompt.
|
||||
func escalated(uid int, name string, args []string) (string, []string) {
|
||||
if uid == 0 {
|
||||
return name, args
|
||||
}
|
||||
return "sudo", append([]string{"-n", name}, args...)
|
||||
}
|
||||
|
||||
func execRunner(ctx context.Context, name string, args ...string) (string, error) {
|
||||
ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||||
defer cancel()
|
||||
program, argv := escalated(os.Getuid(), name, args)
|
||||
var stdout, stderr bytes.Buffer
|
||||
cmd := exec.CommandContext(ctx, program, argv...)
|
||||
cmd.Stdout, cmd.Stderr = &stdout, &stderr
|
||||
err := cmd.Run()
|
||||
if err == nil {
|
||||
return stdout.String(), nil
|
||||
}
|
||||
said := strings.TrimSpace(stdout.String() + stderr.String())
|
||||
if program == "sudo" {
|
||||
if errors.Is(err, exec.ErrNotFound) {
|
||||
return "", fmt.Errorf("%s needs root, and sudo is not installed here for the runtime's account to escalate with", name)
|
||||
}
|
||||
if regexp.MustCompile(`(?m)^sudo:`).MatchString(said) {
|
||||
return "", fmt.Errorf("%s needs root and the runtime's account may not run it without a prompt: %s", name, said)
|
||||
}
|
||||
}
|
||||
if said != "" {
|
||||
return "", fmt.Errorf("%s: %s", name, said)
|
||||
}
|
||||
return "", fmt.Errorf("%s failed: %v", name, err)
|
||||
}
|
||||
|
||||
// Line is one line of the file, as a reader sees it.
|
||||
type Line struct {
|
||||
// Text is the line exactly as it is in the file.
|
||||
Text string `json:"text"`
|
||||
// Owner is whose it is: the block's id (`mesh hosts.own`, or another tool's) or "operator".
|
||||
Owner string `json:"owner"`
|
||||
// Address and Names are an entry's; absent for a comment or a blank line.
|
||||
Address string `json:"address,omitempty"`
|
||||
Names []string `json:"names,omitempty"`
|
||||
}
|
||||
|
||||
var (
|
||||
begin = regexp.MustCompile(`^#\s*BEGIN\s+(.+?)\s*$`)
|
||||
end = regexp.MustCompile(`^#\s*END\s+(.+?)\s*$`)
|
||||
)
|
||||
|
||||
// isAddress is whether s is an IPv4 or IPv6 address, as a hosts file's first field must be.
|
||||
func isAddress(s string) bool {
|
||||
_, err := netip.ParseAddr(s)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// Parse is every line of a hosts file, each marked whose it is.
|
||||
func Parse(text string) []Line {
|
||||
out := []Line{}
|
||||
block := ""
|
||||
for _, raw := range strings.Split(text, "\n") {
|
||||
if block == "" {
|
||||
if m := begin.FindStringSubmatch(raw); m != nil {
|
||||
block = m[1]
|
||||
out = append(out, Line{Text: raw, Owner: block})
|
||||
continue
|
||||
}
|
||||
}
|
||||
owner := block
|
||||
if owner == "" {
|
||||
owner = Operator
|
||||
}
|
||||
line := Line{Text: raw, Owner: owner}
|
||||
entry, _, _ := strings.Cut(raw, "#")
|
||||
if fields := strings.Fields(entry); len(fields) >= 2 && isAddress(fields[0]) {
|
||||
line.Address, line.Names = fields[0], fields[1:]
|
||||
}
|
||||
out = append(out, line)
|
||||
if m := end.FindStringSubmatch(raw); block != "" && m != nil && m[1] == block {
|
||||
block = ""
|
||||
}
|
||||
}
|
||||
// A trailing newline splits into one empty last element; it is the file's ending, not a line.
|
||||
if n := len(out); n > 0 && out[n-1].Text == "" && strings.HasSuffix(text, "\n") {
|
||||
out = out[:n-1]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var label = regexp.MustCompile(`^[A-Za-z0-9](?:[A-Za-z0-9-]{0,61}[A-Za-z0-9])?$`)
|
||||
|
||||
// Refused input says why, so a caller is one edit from right.
|
||||
func checkAddress(address string) error {
|
||||
if !isAddress(address) {
|
||||
return fmt.Errorf("%q is not an IPv4 or IPv6 address", address)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkName(name string) error {
|
||||
bad := fmt.Errorf("%q is not a host name", name)
|
||||
if len(name) < 1 || len(name) > 253 {
|
||||
return bad
|
||||
}
|
||||
for _, l := range strings.Split(strings.TrimSuffix(name, "."), ".") {
|
||||
if !label.MatchString(l) {
|
||||
return bad
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithAdded is the file with one address and its names added to the operator's lines; unchanged when
|
||||
// they are already there.
|
||||
func WithAdded(text, address string, names []string) (string, error) {
|
||||
if err := checkAddress(address); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if len(names) == 0 {
|
||||
return "", errors.New("add names at least one name for the address")
|
||||
}
|
||||
for _, n := range names {
|
||||
if err := checkName(n); err != nil {
|
||||
return "", err
|
||||
}
|
||||
}
|
||||
have := map[string]bool{}
|
||||
for _, l := range Parse(text) {
|
||||
if l.Owner == Operator && l.Address == address {
|
||||
for _, n := range l.Names {
|
||||
have[n] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
var missing []string
|
||||
for _, n := range names {
|
||||
if !have[n] && !slices.Contains(missing, n) {
|
||||
missing = append(missing, n)
|
||||
}
|
||||
}
|
||||
if len(missing) == 0 {
|
||||
return text, nil
|
||||
}
|
||||
body := text
|
||||
if body != "" && !strings.HasSuffix(body, "\n") {
|
||||
body += "\n"
|
||||
}
|
||||
return body + address + "\t" + strings.Join(missing, " ") + "\n", nil
|
||||
}
|
||||
|
||||
// WithRemoved is the file with one name, or every line of one address, taken out of the operator's
|
||||
// lines, and how many lines it touched. Blocks are never touched: a name only the mesh or another tool
|
||||
// writes is refused, naming whose it is.
|
||||
func WithRemoved(text, what string) (string, int, error) {
|
||||
byAddress := isAddress(what)
|
||||
if !byAddress {
|
||||
if err := checkName(what); err != nil {
|
||||
return "", 0, err
|
||||
}
|
||||
}
|
||||
matches := func(l Line) bool {
|
||||
if byAddress {
|
||||
return l.Address == what
|
||||
}
|
||||
return slices.Contains(l.Names, what)
|
||||
}
|
||||
lines := Parse(text)
|
||||
removed := 0
|
||||
kept := []string{}
|
||||
for _, l := range lines {
|
||||
if l.Owner != Operator || l.Address == "" || !matches(l) {
|
||||
kept = append(kept, l.Text)
|
||||
continue
|
||||
}
|
||||
removed++
|
||||
if byAddress {
|
||||
continue
|
||||
}
|
||||
var rest []string
|
||||
for _, n := range l.Names {
|
||||
if n != what {
|
||||
rest = append(rest, n)
|
||||
}
|
||||
}
|
||||
if len(rest) > 0 {
|
||||
kept = append(kept, l.Address+"\t"+strings.Join(rest, " "))
|
||||
}
|
||||
}
|
||||
if removed == 0 {
|
||||
for _, l := range lines {
|
||||
if l.Owner != Operator && matches(l) {
|
||||
return "", 0, fmt.Errorf("%s is written by %s, not the operator; it is not this verb's to remove", what, l.Owner)
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.Join(kept, "\n") + "\n", removed, nil
|
||||
}
|
||||
|
||||
// HostsFile is the machine's hosts file.
|
||||
type HostsFile struct {
|
||||
Path string
|
||||
Run Runner
|
||||
}
|
||||
|
||||
// Entries is the file's lines, each marked whose.
|
||||
type Entries struct {
|
||||
Path string `json:"path"`
|
||||
Lines []Line `json:"lines"`
|
||||
}
|
||||
|
||||
func (h HostsFile) read() (string, error) {
|
||||
b, err := os.ReadFile(h.Path)
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
// Entries is every line of the file, each marked whose it is.
|
||||
func (h HostsFile) Entries() (*Entries, error) {
|
||||
text, err := h.read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Entries{Path: h.Path, Lines: Parse(text)}, nil
|
||||
}
|
||||
|
||||
// Added is whether add changed the file, and the line it wrote.
|
||||
type Added struct {
|
||||
Added bool `json:"added"`
|
||||
Line string `json:"line,omitempty"`
|
||||
}
|
||||
|
||||
// Add adds one address and its names to the operator's lines.
|
||||
func (h HostsFile) Add(ctx context.Context, address string, names []string) (*Added, error) {
|
||||
before, err := h.read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
after, err := WithAdded(before, address, names)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if after == before {
|
||||
return &Added{Added: false}, nil
|
||||
}
|
||||
if err := h.write(ctx, after); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Added{Added: true, Line: strings.TrimSpace(after[len(before):])}, nil
|
||||
}
|
||||
|
||||
// Removed is how many of the operator's lines remove touched.
|
||||
type Removed struct {
|
||||
Removed int `json:"removed"`
|
||||
}
|
||||
|
||||
// Remove takes one name, or every line of one address, out of the operator's lines.
|
||||
func (h HostsFile) Remove(ctx context.Context, what string) (*Removed, error) {
|
||||
before, err := h.read()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
after, removed, err := WithRemoved(before, what)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if removed > 0 {
|
||||
if err := h.write(ctx, after); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &Removed{Removed: removed}, nil
|
||||
}
|
||||
|
||||
// write puts the file in place whole, so a reader never sees half of it: the content is staged in a
|
||||
// private copy, installed as root beside the file — the same directory, so the same filesystem — and
|
||||
// renamed over it.
|
||||
func (h HostsFile) write(ctx context.Context, content string) error {
|
||||
dir, err := os.MkdirTemp("", "hosts-")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer os.RemoveAll(dir)
|
||||
staged := filepath.Join(dir, "hosts")
|
||||
if err := os.WriteFile(staged, []byte(content), 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
beside := filepath.Join(filepath.Dir(h.Path), "."+filepath.Base(h.Path)+".hosts-tools")
|
||||
if _, err := h.Run(ctx, "install", "-m", "0644", staged, beside); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := h.Run(ctx, "mv", "-f", beside, h.Path); err != nil {
|
||||
_, _ = h.Run(ctx, "rm", "-f", beside)
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
package main
|
||||
|
||||
// The hosts file's verbs over files shaped like the workstation's on 2026-10-03 (novox/hq ADR 0199):
|
||||
// distribution lines, an operator's development names, the mesh's block and another tool's.
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const file = "# Static table lookup for hostnames.\n" +
|
||||
"127.0.0.1\tlocaldev.example.com\n" +
|
||||
"127.0.0.1 a.example.com b.example.com\n" +
|
||||
"# BEGIN mesh hosts.own\n" +
|
||||
"127.0.0.1\tlocalhost\n" +
|
||||
"::1\tlocalhost\n" +
|
||||
"# END mesh hosts.own\n" +
|
||||
"# BEGIN other-tool\n" +
|
||||
"192.0.2.7\tproject.test\n" +
|
||||
"# END other-tool\n"
|
||||
|
||||
func blocks(text string) []string {
|
||||
var out []string
|
||||
for _, l := range Parse(text) {
|
||||
if l.Owner != Operator {
|
||||
out = append(out, l.Text)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestEveryLineSaysWhoseItIs(t *testing.T) {
|
||||
lines := Parse(file)
|
||||
if len(lines) != 10 {
|
||||
t.Fatalf("%d lines: %+v", len(lines), lines)
|
||||
}
|
||||
want := Line{Text: "127.0.0.1\tlocaldev.example.com", Owner: Operator, Address: "127.0.0.1", Names: []string{"localdev.example.com"}}
|
||||
if !reflect.DeepEqual(lines[1], want) {
|
||||
t.Errorf("%+v", lines[1])
|
||||
}
|
||||
if lines[0].Address != "" || lines[0].Owner != Operator {
|
||||
t.Errorf("a comment is the operator's and no entry: %+v", lines[0])
|
||||
}
|
||||
if lines[3].Owner != "mesh hosts.own" || lines[4].Owner != "mesh hosts.own" || lines[6].Owner != "mesh hosts.own" {
|
||||
t.Errorf("the mesh's block, its markers included: %+v", lines[3:7])
|
||||
}
|
||||
if lines[8].Owner != "other-tool" || !reflect.DeepEqual(lines[8].Names, []string{"project.test"}) {
|
||||
t.Errorf("%+v", lines[8])
|
||||
}
|
||||
if lines[5].Address != "::1" {
|
||||
t.Errorf("an IPv6 entry: %+v", lines[5])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnEntryWithATrailingCommentKeepsItsNames(t *testing.T) {
|
||||
l := Parse("10.0.0.1 nas.lan # the box upstairs")[0]
|
||||
if l.Address != "10.0.0.1" || !reflect.DeepEqual(l.Names, []string{"nas.lan"}) {
|
||||
t.Errorf("%+v", l)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnUnclosedBlockHoldsTheRestOfTheFile(t *testing.T) {
|
||||
lines := Parse("# BEGIN x\n10.0.0.1 a.test\n# END y\n10.0.0.2 b.test\n")
|
||||
for _, l := range lines {
|
||||
if l.Owner != "x" {
|
||||
t.Errorf("%+v", l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddAppendsAnOperatorLineAndIsANoOpWhenTheNamesAreThere(t *testing.T) {
|
||||
after, err := WithAdded(file, "192.0.2.9", []string{"lab.test", "www.lab.test"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(after, "192.0.2.9\tlab.test www.lab.test\n") {
|
||||
t.Errorf("%q", after)
|
||||
}
|
||||
if !reflect.DeepEqual(blocks(after), blocks(file)) {
|
||||
t.Errorf("blocks changed")
|
||||
}
|
||||
if same, _ := WithAdded(file, "127.0.0.1", []string{"a.example.com"}); same != file {
|
||||
t.Errorf("a name already there changed the file")
|
||||
}
|
||||
if some, _ := WithAdded(file, "127.0.0.1", []string{"a.example.com", "c.example.com"}); !strings.HasSuffix(some, "127.0.0.1\tc.example.com\n") {
|
||||
t.Errorf("%q", some)
|
||||
}
|
||||
if ended, _ := WithAdded("127.0.0.1 localhost", "192.0.2.1", []string{"x.test"}); ended != "127.0.0.1 localhost\n192.0.2.1\tx.test\n" {
|
||||
t.Errorf("a file without a last newline: %q", ended)
|
||||
}
|
||||
if empty, _ := WithAdded("", "192.0.2.1", []string{"x.test"}); empty != "192.0.2.1\tx.test\n" {
|
||||
t.Errorf("an empty file: %q", empty)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddDoesNotCountANameOnlyABlockHasAsTheOperators(t *testing.T) {
|
||||
after, err := WithAdded(file, "127.0.0.1", []string{"localhost"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasSuffix(after, "# END other-tool\n127.0.0.1\tlocalhost\n") {
|
||||
t.Errorf("%q", after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddRefusesWhatIsNotAnAddressOrAHostName(t *testing.T) {
|
||||
for _, c := range []struct {
|
||||
address string
|
||||
names []string
|
||||
says string
|
||||
}{
|
||||
{"not-an-ip", []string{"x.test"}, "not an IPv4 or IPv6 address"},
|
||||
{"192.0.2.9", []string{"bad name\n10.0.0.1 evil"}, "not a host name"},
|
||||
{"192.0.2.9", []string{"-lead.test"}, "not a host name"},
|
||||
{"192.0.2.9", []string{strings.Repeat("a", 64) + ".test"}, "not a host name"},
|
||||
{"192.0.2.9", []string{strings.Repeat("abcdefgh.", 30)}, "not a host name"},
|
||||
{"192.0.2.9", []string{}, "at least one name"},
|
||||
} {
|
||||
if _, err := WithAdded(file, c.address, c.names); err == nil || !strings.Contains(err.Error(), c.says) {
|
||||
t.Errorf("%q %q: %v", c.address, c.names, err)
|
||||
}
|
||||
}
|
||||
if _, err := WithAdded(file, "2001:db8::1", []string{"v6.test."}); err != nil {
|
||||
t.Errorf("an IPv6 address and a rooted name: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveTakesOneNameOrOneAddressAndBlocksStayByteForByte(t *testing.T) {
|
||||
one, n, err := WithRemoved(file, "a.example.com")
|
||||
if err != nil || n != 1 {
|
||||
t.Fatalf("%d %v", n, err)
|
||||
}
|
||||
if !strings.Contains(one, "127.0.0.1\tb.example.com\n") || strings.Contains(one, "a.example.com") {
|
||||
t.Errorf("%q", one)
|
||||
}
|
||||
if !reflect.DeepEqual(blocks(one), blocks(file)) {
|
||||
t.Errorf("blocks changed")
|
||||
}
|
||||
all, n, err := WithRemoved(file, "127.0.0.1")
|
||||
if err != nil || n != 2 {
|
||||
t.Fatalf("%d %v", n, err)
|
||||
}
|
||||
if !strings.Contains(all, "# BEGIN mesh hosts.own\n127.0.0.1\tlocalhost\n") {
|
||||
t.Errorf("the mesh's own localhost is not the operator's to remove: %q", all)
|
||||
}
|
||||
if !reflect.DeepEqual(blocks(all), blocks(file)) {
|
||||
t.Errorf("blocks changed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoveRefusesANameOnlyABlockWritesNamingWhose(t *testing.T) {
|
||||
if _, _, err := WithRemoved(file, "project.test"); err == nil || !strings.Contains(err.Error(), "written by other-tool") {
|
||||
t.Errorf("%v", err)
|
||||
}
|
||||
if _, _, err := WithRemoved(file, "::1"); err == nil || !strings.Contains(err.Error(), "written by mesh hosts.own") {
|
||||
t.Errorf("%v", err)
|
||||
}
|
||||
if _, n, err := WithRemoved(file, "nowhere.test"); err != nil || n != 0 {
|
||||
t.Errorf("%d %v", n, err)
|
||||
}
|
||||
if _, _, err := WithRemoved(file, "bad name"); err == nil {
|
||||
t.Errorf("a name that is neither an address nor a host name is refused")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTheFileIsWrittenAsRootThroughSudoWhereTheAccountIsNotRoot(t *testing.T) {
|
||||
if p, a := escalated(1000, "install", []string{"x"}); p != "sudo" || !reflect.DeepEqual(a, []string{"-n", "install", "x"}) {
|
||||
t.Errorf("%s %v", p, a)
|
||||
}
|
||||
if p, a := escalated(0, "install", []string{"x"}); p != "install" || !reflect.DeepEqual(a, []string{"x"}) {
|
||||
t.Errorf("%s %v", p, a)
|
||||
}
|
||||
}
|
||||
|
||||
// runPlain runs a command as given, unescalated: the test's file is the test's own.
|
||||
func runPlain(ctx context.Context, name string, args ...string) (string, error) {
|
||||
out, err := exec.CommandContext(ctx, name, args...).CombinedOutput()
|
||||
return string(out), err
|
||||
}
|
||||
|
||||
func TestTheVerbsWriteTheFileWholeBesideItAndRenameItOver(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "hosts")
|
||||
if err := os.WriteFile(path, []byte(file), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var calls [][]string
|
||||
h := HostsFile{Path: path, Run: func(ctx context.Context, name string, args ...string) (string, error) {
|
||||
calls = append(calls, append([]string{name}, args...))
|
||||
return runPlain(ctx, name, args...)
|
||||
}}
|
||||
ctx := context.Background()
|
||||
added, err := h.Add(ctx, "192.0.2.9", []string{"lab.test"})
|
||||
if err != nil || !added.Added || added.Line != "192.0.2.9\tlab.test" {
|
||||
t.Fatalf("%+v %v", added, err)
|
||||
}
|
||||
beside := filepath.Join(dir, ".hosts.hosts-tools")
|
||||
if len(calls) != 2 || calls[0][0] != "install" || calls[0][len(calls[0])-1] != beside ||
|
||||
!reflect.DeepEqual(calls[1], []string{"mv", "-f", beside, path}) {
|
||||
t.Errorf("%v", calls)
|
||||
}
|
||||
if again, _ := h.Add(ctx, "192.0.2.9", []string{"lab.test"}); again.Added || len(calls) != 2 {
|
||||
t.Errorf("an add already there wrote the file: %+v %v", again, calls)
|
||||
}
|
||||
got, err := h.Entries()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
last := got.Lines[len(got.Lines)-1]
|
||||
if last.Owner != Operator || last.Address != "192.0.2.9" {
|
||||
t.Errorf("add then entries shows the line as the operator's: %+v", last)
|
||||
}
|
||||
removed, err := h.Remove(ctx, "lab.test")
|
||||
if err != nil || removed.Removed != 1 {
|
||||
t.Fatalf("%+v %v", removed, err)
|
||||
}
|
||||
if b, _ := os.ReadFile(path); string(b) != file {
|
||||
t.Errorf("add then remove gives the file back: %q", b)
|
||||
}
|
||||
if _, err := os.Stat(beside); !os.IsNotExist(err) {
|
||||
t.Errorf("the staged copy beside the file is left: %v", err)
|
||||
}
|
||||
if info, _ := os.Stat(path); info.Mode().Perm() != 0o644 {
|
||||
t.Errorf("mode %v", info.Mode())
|
||||
}
|
||||
}
|
||||
|
||||
func TestThePathTheCodeWritesIsThePathTheManifestsResourceDeclares(t *testing.T) {
|
||||
raw, err := os.ReadFile("../../module.json")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var m struct {
|
||||
Claims []struct {
|
||||
Name string `json:"name"`
|
||||
Serves []string `json:"serves"`
|
||||
} `json:"claims"`
|
||||
Resources []struct {
|
||||
ID string `json:"id"`
|
||||
Path string `json:"path"`
|
||||
} `json:"resources"`
|
||||
}
|
||||
if err := json.Unmarshal(raw, &m); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
found := false
|
||||
for _, r := range m.Resources {
|
||||
if r.ID == "own" {
|
||||
found = true
|
||||
if r.Path != HostsPath {
|
||||
t.Errorf("the manifest writes %s, the code %s", r.Path, HostsPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("no resource own")
|
||||
}
|
||||
var served []string
|
||||
for _, tool := range tools(HostsFile{}) {
|
||||
served = append(served, strings.TrimPrefix(tool.Name, Seat+"."))
|
||||
if tool.Description == "" || tool.Run == nil {
|
||||
t.Errorf("%s", tool.Name)
|
||||
}
|
||||
}
|
||||
if len(m.Claims) != 1 || m.Claims[0].Name != Seat || !reflect.DeepEqual(m.Claims[0].Serves, served) {
|
||||
t.Errorf("the manifest serves %+v, the binary %v", m.Claims, served)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamesAreSplitOnSpacesAndCommasOrTakenAsAList(t *testing.T) {
|
||||
if got := namesArg(map[string]any{"names": " a.test, b.test c.test"}); !reflect.DeepEqual(got, []string{"a.test", "b.test", "c.test"}) {
|
||||
t.Errorf("%v", got)
|
||||
}
|
||||
if got := namesArg(map[string]any{"names": []any{"a.test", "b.test"}}); !reflect.DeepEqual(got, []string{"a.test", "b.test"}) {
|
||||
t.Errorf("%v", got)
|
||||
}
|
||||
if got := namesArg(map[string]any{}); len(got) != 0 {
|
||||
t.Errorf("%v", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
// hosts-tools (novox/hq ADR 0199): the hosts file's tools. One binary, launched by the machine's tool
|
||||
// runtime and speaking MCP to it over stdio through the Go SDK (ADR 0193, ADR 0198): the
|
||||
// node-hosts-file seat's three verbs — the file's lines with whose each is, add an operator's line,
|
||||
// remove one. They change the machine's file and nothing else; the controller holds none of it.
|
||||
//
|
||||
// stdout is the MCP channel; everything this module says, it says on stderr.
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
stdio "git.novox.be/novox/mesh-sdk/go"
|
||||
)
|
||||
|
||||
// Seat is the role this module holds.
|
||||
const Seat = "node-hosts-file"
|
||||
|
||||
func main() {
|
||||
if err := stdio.Serve("", tools(HostsFile{Path: HostsPath, Run: execRunner})); err != nil {
|
||||
fmt.Fprintf(os.Stderr, "[hosts] %v\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func str(description string) map[string]any {
|
||||
return map[string]any{"type": "string", "description": description}
|
||||
}
|
||||
|
||||
func arg(a map[string]any, k string) string {
|
||||
v, _ := a[k].(string)
|
||||
return strings.TrimSpace(v)
|
||||
}
|
||||
|
||||
var separators = regexp.MustCompile(`[\s,]+`)
|
||||
|
||||
// namesArg is the names given, separated by spaces or commas — or, from a caller that sends a list,
|
||||
// the list.
|
||||
func namesArg(a map[string]any) []string {
|
||||
var raw []string
|
||||
switch v := a["names"].(type) {
|
||||
case string:
|
||||
raw = separators.Split(v, -1)
|
||||
case []any:
|
||||
for _, n := range v {
|
||||
if s, ok := n.(string); ok {
|
||||
raw = append(raw, separators.Split(s, -1)...)
|
||||
}
|
||||
}
|
||||
}
|
||||
names := []string{}
|
||||
for _, n := range raw {
|
||||
if n != "" {
|
||||
names = append(names, n)
|
||||
}
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// verb is one of the seat's verbs: listed as `<seat>.<verb>`, so the runtime serves it on the seat's
|
||||
// subject, as <node>/node-hosts-file.<verb>.
|
||||
func verb(name, description string, input map[string]any, run func(a map[string]any) (any, error)) stdio.Tool {
|
||||
return stdio.Tool{Name: Seat + "." + name, Description: description, Input: input, Run: run}
|
||||
}
|
||||
|
||||
func tools(h HostsFile) []stdio.Tool {
|
||||
ctx := context.Background()
|
||||
return []stdio.Tool{
|
||||
verb("entries", "Every line of this machine's /etc/hosts, each marked whose it is: the operator's, or the block of the module or tool that writes it.",
|
||||
nil, func(map[string]any) (any, error) { return h.Entries() }),
|
||||
verb("add", "Add one address and its names to the operator's lines of this machine's /etc/hosts — a name for this machine's own programs, not the mesh's. Nothing changes when they are already there.",
|
||||
map[string]any{"address": str("the IPv4 or IPv6 address"), "names": str("the names for it, separated by spaces")},
|
||||
func(a map[string]any) (any, error) { return h.Add(ctx, arg(a, "address"), namesArg(a)) }),
|
||||
verb("remove", "Remove one name, or every line of one address, from the operator's lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.",
|
||||
map[string]any{"name": str("a host name, or an address to remove every line of")},
|
||||
func(a map[string]any) (any, error) { return h.Remove(ctx, arg(a, "name")) }),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module hosts
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.7
|
||||
@@ -0,0 +1,2 @@
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7 h1:C0sTQmtTiyYH7bnqZb7PusXnqA37gKuT7Nqjn9gG47w=
|
||||
git.novox.be/novox/mesh-sdk/go v0.1.7/go.mod h1:GFuZUElBZ9A++mxgIKo97aXXo+kV0uJ/UkbhQPPIbrY=
|
||||
@@ -20,7 +20,7 @@
|
||||
"mode": "0644",
|
||||
"into": "block",
|
||||
"at": "start",
|
||||
"content": "# The machine's own names (module hosts, novox/hq ADR 0199). Every line outside this block is the\n# operator's: kept across every push, changed through the node-hosts-file verbs add and remove, and\n# given back when this module goes. The mesh's names are not here: the mesh's resolver answers them.\n127.0.0.1\tlocalhost\n::1\tlocalhost\n"
|
||||
"content": "# The machine's own names (module hosts, novox/hq ADR 0199). Every line outside this block is the\n# operator's: kept across every push, changed through the node-hosts-file verbs add and remove, and\n# given back when this module goes. The mesh's names are not here: the mesh's resolver answers them.\n127.0.0.1\tlocalhost\n::1\tlocalhost\n127.0.1.1\t${machine:name}\n"
|
||||
}
|
||||
],
|
||||
"build": {
|
||||
@@ -28,12 +28,12 @@
|
||||
{
|
||||
"name": "tools",
|
||||
"kind": "bundle",
|
||||
"language": "typescript",
|
||||
"entrypoints": [
|
||||
"tools/index.js"
|
||||
],
|
||||
"language": "go",
|
||||
"system": "arch",
|
||||
"from": "cmd/hosts-tools",
|
||||
"binary": "hosts-tools",
|
||||
"loads": [
|
||||
"tools/index.js"
|
||||
"hosts-tools"
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
{
|
||||
"name": "@novox/module-hosts",
|
||||
"version": "0.1.0",
|
||||
"description": "hosts — holds the node-hosts-file seat: writes the machine's own lines into /etc/hosts and serves the verbs entries, add and remove over the operator's lines (novox/hq ADR 0199).",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@novox/mesh-sdk": "^0.1.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.0.0",
|
||||
"typescript": "^5.6.0"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "tsc client.ts tools/index.ts --module NodeNext --moduleResolution NodeNext --target ES2022 --rootDir . --outDir dist",
|
||||
"test": "node --test --experimental-strip-types 'test/*.test.ts'"
|
||||
}
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
// The hosts file's verbs over files shaped like the workstation's on 2026-10-03 (novox/hq ADR 0199):
|
||||
// distribution lines, an operator's development names, the mesh's block and another tool's.
|
||||
import { test } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { HOSTS_FILE, escalated, parse, withAdded, withRemoved } from "../client.ts";
|
||||
|
||||
const FILE =
|
||||
"# Static table lookup for hostnames.\n" +
|
||||
"127.0.0.1\tlocaldev.example.com\n" +
|
||||
"127.0.0.1 a.example.com b.example.com\n" +
|
||||
"# BEGIN mesh hosts.own\n" +
|
||||
"127.0.0.1\tlocalhost\n" +
|
||||
"::1\tlocalhost\n" +
|
||||
"# END mesh hosts.own\n" +
|
||||
"# BEGIN other-tool\n" +
|
||||
"192.0.2.7\tproject.test\n" +
|
||||
"# END other-tool\n";
|
||||
|
||||
const blocks = (text: string) => parse(text).filter((l) => l.owner !== "operator").map((l) => l.text);
|
||||
|
||||
test("every line says whose it is", () => {
|
||||
const lines = parse(FILE);
|
||||
assert.equal(lines.length, 10);
|
||||
assert.deepEqual(lines[1], { text: "127.0.0.1\tlocaldev.example.com", owner: "operator", address: "127.0.0.1", names: ["localdev.example.com"] });
|
||||
assert.equal(lines[4].owner, "mesh hosts.own");
|
||||
assert.equal(lines[8].owner, "other-tool");
|
||||
assert.deepEqual(lines[8].names, ["project.test"]);
|
||||
});
|
||||
|
||||
test("add appends an operator line, and is a no-op when the names are there", () => {
|
||||
const after = withAdded(FILE, "192.0.2.9", ["lab.test", "www.lab.test"]);
|
||||
assert.ok(after.endsWith("192.0.2.9\tlab.test www.lab.test\n"));
|
||||
assert.deepEqual(blocks(after), blocks(FILE));
|
||||
assert.equal(withAdded(FILE, "127.0.0.1", ["a.example.com"]), FILE);
|
||||
assert.ok(withAdded(FILE, "127.0.0.1", ["a.example.com", "c.example.com"]).endsWith("127.0.0.1\tc.example.com\n"));
|
||||
});
|
||||
|
||||
test("add refuses what is not an address or a host name", () => {
|
||||
assert.throws(() => withAdded(FILE, "not-an-ip", ["x.test"]), /not an IPv4 or IPv6 address/);
|
||||
assert.throws(() => withAdded(FILE, "192.0.2.9", ["bad name\n10.0.0.1 evil"]), /not a host name/);
|
||||
assert.throws(() => withAdded(FILE, "192.0.2.9", []), /at least one name/);
|
||||
});
|
||||
|
||||
test("remove takes one name or one address from the operator's lines, and blocks stay byte for byte", () => {
|
||||
const one = withRemoved(FILE, "a.example.com");
|
||||
assert.equal(one.removed, 1);
|
||||
assert.ok(one.text.includes("127.0.0.1\tb.example.com\n"));
|
||||
assert.ok(!one.text.includes("a.example.com"));
|
||||
assert.deepEqual(blocks(one.text), blocks(FILE));
|
||||
const all = withRemoved(FILE, "127.0.0.1");
|
||||
assert.equal(all.removed, 2);
|
||||
assert.ok(all.text.includes("# BEGIN mesh hosts.own\n127.0.0.1\tlocalhost\n"), "the mesh's own localhost is not the operator's to remove");
|
||||
});
|
||||
|
||||
test("remove refuses a name only a block writes, naming whose", () => {
|
||||
assert.throws(() => withRemoved(FILE, "project.test"), /written by other-tool/);
|
||||
assert.equal(withRemoved(FILE, "nowhere.test").removed, 0);
|
||||
});
|
||||
|
||||
test("the file is written as root through sudo where the account is not root", () => {
|
||||
assert.deepEqual(escalated("install", ["x"], 1000), ["sudo", ["-n", "install", "x"]]);
|
||||
assert.deepEqual(escalated("install", ["x"], 0), ["install", ["x"]]);
|
||||
});
|
||||
|
||||
test("the path the code writes is the path the manifest's resource declares", () => {
|
||||
const manifest = JSON.parse(readFileSync(new URL("../module.json", import.meta.url), "utf8"));
|
||||
assert.equal(manifest.resources.find((r: { id: string }) => r.id === "own").path, HOSTS_FILE);
|
||||
});
|
||||
@@ -1,40 +0,0 @@
|
||||
// The hosts file's tools: the node-hosts-file seat's three verbs (novox/hq ADR 0199) — the file's lines
|
||||
// with whose each is, add an operator's line, remove one. They change the machine's file and nothing
|
||||
// else; the controller holds none of it.
|
||||
|
||||
import { registerModuleTools, type ToolDefinition } from "@novox/mesh-sdk/tools";
|
||||
import { HostsFile } from "../client.js";
|
||||
|
||||
export function getSeatVerbs(hosts: HostsFile): ToolDefinition[] {
|
||||
return [
|
||||
{
|
||||
name: "entries",
|
||||
description:
|
||||
"Every line of this machine's /etc/hosts, each marked whose it is: the operator's, or the block of the module or tool that writes it.",
|
||||
input: {},
|
||||
run: async () => hosts.entries(),
|
||||
},
|
||||
{
|
||||
name: "add",
|
||||
description:
|
||||
"Add one address and its names to the operator's lines of this machine's /etc/hosts — a name for this machine's own programs, not the mesh's. Nothing changes when they are already there.",
|
||||
input: {
|
||||
address: { type: "string", description: "the IPv4 or IPv6 address" },
|
||||
names: { type: "string", description: "the names for it, separated by spaces" },
|
||||
},
|
||||
run: async (args) =>
|
||||
hosts.add(String(args.address ?? ""), String(args.names ?? "").split(/[\s,]+/).filter(Boolean)),
|
||||
},
|
||||
{
|
||||
name: "remove",
|
||||
description:
|
||||
"Remove one name, or every line of one address, from the operator's lines of this machine's /etc/hosts. A line a module writes is refused, naming the module.",
|
||||
input: { name: { type: "string", description: "a host name, or an address to remove every line of" } },
|
||||
run: async (args) => hosts.remove(String(args.name ?? "")),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
const hosts = HostsFile.onThisMachine();
|
||||
// The seat's verbs under the seat's name: the runtime serves them as <node>/node-hosts-file.<verb>.
|
||||
registerModuleTools("node-hosts-file", () => getSeatVerbs(hosts));
|
||||
@@ -1,15 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": [
|
||||
"client.ts",
|
||||
"tools/index.ts"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user