New:Socket for Asana Is Now Available.Learn more β†’
Get Started

moshcode

Package Overview
Dependencies
Maintainers
1
Versions
60
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

moshcode - npm Package Compare versions

Comparing version
0.68.0
to
0.69.0
+407
src/dns-filter-cli.mjs
/**
* `moshcode dns filter` β€” the verb over the policy in `dns-filter.mjs`.
*
* Kept out of `dns.mjs` for the same reason the policy is: that file is vendored
* from `@moshcoder/moshpit-dns` and is ported by hand, so it gets the hook and
* nothing else.
*
* The thing this command has to be honest about, in every subcommand that could
* mislead, is that a filter only filters what passes through the bridge. Writing
* `enabled: true` into a file on a machine whose resolver has never heard of the
* bridge changes nothing at all, and a status line that says `on` without saying
* that is the same lie as `bridge started` being printed by a run that never
* wrote the routing.
*/
import {
BLOCK_MODES,
CATALOG_BY_ID,
DEFAULT_CATEGORIES,
DEFAULT_MODE,
FILTER_CATALOG,
configPath,
filterDir,
listPath,
listStatus,
matchSuffix,
normaliseName,
readCachedList,
readConfig,
readStats,
updateList,
writeConfig,
} from "./dns-filter.mjs";
import { DEFAULT_HOST, DEFAULT_PORT, bridgePresence, describeBridge, parseDnsPort } from "./dns.mjs";
import { daemonStatus } from "./dns-system.mjs";
const USAGE = `moshcode dns filter β€” block names before they are ever looked up
moshcode dns filter what is on, what it has blocked
moshcode dns filter on start filtering (${DEFAULT_CATEGORIES.join(", ")})
moshcode dns filter off stop filtering; keeps the lists and the rules
moshcode dns filter lists the catalogue, and what is cached here
moshcode dns filter add <list>... turn a category on
moshcode dns filter remove <list>... turn one off
moshcode dns filter update [<list>] fetch the lists β€” nothing downloads on its own
moshcode dns filter block <name>... always block this name and everything under it
moshcode dns filter allow <name>... never block it, whatever any list says
moshcode dns filter unblock <name>...
moshcode dns filter unallow <name>...
moshcode dns filter test <name> would this be blocked, and by which rule
--mode nxdomain|zero|refuse how a blocked name is answered (default ${DEFAULT_MODE})
--lists a,b with \`on\`: the categories to run, instead of the default
--json with status, lists or test: one document for scripts
Filtering happens in the bridge, so it applies to exactly the queries the bridge
sees: with \`dns enable\` on, that is every lookup this machine makes. Changes are
picked up by a running bridge within about five seconds β€” no restart, no reload.
This command never turns DNS routing on.`;
const flagValue = (args, name) => {
const index = args.indexOf(name);
if (index >= 0 && args[index + 1]) return args[index + 1];
const inline = args.find((a) => a.startsWith(`${name}=`));
return inline ? inline.slice(name.length + 1) : null;
};
const positional = (args) => {
const out = [];
for (let i = 0; i < args.length; i++) {
const arg = args[i];
if (arg === "--mode" || arg === "--lists" || arg === "--port") { i += 1; continue; }
if (arg.startsWith("-")) continue;
out.push(arg);
}
return out;
};
const plural = (n, one, many = `${one}s`) => `${n} ${n === 1 ? one : many}`;
/**
* Is there a bridge that could be applying this policy?
*
* Probed rather than read from the pidfile. A bridge started by systemd, by
* hand, or by an escalated `dns enable` leaves no pidfile this process can see,
* and reporting "no bridge" for a machine that is filtering every lookup would
* send someone to fix the wrong thing.
*/
async function bridgeLine({ host, port, presence = bridgePresence, recorded = daemonStatus }) {
try {
const found = await presence({ host, port, recorded: await recorded().catch(() => undefined) });
return { found, text: describeBridge(found, { host, port }) };
} catch {
return { found: { kind: "unknown", answering: false }, text: "could not be determined" };
}
}
export async function filterCommand(args = [], out = console.log, deps = {}) {
const {
dir = filterDir(),
fetchImpl = fetch,
presence = bridgePresence,
recorded = daemonStatus,
now = () => new Date(),
} = deps;
const sub = positional(args)[0] || "status";
const rest = positional(args).slice(1);
const json = args.includes("--json");
const host = DEFAULT_HOST;
const port = parseDnsPort(flagValue(args, "--port")) || DEFAULT_PORT;
if (sub === "help" || args.includes("--help") || args.includes("-h")) {
out(USAGE);
return 0;
}
let config;
try {
config = await readConfig(dir);
} catch (err) {
out(`! ${err.message}`);
out(` fix or remove ${configPath(dir)} β€” filtering is off until it parses`);
return 1;
}
/* ------------------------------------------------------------------ lists */
if (sub === "lists") {
const rows = [];
for (const entry of FILTER_CATALOG) {
const cached = await listStatus(dir, entry.id);
rows.push({ ...entry, ...cached, on: config.categories.includes(entry.id) });
}
if (json) {
out(JSON.stringify({ dir, categories: config.categories, lists: rows }, null, 2));
return 0;
}
for (const row of rows) {
const state = row.on ? "on " : "off";
const cache = row.cached
? `cached ${row.bytes < 1024 ? "<1" : Math.round(row.bytes / 1024)}k, ${row.at.slice(0, 10)}`
: "not fetched";
out(` ${state} ${row.id.padEnd(9)} ${row.title.padEnd(22)} ${cache}`);
out(` ${row.note}`);
}
out("");
out("fetch what is on with: moshcode dns filter update");
return 0;
}
/* ------------------------------------------------------------------- test */
if (sub === "test") {
const name = rest[0];
if (!name) {
out("usage: moshcode dns filter test <name>");
return 1;
}
const clean = normaliseName(name);
if (!clean) {
out(`! ${name} is not a name this can match`);
return 1;
}
// Read straight from the cache rather than through a filter handle: this
// has to answer for a category that is cached but switched off, so that
// "why is this not blocked" has an answer other than silence.
const allowed = matchSuffix(clean, new Set(config.allow));
const blockedBy = matchSuffix(clean, new Set(config.block));
const hits = [];
if (blockedBy) hits.push({ list: "custom", rule: blockedBy, on: true });
for (const entry of FILTER_CATALOG) {
const set = await readCachedList(dir, entry.id);
if (!set) continue;
const rule = matchSuffix(clean, set);
if (rule) hits.push({ list: entry.id, rule, on: config.categories.includes(entry.id) });
}
const live = hits.filter((h) => h.on);
const blocked = config.enabled && !allowed && live.length > 0;
if (json) {
out(JSON.stringify({ name: clean, blocked, mode: config.mode, allowed, hits }, null, 2));
return 0;
}
if (!config.enabled) out("filtering is off β€” this is what would happen with it on");
if (allowed) {
out(`${clean} β€” allowed by your rule \`${allowed}\``);
if (hits.length) out(` (${plural(hits.length, "list")} would otherwise block it: ${hits.map((h) => h.list).join(", ")})`);
return 0;
}
if (!live.length) {
out(`${clean} β€” not blocked`);
const dormant = hits.filter((h) => !h.on);
if (dormant.length) {
out(` it is in ${dormant.map((h) => h.list).join(", ")}, which ${dormant.length === 1 ? "is" : "are"} not switched on`);
out(` turn one on with: moshcode dns filter add ${dormant[0].list}`);
}
return 0;
}
out(`${clean} β€” blocked by ${live[0].list} (rule \`${live[0].rule}\`), answered as ${config.mode}`);
if (live.length > 1) out(` also in: ${live.slice(1).map((h) => h.list).join(", ")}`);
out(` keep it working with: moshcode dns filter allow ${clean}`);
return 0;
}
/* ----------------------------------------------------------------- update */
if (sub === "update") {
const wanted = rest.length ? rest : config.categories;
if (!wanted.length) {
out("no categories are on β€” nothing to fetch");
out(` turn one on with: moshcode dns filter add ${DEFAULT_CATEGORIES[0]}`);
return 1;
}
const unknown = wanted.filter((id) => !CATALOG_BY_ID.has(id));
if (unknown.length) {
out(`! no such list: ${unknown.join(", ")}`);
out(` the catalogue is: ${FILTER_CATALOG.map((e) => e.id).join(", ")}`);
return 1;
}
let failed = 0;
for (const id of wanted) {
try {
const result = await updateList(dir, id, { fetchImpl });
out(`ok ${id.padEnd(9)} ${result.count.toLocaleString()} names`);
} catch (err) {
failed += 1;
// Named and survived rather than thrown: one dead source should not
// stop the other seven from refreshing.
out(`! ${id.padEnd(9)} ${err?.message || err}`);
}
}
await writeConfig(dir, { ...config, updatedAt: now().toISOString() });
if (failed) out(`\n${plural(failed, "list")} did not refresh β€” the cached copy is still in use`);
if (config.enabled) out("\na running bridge picks these up within about five seconds");
else out("\nfiltering is off β€” turn it on with: moshcode dns filter on");
return failed === wanted.length ? 1 : 0;
}
/* --------------------------------------------------------------- on / off */
if (sub === "on" || sub === "off") {
const mode = flagValue(args, "--mode");
if (mode && !BLOCK_MODES.includes(mode)) {
out(`! --mode must be one of: ${BLOCK_MODES.join(", ")}`);
return 1;
}
const chosen = flagValue(args, "--lists");
const categories = chosen
? chosen.split(",").map((s) => s.trim()).filter(Boolean)
: (config.categories.length ? config.categories : DEFAULT_CATEGORIES.slice());
const unknown = categories.filter((id) => !CATALOG_BY_ID.has(id));
if (unknown.length) {
out(`! no such list: ${unknown.join(", ")}`);
out(` the catalogue is: ${FILTER_CATALOG.map((e) => e.id).join(", ")}`);
return 1;
}
const next = await writeConfig(dir, {
...config,
enabled: sub === "on",
mode: mode || config.mode,
categories,
});
if (sub === "off") {
out("filtering off β€” lists and rules kept, nothing is being blocked");
return 0;
}
const missing = [];
for (const id of next.categories) {
if (!(await listStatus(dir, id)).cached) missing.push(id);
}
out(`filtering on β€” ${next.categories.join(", ")}, blocked names answered as ${next.mode}`);
if (missing.length) {
// The state that would otherwise read as success and block nothing.
out(`! ${plural(missing.length, "list")} ${missing.length === 1 ? "has" : "have"} never been fetched: ${missing.join(", ")}`);
out(" nothing is blocked from them until you run: moshcode dns filter update");
}
const bridge = await bridgeLine({ host, port, presence, recorded });
if (!bridge.found.answering) {
out(`! no bridge is answering on ${host}:${port} β€” ${bridge.text}`);
out(" the filter runs inside the bridge, so nothing is filtered until one does.");
out(" turn DNS on deliberately with: sudo moshcode dns enable");
} else if (bridge.found.forwards === false) {
out(`! the bridge on ${host}:${port} answers Moshpit names but does not forward clearnet ones`);
out(" it is not in the path of ordinary lookups, so only Moshpit names are filtered");
}
return 0;
}
/* ------------------------------------------------------ categories, rules */
const listVerbs = { add: true, remove: true };
if (listVerbs[sub]) {
if (!rest.length) {
out(`usage: moshcode dns filter ${sub} <list>...`);
return 1;
}
const unknown = rest.filter((id) => !CATALOG_BY_ID.has(id));
if (unknown.length) {
out(`! no such list: ${unknown.join(", ")}`);
out(` the catalogue is: ${FILTER_CATALOG.map((e) => e.id).join(", ")}`);
return 1;
}
const set = new Set(config.categories);
for (const id of rest) (sub === "add" ? set.add(id) : set.delete(id));
const next = await writeConfig(dir, { ...config, categories: Array.from(set) });
out(next.categories.length ? `lists: ${next.categories.join(", ")}` : "lists: none");
if (sub === "add") {
const missing = [];
for (const id of rest) if (!(await listStatus(dir, id)).cached) missing.push(id);
if (missing.length) out(` fetch ${missing.join(", ")} with: moshcode dns filter update ${missing.join(" ")}`);
}
return 0;
}
const ruleVerbs = {
block: { field: "block", add: true, said: "blocked" },
allow: { field: "allow", add: true, said: "allowed" },
unblock: { field: "block", add: false, said: "no longer blocked by rule" },
unallow: { field: "allow", add: false, said: "no longer allowed by rule" },
};
if (ruleVerbs[sub]) {
const { field, add, said } = ruleVerbs[sub];
if (!rest.length) {
out(`usage: moshcode dns filter ${sub} <name>...`);
return 1;
}
const names = [];
for (const raw of rest) {
const clean = normaliseName(raw);
if (!clean) {
out(`! ${raw} is not a name`);
return 1;
}
names.push(clean);
}
const set = new Set(config[field]);
for (const name of names) (add ? set.add(name) : set.delete(name));
const next = await writeConfig(dir, { ...config, [field]: Array.from(set) });
out(`${names.join(", ")} β€” ${said}${add ? ", along with everything under it" : ""}`);
out(` ${plural(next[field].length, "rule")} in your ${field} list`);
if (!next.enabled) out(" filtering is off, so this takes effect when you turn it on");
return 0;
}
/* ----------------------------------------------------------------- status */
if (sub !== "status") {
out(`unknown: moshcode dns filter ${sub}`);
out(USAGE);
return 1;
}
const stats = await readStats(dir);
const cached = [];
for (const id of config.categories) cached.push(await listStatus(dir, id));
const bridge = await bridgeLine({ host, port, presence, recorded });
if (json) {
out(JSON.stringify({
dir,
enabled: config.enabled,
mode: config.mode,
categories: config.categories,
block: config.block,
allow: config.allow,
lists: cached,
bridge: { kind: bridge.found.kind, answering: Boolean(bridge.found.answering), forwards: Boolean(bridge.found.forwards) },
stats,
}, null, 2));
return 0;
}
out(`filter ${config.enabled ? `on β€” answering blocked names as ${config.mode}` : "off"}`);
out(`bridge ${bridge.text}`);
out(`lists ${config.categories.length ? config.categories.join(", ") : "none"}`);
const never = cached.filter((c) => !c.cached);
if (never.length) out(` ! never fetched: ${never.map((c) => c.id).join(", ")} β€” run \`moshcode dns filter update\``);
if (config.block.length || config.allow.length) {
out(`rules ${plural(config.block.length, "block")}, ${plural(config.allow.length, "allow")}`);
}
if (stats) {
const share = stats.queries ? `${((stats.blocked / stats.queries) * 100).toFixed(1)}%` : "0%";
out(`blocked ${stats.blocked.toLocaleString()} of ${stats.queries.toLocaleString()} queries (${share}) as of ${String(stats.at).slice(0, 19).replace("T", " ")}`);
for (const [id, count] of Object.entries(stats.byList || {}).sort((a, b) => b[1] - a[1])) {
out(` ${String(count).padStart(7)} ${id}`);
}
if (stats.recent?.length) {
out("recent " + stats.recent.slice(0, 5).map((r) => r.name).join(", "));
}
} else if (config.enabled) {
// The counters are written by the bridge, so their absence is a fact about
// the bridge rather than about the filter.
out("blocked no counts yet β€” the bridge writes them once it is answering");
}
if (config.enabled && !bridge.found.answering) {
out("");
out(`! nothing is being filtered: the filter runs inside the bridge and none is answering on ${host}:${port}`);
out(" turn DNS on deliberately with: sudo moshcode dns enable");
}
out("");
out(`config ${configPath(dir)}`);
out(`lists at ${listPath(dir, "<list>")}`);
return 0;
}
/**
* Blocklist filtering for the Moshpit bridge.
*
* With catch-all routing on, the bridge already sees every lookup this machine
* makes β€” that is what makes Moshpit names resolve at all. Filtering is the
* other thing a resolver in that position can do: refuse the names that exist
* only to track, mine, phish or advertise, before the connection is ever made.
*
* The policy lives here rather than in `dns.mjs` for two reasons. `dns.mjs` is a
* vendored copy of `@moshcoder/moshpit-dns` and every line added to it is a line
* to port by hand at the next sync; and the decision "is this name blocked" is
* pure β€” a name, some sets, an answer β€” which is worth being able to test
* without a socket.
*
* Three things this deliberately does not do:
*
* - It never turns DNS routing on. `moshcode dns filter on` writes a file and
* nothing else; a machine with no bridge in its query path is unaffected by
* it. Enabling the bridge stays something a human types.
* - It never fetches a list on its own. Lists are downloaded by
* `dns filter update` and read from a cache after that, so a resolver in the
* hot path of every lookup on the machine never waits on the network to
* decide, and an offline box keeps answering exactly as it did.
* - An allow entry always beats a block entry. A blocklist someone else
* maintains will eventually take down something you need, and the fix has to
* be one command that cannot be undone by the next `update`.
*/
import { promises as fs } from "node:fs";
import os from "node:os";
import path from "node:path";
export const FILTER_VERSION = 1;
/**
* The lists on offer, all public and all fetched by URL at `update` time.
*
* `format` is how the file is written, not what it contains: `hosts` is the
* `0.0.0.0 name` shape, `domains` is one name per line. Both are parsed by
* `parseList`, which is lenient enough that the distinction is documentation β€”
* it matters when reading a source, not when reading a cache.
*/
export const FILTER_CATALOG = [
{
id: "ads",
title: "Ads and trackers",
format: "hosts",
url: "https://raw.githubusercontent.com/StevenBlack/hosts/master/hosts",
note: "StevenBlack unified β€” the baseline nearly every blocker starts from",
},
{
id: "malware",
title: "Malware distribution",
format: "hosts",
url: "https://urlhaus.abuse.ch/downloads/hostfile/",
note: "URLhaus, abuse.ch β€” hosts serving malware payloads right now",
},
{
id: "phishing",
title: "Phishing",
format: "domains",
url: "https://phishing.army/download/phishing_army_blocklist_extended.txt",
note: "Phishing Army, extended",
},
{
id: "mining",
title: "Cryptomining",
format: "hosts",
url: "https://raw.githubusercontent.com/hoshsadiq/adblock-nocoin-list/master/hosts.txt",
note: "in-browser miners",
},
{
id: "adult",
title: "Adult content",
format: "hosts",
url: "https://raw.githubusercontent.com/StevenBlack/hosts/master/extensions/porn/clefspeare13/hosts",
note: "off by default",
},
{
id: "gambling",
title: "Gambling",
format: "hosts",
url: "https://raw.githubusercontent.com/StevenBlack/hosts/master/extensions/gambling/sinfonietta/hosts",
note: "off by default",
},
{
id: "social",
title: "Social networks",
format: "hosts",
url: "https://raw.githubusercontent.com/StevenBlack/hosts/master/extensions/social/sinfonietta/hosts",
note: "off by default β€” blocks the sites themselves, not just their trackers",
},
{
id: "fakenews",
title: "Fake news",
format: "hosts",
url: "https://raw.githubusercontent.com/StevenBlack/hosts/master/extensions/fakenews/hosts",
note: "off by default",
},
];
export const CATALOG_BY_ID = new Map(FILTER_CATALOG.map((entry) => [entry.id, entry]));
/**
* What `filter on` turns on when told nothing else: the four categories that
* block things nobody asks for. Everything that blocks content a person might
* actually want β€” adult, gambling, social, fakenews β€” is opt-in by name.
*/
export const DEFAULT_CATEGORIES = ["ads", "malware", "phishing", "mining"];
/**
* How a blocked name is answered.
*
* nxdomain the name does not exist. Fastest failure in a browser, and the
* one that caches; the default for that reason.
* zero 0.0.0.0 / :: β€” an address that goes nowhere. Slower to fail, but
* it keeps the name existing, which some captive software insists
* on before it will show its own error rather than hang.
* refuse REFUSED. Honest β€” "I will not answer this" β€” and the only mode a
* client can tell apart from a real absence, so it is the one to
* use while working out whether the filter is what broke something.
*/
export const BLOCK_MODES = ["nxdomain", "zero", "refuse"];
export const DEFAULT_MODE = "nxdomain";
/** Where the config, the cached lists and the counters live. */
export function filterDir(env = process.env, home = os.homedir()) {
return env.MOSHCODE_DNS_FILTER_DIR || path.join(home, ".moshcode", "dns-filter");
}
export const configPath = (dir) => path.join(dir, "filter.json");
export const listPath = (dir, id) => path.join(dir, "lists", `${id}.txt`);
export const statsPath = (dir) => path.join(dir, "stats.json");
/**
* A name as this module compares them: lowercase, no trailing dot, no leading
* dot, and nothing that is not a name at all. Every entry in every set goes
* through here too, so a list written with mixed case or absolute names matches
* a query written the other way.
*/
export function normaliseName(name) {
const clean = String(name ?? "").trim().toLowerCase().replace(/\.+$/, "").replace(/^\.+/, "");
if (!clean || clean.length > 253) return null;
if (!/^[a-z0-9_*](?:[a-z0-9_*.-]*[a-z0-9_*])?$/.test(clean)) return null;
return clean;
}
// The left-hand column of a hosts file: where the list points a name it is
// killing. These are not names to block β€” blocking 0.0.0.0 is meaningless and
// blocking 127.0.0.1 would be a bad afternoon.
const SINKHOLES = new Set([
"0.0.0.0", "127.0.0.1", "255.255.255.255", "::", "::1", "ff00::0", "ff02::1", "ff02::2", "ff02::3",
"fe80::1%lo0", "0000:0000:0000:0000:0000:0000:0000:0000",
]);
// Names a hosts file always carries and that must never end up in a blocklist:
// they are the machine describing itself to itself.
const NEVER_BLOCK = new Set([
"localhost", "localhost.localdomain", "local", "broadcasthost",
"ip6-localhost", "ip6-loopback", "ip6-localnet", "ip6-mcastprefix",
"ip6-allnodes", "ip6-allrouters", "ip6-allhosts",
]);
/**
* Read a blocklist in any of the shapes these sources ship in.
*
* Hosts lines (`0.0.0.0 a.example b.example`), plain one-name-per-line lists,
* and the `||name^` form an adblock-syntax list uses β€” the last only because it
* costs one regex and turns a whole class of source from "unparseable" into
* "works", not because anything in the catalogue needs it.
*
* Anything else on a line is dropped rather than guessed at. A blocklist parser
* that improvises produces a resolver that blocks something nobody can explain.
*/
export function parseList(text) {
const out = [];
const seen = new Set();
for (const raw of String(text ?? "").split(/\r?\n/)) {
const line = raw.split("#")[0].split("!")[0].trim();
if (!line) continue;
const fields = line.split(/\s+/);
let candidates;
if (fields.length > 1) {
// A hosts line is only a hosts line if the first field is a sinkhole. A
// two-field line that starts with a real address is somebody's actual
// /etc/hosts entry and none of our business.
if (!SINKHOLES.has(fields[0].toLowerCase())) continue;
candidates = fields.slice(1);
} else {
const adblock = fields[0].match(/^\|\|([^/^$]+)\^?$/);
candidates = [adblock ? adblock[1] : fields[0]];
}
for (const candidate of candidates) {
const name = normaliseName(candidate);
// A blocklist entry must have a dot. Without this rule one malformed line
// reading `com` blocks the internet, and the failure looks like the
// network being down rather than like a bad list.
if (!name || !name.includes(".") || name.includes("*")) continue;
if (NEVER_BLOCK.has(name) || SINKHOLES.has(name)) continue;
if (seen.has(name)) continue;
seen.add(name);
out.push(name);
}
}
return out;
}
/**
* Does `name`, or any parent of it, appear in `set`?
*
* Blocking a name blocks everything under it β€” a list naming `doubleclick.net`
* means the tracker at `stats.g.doubleclick.net` too, and every list in the
* catalogue is written on that assumption. Returns the entry that matched, so
* the answer to "why was this blocked" is a rule someone can look up rather
* than a boolean.
*
* The walk includes the bare rightmost label. No fetched list can contain one
* (`parseList` requires a dot), so this only ever fires for something typed by
* hand β€” which is how `filter block eggs` takes out a whole Moshpit ending.
*/
export function matchSuffix(name, set) {
const clean = normaliseName(name);
if (!clean || !set || set.size === 0) return null;
const labels = clean.split(".");
for (let i = 0; i < labels.length; i++) {
const candidate = labels.slice(i).join(".");
if (set.has(candidate)) return candidate;
}
return null;
}
const toSet = (names) => {
const set = new Set();
for (const name of names || []) {
const clean = normaliseName(name);
if (clean) set.add(clean);
}
return set;
};
/**
* The decision half, with no filesystem under it.
*
* `lists` is a Map of category id to a Set of names, iterated in order so the
* category reported for a name that several lists carry is stable rather than
* whichever one happened to be built first.
*/
export function createFilter({
enabled = true,
mode = DEFAULT_MODE,
lists = new Map(),
allow = [],
block = [],
} = {}) {
const allowSet = toSet(allow);
const blockSet = toSet(block);
const counters = { queries: 0, blocked: 0, byList: Object.create(null), recent: [] };
const decide = (name) => {
counters.queries += 1;
if (!enabled) return null;
const clean = normaliseName(name);
if (!clean) return null;
// Allow first, and unconditionally. This is the escape hatch for a list
// that took down something real, so nothing below may override it.
if (matchSuffix(clean, allowSet)) return null;
let hit = null;
const custom = matchSuffix(clean, blockSet);
if (custom) hit = { list: "custom", rule: custom };
else {
for (const [id, set] of lists) {
const rule = matchSuffix(clean, set);
if (rule) { hit = { list: id, rule }; break; }
}
}
if (!hit) return null;
counters.blocked += 1;
counters.byList[hit.list] = (counters.byList[hit.list] || 0) + 1;
// A short tail, not a log. Enough to answer "what did it just block" in
// `filter status`; not enough to become a record of everything a person
// looked up, which is not a thing a resolver should keep by default.
counters.recent.unshift({ name: clean, ...hit });
if (counters.recent.length > 20) counters.recent.pop();
return { ...hit, mode };
};
return {
enabled,
mode,
decide,
counters,
stats: () => ({
queries: counters.queries,
blocked: counters.blocked,
byList: { ...counters.byList },
recent: counters.recent.slice(),
}),
sizes: () => {
const out = {};
for (const [id, set] of lists) out[id] = set.size;
if (blockSet.size) out.custom = blockSet.size;
return out;
},
};
}
/** The shape written to `filter.json`, with every field defaulted. */
export function normaliseConfig(raw = {}) {
const categories = Array.isArray(raw.categories)
? raw.categories.filter((id) => CATALOG_BY_ID.has(id))
: DEFAULT_CATEGORIES.slice();
return {
version: FILTER_VERSION,
enabled: Boolean(raw.enabled),
mode: BLOCK_MODES.includes(raw.mode) ? raw.mode : DEFAULT_MODE,
categories,
block: Array.from(toSet(raw.block)),
allow: Array.from(toSet(raw.allow)),
updatedAt: raw.updatedAt || null,
};
}
export async function readConfig(dir) {
try {
return normaliseConfig(JSON.parse(await fs.readFile(configPath(dir), "utf8")));
} catch (err) {
// A missing file is the default answer β€” filtering off β€” not an error. A
// corrupt one is reported, because silently reverting to "off" is how a
// machine ends up unfiltered while its owner believes otherwise.
if (err?.code === "ENOENT") return normaliseConfig({ enabled: false });
throw new Error(`${configPath(dir)} is not readable as JSON β€” ${err?.message || err}`);
}
}
export async function writeConfig(dir, config) {
const next = normaliseConfig(config);
await fs.mkdir(dir, { recursive: true });
await fs.writeFile(configPath(dir), `${JSON.stringify(next, null, 2)}\n`);
return next;
}
/** What is cached for a category, without loading the whole thing. */
export async function listStatus(dir, id) {
try {
const stat = await fs.stat(listPath(dir, id));
return { id, cached: true, bytes: stat.size, at: stat.mtime.toISOString() };
} catch {
return { id, cached: false, bytes: 0, at: null };
}
}
export async function readCachedList(dir, id) {
try {
const text = await fs.readFile(listPath(dir, id), "utf8");
return toSet(text.split("\n"));
} catch (err) {
if (err?.code === "ENOENT") return null;
throw err;
}
}
/**
* Fetch one category and write it to the cache.
*
* The write goes to a temporary file and is renamed into place, so a bridge
* reloading in the middle of an update reads either the old list or the new one
* and never half of either.
*/
export async function updateList(dir, id, { fetchImpl = fetch, timeoutMs = 60000 } = {}) {
const source = CATALOG_BY_ID.get(id);
if (!source) throw new Error(`no such list: ${id}`);
const response = await fetchImpl(source.url, { signal: AbortSignal.timeout(timeoutMs), redirect: "follow" });
if (!response.ok) throw new Error(`${source.url} answered ${response.status}`);
const names = parseList(await response.text());
// A source that parses to nothing is a source that changed shape, moved, or
// answered with an error page carrying a 200. Overwriting a good cache with
// that would quietly unfilter the machine.
if (!names.length) throw new Error(`${source.url} parsed to nothing β€” leaving the cached copy alone`);
await fs.mkdir(path.dirname(listPath(dir, id)), { recursive: true });
const temp = `${listPath(dir, id)}.tmp`;
await fs.writeFile(temp, `${names.join("\n")}\n`);
await fs.rename(temp, listPath(dir, id));
return { id, count: names.length, url: source.url };
}
/**
* Load config and cached lists into a live filter, and keep it current.
*
* The bridge is a long-lived process and the config is edited by a separate
* command, so a handle re-reads when the config file's mtime moves. It checks at
* most every `reloadMs`, off the back of a query rather than on a timer: a timer
* in a resolver is a thing that keeps a process alive after its socket has
* closed, and this way an idle bridge does no work at all.
*/
export async function openFilter({ dir = filterDir(), reloadMs = 5000, now = () => Date.now() } = {}) {
let filter = createFilter({ enabled: false });
let config = normaliseConfig({ enabled: false });
let stamp = null;
let checkedAt = now();
let loading = null;
// Both clocks start now rather than at zero, so opening a handle does not
// write a stats file and re-read a config on its very first query. A process
// that asks one question and exits should leave nothing behind.
let flushedAt = now();
let flushing = false;
const configStamp = async () => {
try {
return (await fs.stat(configPath(dir))).mtimeMs;
} catch {
return null;
}
};
const load = async () => {
config = await readConfig(dir);
const lists = new Map();
for (const id of config.categories) {
const set = await readCachedList(dir, id);
if (set) lists.set(id, set);
}
const carried = filter.counters;
filter = createFilter({
enabled: config.enabled,
mode: config.mode,
lists,
allow: config.allow,
block: config.block,
});
// Counters survive a reload. They describe what this bridge has done since
// it started, and losing them every time a name is allowlisted would make
// the numbers meaningless exactly when someone is watching them.
Object.assign(filter.counters, carried);
stamp = await configStamp();
return filter;
};
const refresh = () => {
if (loading) return loading;
loading = (async () => {
try {
if (await configStamp() !== stamp) await load();
} catch {
// Keep serving with what is already loaded. A resolver that stops
// answering because a config file went strange is worse than one
// running a slightly stale policy.
} finally {
loading = null;
}
})();
return loading;
};
const flush = () => {
if (flushing) return;
flushing = true;
const payload = { at: new Date(now()).toISOString(), ...filter.stats(), lists: filter.sizes() };
fs.mkdir(dir, { recursive: true })
.then(() => fs.writeFile(statsPath(dir), `${JSON.stringify(payload, null, 2)}\n`))
.catch(() => {})
.finally(() => { flushing = false; });
};
await load();
return {
get config() { return config; },
get mode() { return filter.mode; },
get enabled() { return filter.enabled; },
reload: load,
stats: () => filter.stats(),
sizes: () => filter.sizes(),
decide(name) {
const at = now();
if (at - checkedAt >= reloadMs) {
checkedAt = at;
refresh(); // deliberately not awaited β€” this query uses the loaded policy
}
const verdict = filter.decide(name);
if (at - flushedAt >= 10000) {
flushedAt = at;
flush();
}
return verdict;
},
};
}
export async function readStats(dir) {
try {
return JSON.parse(await fs.readFile(statsPath(dir), "utf8"));
} catch {
return null;
}
}
// `/shorten <url>` β€” mint a short link on the pit, and get `/f/<code>` back.
//
// The pit hands out long URLs constantly: a session mirror, an approval, a
// name's site, a release asset. The place they get pasted is a terminal, a chat
// line, a slide or a QR code, where a 140-character URL wraps and breaks in
// half. So this asks the registry for a short one and prints it.
//
// Everything here is one HTTP call to pit.moshcode.sh β€” the registry owns the
// codes, because a short link that only worked from the laptop that minted it
// would not be a link at all. The command is thin on purpose: parse, call,
// print, and be honest about what came back.
//
// Authenticated, always. An anonymous shortener is an open redirector with a
// database, which is the thing phishing kits are built out of; the account is
// what makes a link revocable and its owner findable.
import { loadCreds } from "./auth.mjs";
import { acid, ash, bone, err, info, ok } from "./ui.mjs";
/** Where the codes live. The registry, not the app β€” see the note above. */
export const DEFAULT_REGISTRY_BASE = "https://pit.moshcode.sh";
function registryBase(env = process.env) {
return String(env.MOSHPIT_REGISTRY || env.MOSHCODE_PIT || DEFAULT_REGISTRY_BASE).replace(/\/+$/, "");
}
/** The token `moshcode login` wrote, or one set in the environment. */
export function apiToken(env = process.env, creds = loadCreds) {
return env.MOSHCODE_API_KEY || creds()?.token || "";
}
/**
* Split `/shorten` into what it was asked to do.
*
* A bare URL is the whole point of the command, so it needs no verb: `/shorten
* https://…` shortens, and only `list` and `rm` are spelled out. Flags are
* pulled out first so `--name` can sit anywhere, which is where people put it.
*
* @param {string[]} argv
*/
export function parseArgs(argv = []) {
const args = (Array.isArray(argv) ? argv : []).map(String);
const json = args.includes("--json");
let name = null;
const positional = [];
for (let i = 0; i < args.length; i += 1) {
const arg = args[i];
if (arg === "--json") continue;
if (arg === "--name" || arg === "-n") { name = args[i + 1] ?? null; i += 1; continue; }
if (arg.startsWith("--name=")) { name = arg.slice("--name=".length); continue; }
positional.push(arg);
}
const first = (positional[0] || "").toLowerCase();
if (!positional.length) return { verb: "help", json, name };
if (first === "list" || first === "ls") return { verb: "list", json, name };
if (first === "rm" || first === "delete" || first === "del") {
return { verb: "rm", code: positional[1] || "", json, name };
}
// Anything else is the URL. Deliberately not validated here: the registry has
// the one implementation of what may be shortened (lib/moshpit-links.mjs),
// and a second, looser copy in the client is how the two drift apart.
return { verb: "shorten", url: positional[0], json, name };
}
/**
* One authenticated call to the registry, with the failures a person can act on.
*
* Every non-2xx is turned into `{ ok: false, error }` rather than thrown: this
* runs at a prompt someone is sitting in front of, and a stack trace over a
* 401 tells them nothing about the `moshcode login` that fixes it.
*/
async function call(path, { method = "GET", body = null, token, base, fetchImpl = fetch } = {}) {
if (!token) {
return { ok: false, needsAuth: true, error: "not logged in β€” run `/login` first" };
}
let response;
try {
response = await fetchImpl(`${base}${path}`, {
method,
headers: {
authorization: `Bearer ${token}`,
...(body ? { "content-type": "application/json" } : {}),
},
...(body ? { body: JSON.stringify(body) } : {}),
});
} catch (error) {
return { ok: false, error: `${base} unreachable: ${error.message}` };
}
let payload = null;
try { payload = await response.json(); } catch { payload = null; }
if (response.status === 401) {
return { ok: false, needsAuth: true, error: "the registry rejected the credentials β€” run `/login`" };
}
if (!response.ok) {
return { ok: false, error: payload?.error || `the registry said ${response.status}` };
}
return { ok: true, status: response.status, body: payload ?? {} };
}
/** Mint one. Returns the link the registry stored, existing or new. */
export async function shorten(url, {
name = null, env = process.env, token = apiToken(env), fetchImpl = fetch,
} = {}) {
return call("/api/moshpit/links", {
method: "POST",
body: { url, ...(name ? { name } : {}) },
token,
base: registryBase(env),
fetchImpl,
});
}
/** What this account has minted. */
export async function listLinks({ env = process.env, token = apiToken(env), fetchImpl = fetch } = {}) {
return call("/api/moshpit/links", { token, base: registryBase(env), fetchImpl });
}
/** Take one down. */
export async function removeLink(code, { env = process.env, token = apiToken(env), fetchImpl = fetch } = {}) {
return call(`/api/moshpit/links/${encodeURIComponent(code)}`, {
method: "DELETE", token, base: registryBase(env), fetchImpl,
});
}
/**
* How to run this, spelled the way the caller reached it.
*
* The pit writes its verbs with a slash and the CLI does not, and printing the
* wrong one is a usage line that does not work when pasted back β€” `/games` does
* the same thing for the same reason.
*/
function usage(out, prefix) {
const lines = [
["<url>", "mint a short link β€” /f/<code> on the pit"],
["<url> --name <name>", "file it under a moshpit name you hold"],
["list", "every link you have minted, newest first"],
["rm <code>", "take one down"],
].map(([args, text]) => [`${prefix} ${args}`, text]);
// The column is measured rather than fixed: `moshcode shorten` is twice as
// wide as `/shorten`, and a hardcoded one leaves the longest line unaligned
// in whichever spelling was not the one it was chosen for.
const width = Math.max(...lines.map(([invocation]) => invocation.length)) + 2;
out(info("usage:"));
for (const [invocation, text] of lines) {
out(` ${acid(invocation)}${ash(" ".repeat(width - invocation.length) + text)}`);
}
}
/**
* `/shorten` in the pit, and `moshcode shorten` on the command line.
*
* @param {string[]} argv
* @param {{out?: (s: string) => void, err?: (s: string) => void, env?: object,
* token?: string, prefix?: string, fetchImpl?: typeof fetch}} [io]
* @returns {Promise<number>} exit code
*/
export async function shortenCommand(argv = [], io = {}) {
const out = io.out || ((s) => console.log(s));
const say = io.err || ((s) => console.error(s));
const env = io.env || process.env;
const token = io.token ?? apiToken(env);
const prefix = io.prefix || "/shorten";
const fetchImpl = io.fetchImpl || fetch;
const parsed = parseArgs(argv);
if (parsed.verb === "help") {
usage(out, prefix);
return 1;
}
if (parsed.verb === "list") {
const result = await listLinks({ env, token, fetchImpl });
if (!result.ok) { say(err(result.error)); return 1; }
const links = result.body.links || [];
if (parsed.json) { out(JSON.stringify(links, null, 2)); return 0; }
if (!links.length) {
out(info(`no short links yet β€” ${prefix} <url> mints one`));
return 0;
}
for (const link of links) {
const hits = `${link.hits} hit${link.hits === 1 ? "" : "s"}`;
out(` ${acid(link.short)} ${ash("β†’")} ${bone(link.url)}`);
out(` ${ash(`${hits}${link.name ? ` Β· ${link.name}` : ""}`)}`);
}
return 0;
}
if (parsed.verb === "rm") {
if (!parsed.code) { say(err(`usage: ${prefix} rm <code>`)); return 1; }
const result = await removeLink(parsed.code, { env, token, fetchImpl });
if (!result.ok) { say(err(result.error)); return 1; }
if (parsed.json) { out(JSON.stringify(result.body, null, 2)); return 0; }
out(ok(`took down /f/${result.body.code ?? parsed.code}`));
return 0;
}
const result = await shorten(parsed.url, { name: parsed.name, env, token, fetchImpl });
if (!result.ok) { say(err(result.error)); return 1; }
if (parsed.json) { out(JSON.stringify(result.body, null, 2)); return 0; }
// Say when a code came back rather than being made. Shortening is idempotent
// per account, and someone who ran it twice should see why the code is the
// one they already have instead of wondering whether the second call worked.
out(ok(`${acid(result.body.short)} ${ash("β†’")} ${bone(result.body.url)}`));
if (result.body.created === false) out(info("already shortened β€” same code as last time"));
return 0;
}
+5
-0

