| /** | ||
| * `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; | ||
| } | ||
| } |
+212
| // `/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", |
+37
-0
@@ -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' |
+7
-0
@@ -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
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 3 instances
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
AI-detected potential code anomaly
Supply chain riskAI has identified unusual behaviors that may pose a security risk.
Found 2 instances
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1858855
2.9%160
1.91%33404
3.6%1682
2.19%153
6.25%75
10.29%