@profullstack/fleet 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Profullstack, Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,67 @@
1
- # Temporary Holding Version
1
+ # @profullstack/fleet
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Read an [OpenDevice](https://logicsrc.com/docs/opendevice) inventory, the one file that lists every machine you run (servers, desktops, laptops, phones, tablets). Then:
4
+
5
+ - **cost**: add up what the fleet costs a month, per currency, by provider, profile and scope
6
+ - **observe**: collect each box's public metadata (DNS, reverse DNS, ASN, TLS certificate, SSH banner, HTTP server, open well-known ports, well-known files)
7
+ - **export**: turn the costs into CoinPay finances recurring charges, with stable keys and payees for matching against bank feeds
8
+ - **join**: match the inventory against agent-reported machines (ThreatCrush servers, W3BS fleet machines), so the boxes without an agent still show up
9
+
10
+ ThreatCrush's dashboard and w3bs.org/fleet read inventories through this package.
11
+
12
+ ```sh
13
+ npm i @profullstack/fleet
14
+ npx fleet list opendevice.json
15
+ ```
16
+
17
+ ## CLI
18
+
19
+ ```
20
+ fleet list [inventory] Every device, its profile, provider and monthly cost
21
+ fleet cost [inventory] Monthly run rate: total, by provider, profile, scope
22
+ fleet validate [inventory] Errors and warnings against the spec
23
+ fleet observe [inventory] Collect public metadata; --write saves it into the file
24
+ fleet coinpay [inventory] Costs as CoinPay recurring charges (JSON)
25
+ ```
26
+
27
+ `inventory` is a path or an https URL (`--header "Authorization: Bearer ..."` for a private one). It defaults to `$FLEET_INVENTORY`, then `./opendevice.json`. Add `--json` for machine output.
28
+
29
+ ```
30
+ $ fleet list
31
+ ID PROFILE STATUS PROVIDER $/MO
32
+ app1 server active Example Racks $149.00
33
+ eu1 server active Euro VPS €10.00
34
+ ops-phone phone active - $25.00
35
+ design-laptop desktop active - $40.00
36
+
37
+ Monthly: €10.00 + $214.00 (4 counted)
38
+ ```
39
+
40
+ ## API
41
+
42
+ ```js
43
+ import { parseInventory, validateInventory, fleetCost, toCoinPayCosts, matchAgents, loadInventory, formatMonthly } from '@profullstack/fleet';
44
+ import { observe, observeInventory } from '@profullstack/fleet/observe'; // Node only
45
+
46
+ const inv = await loadInventory('https://ops.example/opendevice.json', { headers: { authorization: `Bearer ${token}` } });
47
+ const cost = fleetCost(inv); // { monthly: { USD: 590 }, by_provider, by_profile, by_scope, devices, unstated, counted }
48
+ formatMonthly(cost.monthly); // "$590.00"
49
+ toCoinPayCosts(inv); // [{ key: 'device:dev2', payee: 'DEDIROCK', monthlyEquivalent: 149, cadence: 'monthly', ... }]
50
+ matchAgents(inv, threatcrushServers); // { devices: [{ device, agents: [...] }], unmatched: [...] }
51
+ ```
52
+
53
+ ### Cost rules (from the spec)
54
+
55
+ - Monthly run rate: `month` as is, `year / 12`, `hour * 730`. `once` counts only with `amortize_months` and `since`.
56
+ - Totals stay per currency. Nothing is converted.
57
+ - A `group` shared by several devices on one bill counts once, from the device that states `amount`.
58
+ - A device with no `amount` is **unstated** and listed as such, never guessed.
59
+ - `retired` devices and costs past `until` count zero.
60
+
61
+ ### Observation rules
62
+
63
+ Only what anyone could see without the device's credentials, and only for devices you run. Private addresses (RFC 1918, CGNAT, link-local, ULA) are never probed. Client profiles (desktop, phone, tablet) are not network-probed.
64
+
65
+ ## License
66
+
67
+ MIT, Profullstack, Inc.
package/bin/fleet.js ADDED
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ // fleet: read an OpenDevice inventory, show what the fleet costs a month, observe
3
+ // public metadata, and export costs for CoinPay.
4
+ import { writeFile } from 'node:fs/promises';
5
+ import { fleetCost, formatMonthly, loadInventory, toCoinPayCosts, validateInventory } from '../src/index.js';
6
+ import { observeInventory } from '../src/observe.js';
7
+
8
+ const HELP = `fleet - OpenDevice inventories (https://logicsrc.com/docs/opendevice)
9
+
10
+ Usage:
11
+ fleet list [inventory] Every device, its profile, provider and monthly cost
12
+ fleet cost [inventory] Monthly run rate: total, by provider, profile, scope
13
+ fleet validate [inventory] Errors and warnings against the spec
14
+ fleet observe [inventory] Collect public metadata (DNS, PTR, ASN, TLS, SSH, ports)
15
+ --write saves it back into the file
16
+ fleet coinpay [inventory] Costs as CoinPay recurring charges (JSON)
17
+
18
+ inventory is a path or an https URL; default $FLEET_INVENTORY or ./opendevice.json
19
+ --json machine output --header "K: V" send a header with a URL
20
+ `;
21
+
22
+ const args = process.argv.slice(2);
23
+ const flag = (f) => args.includes(f);
24
+ const opt = (f) => {
25
+ const i = args.indexOf(f);
26
+ return i >= 0 ? args[i + 1] : undefined;
27
+ };
28
+ const positional = args.filter((a, i) => !a.startsWith('--') && !(args[i - 1] === '--header'));
29
+ const [cmd, where = process.env.FLEET_INVENTORY || './opendevice.json'] = positional;
30
+ const json = flag('--json');
31
+ const out = (v) => process.stdout.write(`${typeof v === 'string' ? v : JSON.stringify(v, null, 2)}\n`);
32
+
33
+ function headers() {
34
+ const h = opt('--header');
35
+ if (!h) return undefined;
36
+ const i = h.indexOf(':');
37
+ return { [h.slice(0, i).trim()]: h.slice(i + 1).trim() };
38
+ }
39
+
40
+ function pad(rows) {
41
+ const w = rows[0].map((_, c) => Math.max(...rows.map((r) => String(r[c]).length)));
42
+ return rows.map((r) => r.map((v, c) => String(v).padEnd(w[c])).join(' ').trimEnd()).join('\n');
43
+ }
44
+
45
+ async function main() {
46
+ if (!cmd || cmd === 'help' || flag('--help') || flag('-h')) return out(HELP);
47
+ const inv = await loadInventory(where, { headers: headers() });
48
+ if (cmd === 'validate') {
49
+ const r = validateInventory(inv);
50
+ if (json) return out(r);
51
+ for (const e of r.errors) out(`error: ${e}`);
52
+ for (const w of r.warnings) out(`warn: ${w}`);
53
+ out(r.valid ? `ok: ${inv.devices.length} devices` : 'invalid');
54
+ process.exitCode = r.valid ? 0 : 1;
55
+ return;
56
+ }
57
+ if (cmd === 'list' || cmd === 'cost') {
58
+ const c = fleetCost(inv);
59
+ if (json) return out(cmd === 'list' ? c.devices : c);
60
+ if (cmd === 'list') {
61
+ const rows = [['ID', 'PROFILE', 'STATUS', 'PROVIDER', '$/MO']];
62
+ for (const d of c.devices) {
63
+ const m = d.monthly;
64
+ rows.push([d.id, d.profile, d.status, d.provider ?? '-', m.state === 'stated' ? formatMonthly({ [m.currency]: m.amount }) : m.state]);
65
+ }
66
+ out(pad(rows));
67
+ }
68
+ out(`\nMonthly: ${formatMonthly(c.monthly)} (${c.counted} counted${c.unstated.length ? `, unstated: ${c.unstated.join(', ')}` : ''})`);
69
+ if (cmd === 'cost') {
70
+ for (const [label, m] of [['provider', c.by_provider], ['profile', c.by_profile], ['scope', c.by_scope]]) {
71
+ out(`\nBy ${label}:`);
72
+ for (const [k, v] of Object.entries(m)) {
73
+ const cur = k.split(' ').pop();
74
+ out(` ${k.slice(0, -cur.length - 1).padEnd(20)} ${formatMonthly({ [cur]: v })}`);
75
+ }
76
+ }
77
+ }
78
+ return;
79
+ }
80
+ if (cmd === 'coinpay') return out(toCoinPayCosts(inv));
81
+ if (cmd === 'observe') {
82
+ const next = await observeInventory(inv);
83
+ if (flag('--write')) {
84
+ if (/^https?:/.test(where)) throw new Error('--write needs a local file');
85
+ await writeFile(where, `${JSON.stringify({ ...next, updated: new Date().toISOString() }, null, 2)}\n`);
86
+ return out(`observed ${next.devices.filter((d) => d.observed).length} devices -> ${where}`);
87
+ }
88
+ return out(next.devices.map((d) => ({ id: d.id, observed: d.observed ?? null })));
89
+ }
90
+ out(HELP);
91
+ process.exitCode = 2;
92
+ }
93
+
94
+ main().catch((err) => {
95
+ process.stderr.write(`fleet: ${err.message}\n`);
96
+ process.exitCode = 1;
97
+ });
package/example.json ADDED
@@ -0,0 +1,23 @@
1
+ {
2
+ "operator": { "name": "Northwind Labs", "web": "https://northwind.example" },
3
+ "updated": "2026-10-28T06:00:00Z",
4
+ "currency": "USD",
5
+ "devices": [
6
+ {
7
+ "id": "app1", "name": "app1", "profile": "server", "role": "shared app box",
8
+ "hostnames": ["app1.northwind.example"], "addresses": { "ipv4": ["203.0.113.10"] },
9
+ "provider": { "name": "Example Racks", "offer": { "descriptor": "https://racks.example/.well-known/openserver.json", "id": "dedi-epyc-16" } },
10
+ "cost": { "amount": 149, "interval": "month", "payee": "RACKS EXAMPLE", "paid_with": "paypal", "since": "2026-09-17" }
11
+ },
12
+ {
13
+ "id": "eu1", "name": "eu1", "hostnames": ["eu1.northwind.example"],
14
+ "provider": { "name": "Euro VPS" },
15
+ "cost": { "amount": 120, "currency": "EUR", "interval": "year" }
16
+ },
17
+ { "id": "ops-phone", "name": "On-call phone", "profile": "phone", "vendor": "Google", "model": "Pixel 9",
18
+ "os": { "name": "Android", "version": "17", "patch": "2026-10-05" },
19
+ "cost": { "amount": 25, "interval": "month", "payee": "CARRIER EXAMPLE" } },
20
+ { "id": "design-laptop", "name": "Design laptop", "profile": "desktop", "form": "laptop",
21
+ "cost": { "amount": 1440, "interval": "once", "amortize_months": 36, "since": "2026-01-10" } }
22
+ ]
23
+ }
package/index.d.ts ADDED
@@ -0,0 +1,97 @@
1
+ export const SPEC: string;
2
+ export type Profile = 'server' | 'desktop' | 'phone' | 'tablet' | 'other';
3
+ export type Status = 'active' | 'standby' | 'retired' | 'unknown';
4
+ export type Interval = 'hour' | 'month' | 'year' | 'once';
5
+ export const PROFILES: Profile[];
6
+ export const STATUSES: Status[];
7
+ export const INTERVALS: Interval[];
8
+
9
+ export interface Cost {
10
+ amount?: number | null;
11
+ currency?: string;
12
+ interval?: Interval;
13
+ payee?: string;
14
+ paid_with?: string;
15
+ scope?: 'business' | 'personal';
16
+ category?: string;
17
+ next_due?: string;
18
+ since?: string;
19
+ until?: string;
20
+ amortize_months?: number;
21
+ group?: string;
22
+ note?: string;
23
+ [key: string]: unknown;
24
+ }
25
+
26
+ export interface Device {
27
+ id: string;
28
+ name: string;
29
+ profile: Profile;
30
+ status: Status;
31
+ role?: string;
32
+ hostnames: string[];
33
+ addresses: { ipv4: string[]; ipv6: string[]; private?: string[] };
34
+ provider?: { name?: string; web?: string; offer?: { descriptor?: string; id?: string }; account?: string };
35
+ location?: { country?: string; region?: string };
36
+ hardware?: Record<string, unknown>;
37
+ os?: { name?: string; version?: string; patch?: string };
38
+ cost?: Cost;
39
+ agents?: Array<{ name: string; version?: string; id?: string }>;
40
+ tags?: string[];
41
+ observed?: Record<string, unknown> & { at?: string };
42
+ vendor?: string;
43
+ model?: string;
44
+ form?: string;
45
+ [key: string]: unknown;
46
+ }
47
+
48
+ export interface Inventory {
49
+ operator?: { name?: string; web?: string; profile?: string };
50
+ updated?: string;
51
+ currency?: string;
52
+ devices: Device[];
53
+ [key: string]: unknown;
54
+ }
55
+
56
+ export type CostState = 'stated' | 'unstated' | 'grouped' | 'ended' | 'once' | 'retired';
57
+ export interface Monthly { amount: number | null; currency: string | null; state: CostState }
58
+
59
+ export interface FleetCost {
60
+ /** Per currency; nothing is converted. */
61
+ monthly: Record<string, number>;
62
+ /** Keys are "<name> <CUR>". */
63
+ by_provider: Record<string, number>;
64
+ by_profile: Record<string, number>;
65
+ by_scope: Record<string, number>;
66
+ devices: Array<{ id: string; name: string; profile: Profile; status: Status; provider: string | null; monthly: Monthly }>;
67
+ unstated: string[];
68
+ counted: number;
69
+ }
70
+
71
+ export interface CoinPayCost {
72
+ key: string;
73
+ payee: string;
74
+ amount: number;
75
+ currency: string;
76
+ cadence: 'monthly' | 'yearly';
77
+ monthlyEquivalent: number;
78
+ scope: string;
79
+ category: string;
80
+ paidWith: string | null;
81
+ nextExpected: string | null;
82
+ device: { id: string; name: string; profile: Profile; provider: string | null };
83
+ source: string;
84
+ }
85
+
86
+ export function parseInventory(input: string | object): Inventory;
87
+ export function validateInventory(input: string | object): { valid: boolean; errors: string[]; warnings: string[] };
88
+ export function monthlyCost(device: Device, opts?: { now?: Date }): Monthly;
89
+ export function fleetCost(input: string | object, opts?: { now?: Date }): FleetCost;
90
+ export function toCoinPayCosts(input: string | object, opts?: { now?: Date; source?: string }): CoinPayCost[];
91
+ export function matchAgents<A>(
92
+ input: string | object,
93
+ agents: A[],
94
+ opts?: { hostname?: (a: A) => string | null | undefined; name?: (a: A) => string | null | undefined; ip?: (a: A) => string | null | undefined },
95
+ ): { devices: Array<{ device: Device; agents: A[] }>; unmatched: A[] };
96
+ export function loadInventory(where: string, opts?: { headers?: Record<string, string> }): Promise<Inventory>;
97
+ export function formatMonthly(monthly: Record<string, number> | undefined): string;
package/observe.d.ts ADDED
@@ -0,0 +1,5 @@
1
+ import type { Device, Inventory } from './index.js';
2
+ export const PORTS: number[];
3
+ export const WELLKNOWN: string[];
4
+ export function observe(device: Device, opts?: { timeoutMs?: number; ports?: number[]; now?: () => Date }): Promise<Record<string, unknown> & { at: string }>;
5
+ export function observeInventory(inv: Inventory, opts?: { concurrency?: number; timeoutMs?: number; ports?: number[] }): Promise<Inventory>;
package/package.json CHANGED
@@ -1,6 +1,24 @@
1
1
  {
2
2
  "name": "@profullstack/fleet",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "description": "Read an OpenDevice inventory (servers, desktops, phones, tablets), add up what the fleet costs a month, observe each box's public metadata, and export the costs for CoinPay. Used by ThreatCrush and w3bs.org/fleet.",
6
+ "keywords": ["fleet", "opendevice", "openserver", "inventory", "hosting", "cost", "coinpay", "threatcrush", "w3bs"],
7
+ "repository": { "type": "git", "url": "git+https://github.com/profullstack/fleet.git" },
8
+ "homepage": "https://logicsrc.com/opendevice",
9
+ "bugs": { "url": "https://github.com/profullstack/fleet/issues" },
10
+ "license": "MIT",
11
+ "author": "Profullstack, Inc.",
12
+ "exports": {
13
+ ".": { "types": "./index.d.ts", "import": "./src/index.js" },
14
+ "./observe": { "types": "./observe.d.ts", "import": "./src/observe.js" },
15
+ "./package.json": "./package.json"
16
+ },
17
+ "bin": { "fleet": "bin/fleet.js" },
18
+ "files": ["src", "bin", "index.d.ts", "observe.d.ts", "example.json", "README.md", "LICENSE"],
19
+ "sideEffects": false,
20
+ "engines": { "node": ">=20" },
21
+ "scripts": {
22
+ "test": "node --test"
23
+ }
24
+ }
package/src/index.js ADDED
@@ -0,0 +1,268 @@
1
+ // @profullstack/fleet: read an OpenDevice inventory (logicsrc.com/docs/opendevice),
2
+ // add up what the fleet costs a month, and hand those costs to the tools that need
3
+ // them (CoinPay finances, the ThreatCrush dashboard, w3bs.org/fleet).
4
+ //
5
+ // Pure functions only; nothing here touches the network except loadInventory.
6
+ // Observation of public metadata lives in ./observe.js and is Node-only.
7
+
8
+ export const SPEC = 'https://logicsrc.com/docs/opendevice';
9
+ export const PROFILES = ['server', 'desktop', 'phone', 'tablet', 'other'];
10
+ export const STATUSES = ['active', 'standby', 'retired', 'unknown'];
11
+ export const INTERVALS = ['hour', 'month', 'year', 'once'];
12
+ const HOURS_PER_MONTH = 730;
13
+
14
+ const slug = (s) =>
15
+ String(s ?? '')
16
+ .toLowerCase()
17
+ .replace(/[^a-z0-9]+/g, '-')
18
+ .replace(/^-+|-+$/g, '');
19
+
20
+ const list = (v) => (Array.isArray(v) ? v.filter((x) => typeof x === 'string' && x) : []);
21
+
22
+ function defaultProfile(d) {
23
+ if (PROFILES.includes(d.profile)) return d.profile;
24
+ const hasNet = list(d.hostnames).length || list(d.addresses?.ipv4).length || list(d.addresses?.ipv6).length;
25
+ return hasNet ? 'server' : 'other';
26
+ }
27
+
28
+ /**
29
+ * Normalize an inventory: every device gets an id, a profile and a status, and the
30
+ * inventory's default currency is applied to costs that state none. Unknown keys
31
+ * are kept, per the spec.
32
+ */
33
+ export function parseInventory(input) {
34
+ const doc = typeof input === 'string' ? JSON.parse(input) : input;
35
+ if (!doc || typeof doc !== 'object' || !Array.isArray(doc.devices)) {
36
+ throw new TypeError('OpenDevice inventory needs a "devices" array');
37
+ }
38
+ const currency = typeof doc.currency === 'string' ? doc.currency.toUpperCase() : undefined;
39
+ const seen = new Set();
40
+ const devices = doc.devices.map((raw, i) => {
41
+ const d = { ...raw };
42
+ d.name = typeof d.name === 'string' && d.name ? d.name : `device-${i + 1}`;
43
+ let id = typeof d.id === 'string' && d.id ? d.id : slug(d.name) || `device-${i + 1}`;
44
+ while (seen.has(id)) id = `${id}-${i + 1}`;
45
+ seen.add(id);
46
+ d.id = id;
47
+ d.profile = defaultProfile(d);
48
+ d.status = STATUSES.includes(d.status) ? d.status : 'active';
49
+ d.hostnames = list(d.hostnames);
50
+ d.addresses = { ...d.addresses, ipv4: list(d.addresses?.ipv4), ipv6: list(d.addresses?.ipv6) };
51
+ if (d.cost && typeof d.cost === 'object') {
52
+ d.cost = { ...d.cost };
53
+ d.cost.currency = (d.cost.currency || currency || 'USD').toUpperCase();
54
+ if (!d.cost.interval) d.cost.interval = 'month';
55
+ if (!d.cost.category) d.cost.category = d.profile === 'server' || d.profile === 'other' ? 'hosting' : 'devices';
56
+ }
57
+ return d;
58
+ });
59
+ return { ...doc, currency, devices };
60
+ }
61
+
62
+ const SECRET_KEYS = ['password', 'secret', 'token', 'private_key', 'api_key'];
63
+
64
+ function hasKey(value, keys) {
65
+ if (!value || typeof value !== 'object') return null;
66
+ for (const [k, v] of Object.entries(value)) {
67
+ if (keys.includes(k.toLowerCase())) return k;
68
+ const inner = hasKey(v, keys);
69
+ if (inner) return inner;
70
+ }
71
+ return null;
72
+ }
73
+
74
+ /** Problems a reader should show; an inventory with only warnings is still usable. */
75
+ export function validateInventory(input) {
76
+ const errors = [];
77
+ const warnings = [];
78
+ let inv;
79
+ try {
80
+ inv = parseInventory(input);
81
+ } catch (err) {
82
+ return { valid: false, errors: [err.message], warnings };
83
+ }
84
+ const raw = (typeof input === 'string' ? JSON.parse(input) : input).devices;
85
+ const groups = new Map();
86
+ inv.devices.forEach((d, i) => {
87
+ const at = `devices[${i}] (${d.id})`;
88
+ if (!raw[i]?.name) errors.push(`${at}: name is required`);
89
+ if (raw[i]?.profile && !PROFILES.includes(raw[i].profile)) warnings.push(`${at}: unknown profile "${raw[i].profile}"`);
90
+ if (raw[i]?.number || raw[i]?.phone_number) errors.push(`${at}: a phone number is never part of an OpenDevice record`);
91
+ const secret = hasKey(raw[i], SECRET_KEYS);
92
+ if (secret) errors.push(`${at}: credentials ("${secret}") never belong in an inventory`);
93
+ const c = d.cost;
94
+ if (c) {
95
+ if (!INTERVALS.includes(c.interval)) errors.push(`${at}: cost.interval must be one of ${INTERVALS.join(', ')}`);
96
+ if (c.amount != null && !(typeof c.amount === 'number' && Number.isFinite(c.amount) && c.amount >= 0)) {
97
+ errors.push(`${at}: cost.amount must be a non-negative number`);
98
+ }
99
+ if (c.amount == null && !c.group) warnings.push(`${at}: cost has no amount (counted as unstated)`);
100
+ if (c.group) {
101
+ const g = groups.get(c.group) ?? [];
102
+ g.push({ id: d.id, amount: c.amount });
103
+ groups.set(c.group, g);
104
+ }
105
+ } else if (d.status !== 'retired') {
106
+ warnings.push(`${at}: no cost stated`);
107
+ }
108
+ });
109
+ for (const [g, members] of groups) {
110
+ const payers = members.filter((m) => m.amount != null);
111
+ if (payers.length !== 1) warnings.push(`cost.group "${g}": exactly one device should state the amount (found ${payers.length})`);
112
+ }
113
+ return { valid: errors.length === 0, errors, warnings };
114
+ }
115
+
116
+ function inWindow(cost, now) {
117
+ const t = now.getTime();
118
+ if (cost.since && Date.parse(cost.since) > t) return false;
119
+ if (cost.until && Date.parse(cost.until) < t) return false;
120
+ return true;
121
+ }
122
+
123
+ const round = (n) => Math.round(n * 100) / 100;
124
+
125
+ /**
126
+ * One device's monthly run rate, per the spec's Cost section.
127
+ * state: stated | unstated | grouped | ended | once | retired
128
+ */
129
+ export function monthlyCost(device, { now = new Date() } = {}) {
130
+ const c = device.cost;
131
+ if (device.status === 'retired') return { amount: 0, currency: c?.currency ?? null, state: 'retired' };
132
+ if (!c) return { amount: null, currency: null, state: 'unstated' };
133
+ if (c.amount == null) return { amount: null, currency: c.currency ?? null, state: c.group ? 'grouped' : 'unstated' };
134
+ if (!inWindow(c, now)) return { amount: 0, currency: c.currency, state: 'ended' };
135
+ const a = c.amount;
136
+ switch (c.interval) {
137
+ case 'hour':
138
+ return { amount: round(a * HOURS_PER_MONTH), currency: c.currency, state: 'stated' };
139
+ case 'year':
140
+ return { amount: round(a / 12), currency: c.currency, state: 'stated' };
141
+ case 'once': {
142
+ const months = Number(c.amortize_months);
143
+ if (!(months > 0) || !c.since) return { amount: 0, currency: c.currency, state: 'once' };
144
+ const end = new Date(c.since);
145
+ end.setMonth(end.getMonth() + months);
146
+ if (now > end) return { amount: 0, currency: c.currency, state: 'ended' };
147
+ return { amount: round(a / months), currency: c.currency, state: 'stated' };
148
+ }
149
+ default:
150
+ return { amount: round(a), currency: c.currency, state: 'stated' };
151
+ }
152
+ }
153
+
154
+ const add = (map, key, n) => map.set(key, round((map.get(key) ?? 0) + n));
155
+ const obj = (map) => Object.fromEntries([...map.entries()].sort());
156
+
157
+ /**
158
+ * The fleet's monthly run rate. Totals are per currency; nothing is converted.
159
+ * A group's cost is counted once, from the device that states the amount.
160
+ */
161
+ export function fleetCost(input, { now = new Date() } = {}) {
162
+ const inv = parseInventory(input);
163
+ const total = new Map();
164
+ const byProvider = new Map();
165
+ const byProfile = new Map();
166
+ const byScope = new Map();
167
+ const devices = inv.devices.map((d) => {
168
+ const m = monthlyCost(d, { now });
169
+ if (m.state === 'stated' && m.amount > 0) {
170
+ add(total, m.currency, m.amount);
171
+ add(byProvider, `${d.provider?.name ?? 'self'} ${m.currency}`, m.amount);
172
+ add(byProfile, `${d.profile} ${m.currency}`, m.amount);
173
+ add(byScope, `${d.cost.scope ?? 'business'} ${m.currency}`, m.amount);
174
+ }
175
+ return { id: d.id, name: d.name, profile: d.profile, status: d.status, provider: d.provider?.name ?? null, monthly: m };
176
+ });
177
+ return {
178
+ monthly: obj(total),
179
+ by_provider: obj(byProvider),
180
+ by_profile: obj(byProfile),
181
+ by_scope: obj(byScope),
182
+ devices,
183
+ unstated: devices.filter((d) => d.monthly.state === 'unstated').map((d) => d.id),
184
+ counted: devices.filter((d) => d.monthly.state === 'stated').length,
185
+ };
186
+ }
187
+
188
+ /** Next billing date: next_due if stated, else the next monthly anniversary of since. */
189
+ function nextDue(cost, now) {
190
+ if (cost.next_due) return new Date(cost.next_due).toISOString();
191
+ if (!cost.since || cost.interval !== 'month') return null;
192
+ const d = new Date(cost.since);
193
+ while (d <= now) d.setMonth(d.getMonth() + 1);
194
+ return d.toISOString();
195
+ }
196
+
197
+ const CADENCE = { hour: 'monthly', month: 'monthly', year: 'yearly', once: 'monthly' };
198
+
199
+ /**
200
+ * Costs in the shape CoinPay finances uses for a recurring charge, one per device
201
+ * with a stated run rate. `key` is stable, so re-importing updates instead of
202
+ * adding. CoinPay matches each one against bank charges by payee before counting it.
203
+ */
204
+ export function toCoinPayCosts(input, { now = new Date(), source = 'opendevice' } = {}) {
205
+ const inv = parseInventory(input);
206
+ return inv.devices
207
+ .map((d) => ({ d, m: monthlyCost(d, { now }) }))
208
+ .filter(({ m }) => m.state === 'stated' && m.amount > 0)
209
+ .map(({ d, m }) => ({
210
+ key: `device:${d.id}`,
211
+ payee: d.cost.payee ?? d.provider?.name ?? d.name,
212
+ amount: d.cost.interval === 'hour' || d.cost.interval === 'once' ? m.amount : d.cost.amount,
213
+ currency: m.currency,
214
+ cadence: CADENCE[d.cost.interval],
215
+ monthlyEquivalent: m.amount,
216
+ scope: d.cost.scope ?? 'business',
217
+ category: d.cost.category,
218
+ paidWith: d.cost.paid_with ?? null,
219
+ nextExpected: nextDue(d.cost, now),
220
+ device: { id: d.id, name: d.name, profile: d.profile, provider: d.provider?.name ?? null },
221
+ source,
222
+ }));
223
+ }
224
+
225
+ const norm = (s) => String(s ?? '').trim().toLowerCase().replace(/\.$/, '');
226
+
227
+ /**
228
+ * Join inventory devices with agent-reported machines (ThreatCrush servers,
229
+ * W3BS fleet machines). An agent row matches a device when its hostname, name or
230
+ * IP equals one of the device's id, name, hostnames or addresses. Returns every
231
+ * device, agent or not, plus the agent rows that match no device.
232
+ */
233
+ export function matchAgents(input, agents, { hostname = (a) => a.hostname, name = (a) => a.name, ip = (a) => a.ip_address } = {}) {
234
+ const inv = parseInventory(input);
235
+ const used = new Set();
236
+ const devices = inv.devices.map((d) => {
237
+ const keys = new Set([d.id, d.name, ...d.hostnames, ...d.addresses.ipv4, ...d.addresses.ipv6].map(norm));
238
+ const hits = [];
239
+ agents.forEach((a, i) => {
240
+ if (used.has(i)) return;
241
+ if ([hostname(a), name(a), ip(a)].some((v) => v && keys.has(norm(v)))) {
242
+ used.add(i);
243
+ hits.push(a);
244
+ }
245
+ });
246
+ return { device: d, agents: hits };
247
+ });
248
+ return { devices, unmatched: agents.filter((_, i) => !used.has(i)) };
249
+ }
250
+
251
+ /** Read an inventory from an http(s) URL or a local path (Node). */
252
+ export async function loadInventory(where, { headers } = {}) {
253
+ if (/^https?:\/\//.test(where)) {
254
+ const res = await fetch(where, { headers });
255
+ if (!res.ok) throw new Error(`${where}: HTTP ${res.status}`);
256
+ return parseInventory(await res.text());
257
+ }
258
+ const { readFile } = await import('node:fs/promises');
259
+ return parseInventory(await readFile(where, 'utf8'));
260
+ }
261
+
262
+ /** A per-currency total for display: "$1,234.00 + €53.00". */
263
+ export function formatMonthly(monthly) {
264
+ const parts = Object.entries(monthly ?? {}).map(([cur, n]) =>
265
+ new Intl.NumberFormat('en-US', { style: 'currency', currency: cur }).format(n),
266
+ );
267
+ return parts.length ? parts.join(' + ') : '—';
268
+ }
package/src/observe.js ADDED
@@ -0,0 +1,178 @@
1
+ // Observe a device's public metadata, per the OpenDevice rule: only what anyone on
2
+ // the internet could see without the device's credentials. DNS, reverse DNS, the
3
+ // routing registry (Team Cymru's public DNS service), the TLS certificate and SSH
4
+ // banner the device hands every connection, the HTTP Server header, which of a
5
+ // fixed list of ports answer, and which well-known files are served.
6
+ //
7
+ // Only observe devices you run. Node-only.
8
+ import { promises as dns } from 'node:dns';
9
+ import net from 'node:net';
10
+ import tls from 'node:tls';
11
+
12
+ export const PORTS = [22, 25, 53, 80, 443, 465, 587, 993, 2202, 3000, 5432, 6379, 8080, 8443];
13
+ export const WELLKNOWN = ['security.txt', 'openprofile.md', 'openserver.json', 'opendevice.json'];
14
+
15
+ const settle = async (p, fallback = null) => {
16
+ try {
17
+ return await p;
18
+ } catch {
19
+ return fallback;
20
+ }
21
+ };
22
+
23
+ function timeout(ms) {
24
+ return new Promise((_, reject) => setTimeout(() => reject(new Error('timeout')), ms).unref?.());
25
+ }
26
+
27
+ const race = (p, ms) => Promise.race([p, timeout(ms)]);
28
+
29
+ function isPrivate(ip) {
30
+ if (net.isIPv4(ip)) {
31
+ const [a, b] = ip.split('.').map(Number);
32
+ return a === 10 || a === 127 || (a === 172 && b >= 16 && b < 32) || (a === 192 && b === 168) || (a === 100 && b >= 64 && b < 128) || (a === 169 && b === 254);
33
+ }
34
+ const l = ip.toLowerCase();
35
+ return l === '::1' || l.startsWith('fe80') || l.startsWith('fc') || l.startsWith('fd');
36
+ }
37
+
38
+ function portOpen(host, port, ms) {
39
+ return new Promise((resolve) => {
40
+ const s = net.connect({ host, port });
41
+ const done = (ok) => {
42
+ s.destroy();
43
+ resolve(ok);
44
+ };
45
+ s.setTimeout(ms, () => done(false));
46
+ s.once('connect', () => done(true));
47
+ s.once('error', () => done(false));
48
+ });
49
+ }
50
+
51
+ function sshBanner(host, port, ms) {
52
+ return new Promise((resolve) => {
53
+ const s = net.connect({ host, port });
54
+ let buf = '';
55
+ const done = (v) => {
56
+ s.destroy();
57
+ resolve(v);
58
+ };
59
+ s.setTimeout(ms, () => done(null));
60
+ s.on('data', (chunk) => {
61
+ buf += chunk.toString('latin1');
62
+ const line = buf.split(/\r?\n/)[0];
63
+ if (buf.includes('\n')) done(line.startsWith('SSH-') ? { port, banner: line.trim() } : null);
64
+ });
65
+ s.once('error', () => done(null));
66
+ });
67
+ }
68
+
69
+ function tlsCert(host, servername, port, ms) {
70
+ return new Promise((resolve) => {
71
+ const s = tls.connect({ host, port, servername, rejectUnauthorized: false });
72
+ const done = (v) => {
73
+ s.destroy();
74
+ resolve(v);
75
+ };
76
+ s.setTimeout(ms, () => done(null));
77
+ s.once('secureConnect', () => {
78
+ const c = s.getPeerCertificate();
79
+ if (!c || !c.subject) return done(null);
80
+ done({
81
+ port,
82
+ subject: c.subject.CN ?? null,
83
+ names: (c.subjectaltname ?? '').split(/,\s*/).filter(Boolean).map((n) => n.replace(/^DNS:/, '')),
84
+ issuer: c.issuer?.O ?? c.issuer?.CN ?? null,
85
+ not_after: c.valid_to ? new Date(c.valid_to).toISOString() : null,
86
+ valid: s.authorized,
87
+ });
88
+ });
89
+ s.once('error', () => done(null));
90
+ });
91
+ }
92
+
93
+ async function asnOf(ip) {
94
+ if (!net.isIPv4(ip)) return null;
95
+ const rev = ip.split('.').reverse().join('.');
96
+ const origin = await settle(dns.resolveTxt(`${rev}.origin.asn.cymru.com`));
97
+ const first = origin?.[0]?.join('').split('|').map((s) => s.trim());
98
+ if (!first?.[0]) return null;
99
+ const number = Number(first[0].split(' ')[0]);
100
+ const detail = await settle(dns.resolveTxt(`AS${number}.asn.cymru.com`));
101
+ const org = detail?.[0]?.join('').split('|').map((s) => s.trim())[4] ?? null;
102
+ return { number, org, prefix: first[1] ?? null, country: first[2] ?? null };
103
+ }
104
+
105
+ /**
106
+ * Observe one device. Returns an `observed` block; it never throws for an
107
+ * unreachable device, it just reports less.
108
+ */
109
+ export async function observe(device, { timeoutMs = 4000, ports = PORTS, now = () => new Date() } = {}) {
110
+ const hostnames = device.hostnames ?? [];
111
+ const out = { at: now().toISOString() };
112
+ const a = new Set(device.addresses?.ipv4 ?? []);
113
+ const aaaa = new Set(device.addresses?.ipv6 ?? []);
114
+ if (hostnames.length) {
115
+ out.dns = { a: [], aaaa: [] };
116
+ for (const h of hostnames) {
117
+ for (const ip of (await settle(race(dns.resolve4(h), timeoutMs), [])) ?? []) {
118
+ out.dns.a.includes(ip) || out.dns.a.push(ip);
119
+ a.add(ip);
120
+ }
121
+ for (const ip of (await settle(race(dns.resolve6(h), timeoutMs), [])) ?? []) {
122
+ out.dns.aaaa.includes(ip) || out.dns.aaaa.push(ip);
123
+ aaaa.add(ip);
124
+ }
125
+ }
126
+ }
127
+ const publicIps = [...a, ...aaaa].filter((ip) => !isPrivate(ip));
128
+ if (!publicIps.length) return out;
129
+ const target = [...a].find((ip) => !isPrivate(ip)) ?? publicIps[0];
130
+
131
+ out.ptr = [];
132
+ for (const ip of publicIps) for (const n of (await settle(race(dns.reverse(ip), timeoutMs), [])) ?? []) out.ptr.includes(n) || out.ptr.push(n);
133
+ out.asn = await settle(race(asnOf(target), timeoutMs));
134
+
135
+ const open = await Promise.all(ports.map((p) => portOpen(target, p, timeoutMs).then((ok) => (ok ? p : null))));
136
+ out.ports = open.filter((p) => p != null);
137
+
138
+ const sshPorts = out.ports.filter((p) => p === 22 || p === 2202);
139
+ for (const p of sshPorts) {
140
+ const b = await sshBanner(target, p, timeoutMs);
141
+ if (b) {
142
+ out.ssh = out.ssh ?? [];
143
+ out.ssh.push(b);
144
+ }
145
+ }
146
+ const name = hostnames[0] ?? target;
147
+ if (out.ports.includes(443)) out.tls = await tlsCert(target, net.isIP(name) ? undefined : name, 443, timeoutMs);
148
+ if (out.ports.includes(443) || out.ports.includes(80)) {
149
+ const base = `${out.ports.includes(443) ? 'https' : 'http'}://${name}`;
150
+ const res = await settle(fetch(base, { method: 'GET', redirect: 'manual', signal: AbortSignal.timeout(timeoutMs) }));
151
+ if (res) out.http = { status: res.status, server: res.headers.get('server') };
152
+ out.wellknown = [];
153
+ for (const file of WELLKNOWN) {
154
+ const r = await settle(fetch(`${base}/.well-known/${file}`, { method: 'HEAD', signal: AbortSignal.timeout(timeoutMs) }));
155
+ if (r?.status === 200) out.wellknown.push(file);
156
+ }
157
+ }
158
+ return out;
159
+ }
160
+
161
+ /** Observe every server/other device in an inventory, a few at a time. */
162
+ export async function observeInventory(inv, { concurrency = 3, ...opts } = {}) {
163
+ const targets = inv.devices.filter((d) => (d.profile === 'server' || d.profile === 'other') && d.status !== 'retired');
164
+ const results = new Map();
165
+ let i = 0;
166
+ await Promise.all(
167
+ Array.from({ length: Math.min(concurrency, targets.length) }, async () => {
168
+ while (i < targets.length) {
169
+ const d = targets[i++];
170
+ results.set(d.id, await observe(d, opts));
171
+ }
172
+ }),
173
+ );
174
+ return {
175
+ ...inv,
176
+ devices: inv.devices.map((d) => (results.has(d.id) ? { ...d, observed: { ...d.observed, ...results.get(d.id) } } : d)),
177
+ };
178
+ }