@@ -39,2 +39,3 @@ #!/usr/bin/env node

import { serveCommand } from "../src/serve.mjs";
import { shortenCommand } from "../src/shorten.mjs";
import { createDohServer, nginxDohSite, parseDohPort, parseGuardArgs, DEFAULT_DOH_PORT, DOH_PATH } from "../src/doh-server.mjs";

@@ -591,2 +592,6 @@ import { completionScript } from "../src/completion.mjs";

}
if (cmd === "shorten" || cmd === "short" || cmd === "link") {
process.exitCode = (await shortenCommand(rest, { prefix: `moshcode ${cmd}` })) || 0;
return;
}

@@ -593,0 +598,0 @@ if (cmd === "pwd" || cmd === "where") {

+1
-1
{
"name": "moshcode",
"version": "0.68.0",
"version": "0.69.0",
"type": "module",

@@ -5,0 +5,0 @@ "description": "moshcode β€” a metal wrapper for coding engines and native UGig/CoinPay workflow CLIs, with OpenPRD and moshscript",

@@ -459,2 +459,26 @@ // The command table, and everything help needs to describe it.

{
name: "shorten",
group: "hosting",
description: "mint a short link on the pit β€” /f/<code> follows to your url",
synopsis: [
["moshcode shorten <url>", "mint one and print it"],
["moshcode shorten <url> --name <name>", "file it under a Moshpit name you hold"],
["moshcode shorten list", "every link you have minted, newest first"],
["moshcode shorten rm <code>", "take one down"],
],
flags: [
["--name <name>", "file the link under a Moshpit name you hold", "none"],
["--json", "print the link (or the list) as machine-readable JSON", ""],
],
examples: [
["moshcode shorten https://pit.moshcode.sh/n/blue.eggs", "β†’ pit.moshcode.sh/f/k7mq2xd"],
["moshcode shorten list --json", "pipe your links into a script"],
["moshcode shorten rm k7mq2xd", "the code stops resolving"],
],
seeAlso: ["login", "name", "site"],
note: "needs an account β€” run `moshcode login` first. Shortening the same url twice returns the same code.",
},
{ name: "short", aliasOf: "shorten", description: "alias for shorten" },
{ name: "link", aliasOf: "shorten", description: "alias for shorten" },
{
name: "games",

@@ -819,2 +843,13 @@ group: "arcade",

{ name: "trust", description: "trust one name's certificate, after checking it against the registry pin" },
{
name: "filter",
description: "block ads, trackers, malware and phishing at the resolver",
synopsis: [
["moshcode dns filter", "what is on, and what it has blocked"],
["moshcode dns filter on [--mode nxdomain|zero|refuse] [--lists a,b]", ""],
["moshcode dns filter update", "fetch the lists β€” nothing downloads on its own"],
["moshcode dns filter allow <name>", "never block it, whatever any list says"],
["moshcode dns filter test <name>", "would this be blocked, and by which rule"],
],
},
];

@@ -1171,2 +1206,4 @@

description: "the arcade β€” tetris, invaders, pac-man, frogger, kong, outrun, chess and more" },
{ name: "shorten", aliases: ["short", "link"], args: "<url> | list | rm <code>", cli: "shorten",
description: "mint a short link on the pit β€” /f/<code> follows to your url" },
{ name: "socials", aliases: ["social"], pitOnly: true,

@@ -1173,0 +1210,0 @@ description: "list social networks available for posting" },

@@ -339,3 +339,3 @@ import {

if (( COMP_CWORD == 2 )); then
choices="enable disable status tlds resolve start install trust"
choices="enable disable status tlds resolve start install trust filter"
elif [[ "$nested" == "resolve" && "$cur" == -* ]]; then

@@ -489,3 +489,3 @@ choices="--json --open --registry"

if (( CURRENT == 3 )); then
_values "dns command" enable disable status tlds resolve start install trust
_values "dns command" enable disable status tlds resolve start install trust filter
elif [[ "\${words[3]}" == "resolve" && "$PREFIX" == -* ]]; then

@@ -571,3 +571,3 @@ _values "dns resolve option" --json --open --registry

complete -c moshcode -n '__moshcode_nested_is console serve' -l bind -r -d 'bind address'
complete -c moshcode -n '${atSecondToken("dns")}' -a 'enable disable status tlds resolve start install trust' -d 'dns command'
complete -c moshcode -n '${atSecondToken("dns")}' -a 'enable disable status tlds resolve start install trust filter' -d 'dns command'
complete -c moshcode -n '__moshcode_nested_is dns resolve' -l json -d 'print JSON'

@@ -574,0 +574,0 @@ complete -c moshcode -n '__moshcode_nested_is dns resolve' -l open -d 'open a parked name in the Pit'

@@ -14,2 +14,3 @@ // The moshcode shell β€” run `moshcode` with no args. A metal prompt that opens

import { postSocial, socialRoster } from "./socials.mjs";
import { shortenCommand } from "./shorten.mjs";
import { runUpgrade } from "./upgrade.mjs";

@@ -1114,2 +1115,8 @@ import { locate, tilde } from "./pwd.mjs";

}
// `/shorten` renders in the pit rather than handing the terminal over: it
// is one call to the registry and one line back, the same as `/stocks`.
if (cmd === "shorten" || cmd === "short" || cmd === "link") {
await shortenCommand(rest, { prefix: `/${cmd}` });
continue;
}
if (cmd === "socials" || cmd === "social") {

@@ -1116,0 +1123,0 @@ printSocials();

Sorry, the diff of this file is too big to display

Sorry, the diff of this file is too big to display