@uuaid/pillar 0.2.0-alpha.12 → 0.2.0-alpha.16
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/INTERACTION-CONVENTION.md +216 -0
- package/README.md +275 -11
- package/bin/pillar-blob.mjs +112 -0
- package/bin/pillar-directory.mjs +231 -0
- package/bin/pillar-seed.mjs +112 -13
- package/bin/pillar.mjs +6 -2
- package/package.json +20 -12
- package/src/blob/blob.mjs +426 -0
- package/src/blob/broker.mjs +129 -0
- package/src/blob/http.mjs +196 -0
- package/src/blob/store.mjs +55 -0
- package/src/identity/keychain.mjs +27 -10
- package/src/identity/tier.mjs +24 -3
- package/src/index.mjs +25 -2
- package/src/net/carrier-client.mjs +55 -9
- package/src/net/carrier-server.mjs +169 -11
- package/src/net/delegation.mjs +152 -0
- package/src/net/envelope.mjs +81 -0
- package/src/net/seed-registry.mjs +467 -0
- package/src/net/seed-tier.mjs +185 -0
- package/src/pillar.mjs +96 -3
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pillar-blob — encrypted temporary-object transport through the UUAID broker.
|
|
4
|
+
*
|
|
5
|
+
* pillar-blob send --to UUAID --recipient-pubkey HEX --data FILE [--broker URL]
|
|
6
|
+
* [--carriers URL,...] --key-id REGISTRY_KEY_ID [--ttl-ms N]
|
|
7
|
+
* pillar-blob get --ref-file FILE --expect-envelope ID --key-id REGISTRY_KEY_ID
|
|
8
|
+
* [--broker URL] [--out FILE|-]
|
|
9
|
+
*
|
|
10
|
+
* The broker never receives a DEK. `send` prints metadata only; the secret ref
|
|
11
|
+
* exists solely inside the recipient-encrypted Pillar envelope.
|
|
12
|
+
*/
|
|
13
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { Keychain } from "../src/identity/keychain.mjs";
|
|
17
|
+
import { Pillar } from "../src/pillar.mjs";
|
|
18
|
+
import { PillarBlobHttpClient } from "../src/blob/http.mjs";
|
|
19
|
+
import { fetchBrokerBlob, DEFAULT_BLOB_TTL_MS, BlobError } from "../src/blob/blob.mjs";
|
|
20
|
+
|
|
21
|
+
function usage(code = 2) {
|
|
22
|
+
process.stderr.write("usage: pillar-blob send|get (run without arguments to see the source header)\n");
|
|
23
|
+
process.exit(code);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function parse(argv) {
|
|
27
|
+
const out = {};
|
|
28
|
+
for (let i = 0; i < argv.length; i++) {
|
|
29
|
+
const key = argv[i];
|
|
30
|
+
if (!key.startsWith("--") || i + 1 >= argv.length) usage();
|
|
31
|
+
out[key.slice(2)] = argv[++i];
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function homeDir(args) {
|
|
37
|
+
return args.home ?? process.env.PILLAR_HOME ?? join(homedir(), ".pillar-fleet", "zz-master");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function loadHome(args) {
|
|
41
|
+
const home = homeDir(args);
|
|
42
|
+
const keychain = new Keychain(join(home, "keychain.json"));
|
|
43
|
+
keychain.load({ passphrase: process.env.PILLAR_PASSPHRASE ?? "" });
|
|
44
|
+
let config = {};
|
|
45
|
+
try { config = JSON.parse(readFileSync(join(home, "config.json"), "utf-8")); } catch (_error) {}
|
|
46
|
+
const keyId = args["key-id"] ?? process.env.PILLAR_PRESENTATION_KEY_ID ?? config.presentationKeyId;
|
|
47
|
+
if (!keyId) throw new Error("--key-id (registry-bound presentation key id) is required");
|
|
48
|
+
const broker = args.broker ?? process.env.PILLAR_BLOB_BROKER ?? "https://api.uuaid.org";
|
|
49
|
+
const client = new PillarBlobHttpClient({ baseUrl: broker, keychain, keyId });
|
|
50
|
+
return { home, keychain, config, client };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function fail(error) {
|
|
54
|
+
const code = error instanceof BlobError ? error.code : "error";
|
|
55
|
+
process.stderr.write(`pillar-blob: ${code}: ${error?.message ?? String(error)}\n`);
|
|
56
|
+
process.exit(code === "blob-not-found" || code === "blob-expired" ? 3 : code.includes("integrity") ? 5 : 6);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
60
|
+
if (!command) usage();
|
|
61
|
+
const args = parse(rest);
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
if (command === "send") {
|
|
65
|
+
if (!args.to || !args["recipient-pubkey"] || !args.data) usage();
|
|
66
|
+
const { home, keychain, config, client } = loadHome(args);
|
|
67
|
+
const carriers = (args.carriers ?? process.env.PILLAR_CARRIERS ?? (config.carriers ?? []).join(","))
|
|
68
|
+
.split(",").map((value) => value.trim()).filter(Boolean);
|
|
69
|
+
if (!carriers.length) throw new Error("no carriers configured");
|
|
70
|
+
const pillar = new Pillar({
|
|
71
|
+
keychain,
|
|
72
|
+
mailboxPath: join(home, "mailbox.db"),
|
|
73
|
+
carriers,
|
|
74
|
+
enableLibp2p: false,
|
|
75
|
+
blobClient: client,
|
|
76
|
+
});
|
|
77
|
+
await pillar.start();
|
|
78
|
+
try {
|
|
79
|
+
const result = await pillar.sendBlob({
|
|
80
|
+
recipient: args.to,
|
|
81
|
+
recipientPublicKey: args["recipient-pubkey"],
|
|
82
|
+
data: args.data === "-" ? readFileSync(0) : readFileSync(args.data),
|
|
83
|
+
ttlMs: Number(args["ttl-ms"] ?? DEFAULT_BLOB_TTL_MS),
|
|
84
|
+
});
|
|
85
|
+
process.stdout.write(`${JSON.stringify({
|
|
86
|
+
envelope_id: result.envelope.id,
|
|
87
|
+
via: result.via,
|
|
88
|
+
plaintext_bytes: result.blob.size,
|
|
89
|
+
ciphertext_sha256: result.blob.hash,
|
|
90
|
+
})}\n`);
|
|
91
|
+
} finally {
|
|
92
|
+
await pillar.stop();
|
|
93
|
+
}
|
|
94
|
+
} else if (command === "get") {
|
|
95
|
+
if (!args["ref-file"] || !args["expect-envelope"]) usage();
|
|
96
|
+
const { keychain, client } = loadHome(args);
|
|
97
|
+
const parsed = JSON.parse(readFileSync(args["ref-file"], "utf-8"));
|
|
98
|
+
const ref = parsed.blobRef ?? parsed;
|
|
99
|
+
const result = await fetchBrokerBlob({
|
|
100
|
+
ref,
|
|
101
|
+
envelopeId: args["expect-envelope"],
|
|
102
|
+
recipient: keychain._identity.uuaid,
|
|
103
|
+
client,
|
|
104
|
+
});
|
|
105
|
+
if (!args.out || args.out === "-") process.stdout.write(result.data);
|
|
106
|
+
else writeFileSync(args.out, result.data, { mode: 0o600 });
|
|
107
|
+
process.stderr.write(`pillar-blob: redeemed ${result.size} plaintext byte(s)\n`);
|
|
108
|
+
} else usage();
|
|
109
|
+
} catch (error) {
|
|
110
|
+
fail(error);
|
|
111
|
+
}
|
|
112
|
+
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* pillar-directory — serve directory.json from the seed registry.
|
|
4
|
+
*
|
|
5
|
+
* pillar-directory --port 8800 --carrier https://pillar.uuaid.org \
|
|
6
|
+
* --agents ./agents.json --db /opt/pillar-directory/registry.db
|
|
7
|
+
*
|
|
8
|
+
* Replaces the hand-edited directory.json that every agent bootstraps from.
|
|
9
|
+
* The file could only be changed by whoever had shell on the host serving it,
|
|
10
|
+
* which is the reason the mesh had exactly one seed. Here the seed list is a
|
|
11
|
+
* table any operator can enter by proving they run a live seed, and the rung
|
|
12
|
+
* they hold is computed from probes this process makes itself.
|
|
13
|
+
*
|
|
14
|
+
* Routes:
|
|
15
|
+
* GET /directory.json the directory (carrier + agents + ranked seeds)
|
|
16
|
+
* POST /v1/seeds enroll { url, operator?, region? }
|
|
17
|
+
* GET /v1/seeds every enrolled seed and its standing
|
|
18
|
+
* GET /v1/seeds/<uuaid> one seed's standing + what is left to earn
|
|
19
|
+
* GET /v1/seeds/<uuaid>/grant that seed's current earned tier grant
|
|
20
|
+
* GET /v1/health
|
|
21
|
+
*
|
|
22
|
+
* `agents.json` is passed through untouched: this process owns the seed list,
|
|
23
|
+
* not the address book.
|
|
24
|
+
*/
|
|
25
|
+
import { createServer } from "node:http";
|
|
26
|
+
import { readFileSync, existsSync, mkdirSync } from "node:fs";
|
|
27
|
+
import { homedir } from "node:os";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
import { SeedRegistry } from "../src/net/seed-registry.mjs";
|
|
30
|
+
import { Keychain } from "../src/identity/keychain.mjs";
|
|
31
|
+
|
|
32
|
+
function parseArgs(argv) {
|
|
33
|
+
const args = {};
|
|
34
|
+
for (let i = 0; i < argv.length; i++) {
|
|
35
|
+
const a = argv[i];
|
|
36
|
+
if (a.startsWith("--")) {
|
|
37
|
+
const k = a.slice(2);
|
|
38
|
+
args[k] = (argv[i + 1] && !argv[i + 1].startsWith("--")) ? argv[++i] : true;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return args;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const args = parseArgs(process.argv.slice(2));
|
|
45
|
+
const home = args.home ?? process.env.DIRECTORY_HOME ?? join(homedir(), ".pillar-directory");
|
|
46
|
+
const port = Number(args.port ?? process.env.DIRECTORY_PORT ?? 8800);
|
|
47
|
+
const host = args.host ?? process.env.DIRECTORY_HOST ?? "127.0.0.1";
|
|
48
|
+
const dbPath = args.db ?? process.env.DIRECTORY_DB ?? join(home, "registry.db");
|
|
49
|
+
const carrier = args.carrier ?? process.env.DIRECTORY_CARRIER ?? null;
|
|
50
|
+
const agentsPath = args.agents ?? process.env.DIRECTORY_AGENTS ?? null;
|
|
51
|
+
const protocol = args.protocol ?? process.env.DIRECTORY_PROTOCOL ?? "uacp/1.0-draft";
|
|
52
|
+
const probeMinutes = Number(args["probe-minutes"] ?? process.env.DIRECTORY_PROBE_MINUTES ?? 10);
|
|
53
|
+
const allowPrivateUrls = Boolean(args["allow-private-urls"]);
|
|
54
|
+
|
|
55
|
+
const MAX_ENROLLMENTS_PER_HOUR = 10; // per client IP
|
|
56
|
+
const MAX_BODY = 8 * 1024;
|
|
57
|
+
|
|
58
|
+
mkdirSync(home, { recursive: true });
|
|
59
|
+
const keychain = new Keychain(join(home, "registry-keychain.json"));
|
|
60
|
+
if (!keychain.exists()) {
|
|
61
|
+
keychain.save(Keychain.generate());
|
|
62
|
+
console.log(`minted registry identity -> ${join(home, "registry-keychain.json")}`);
|
|
63
|
+
}
|
|
64
|
+
keychain.load();
|
|
65
|
+
|
|
66
|
+
const registry = new SeedRegistry({ keychain, carrierUrl: carrier, dbPath, protocol, allowPrivateUrls });
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* agents[] is read fresh on each render rather than cached at boot, so
|
|
70
|
+
* updating the address book does not require restarting the directory and
|
|
71
|
+
* dropping every in-flight enrollment.
|
|
72
|
+
*/
|
|
73
|
+
function agents() {
|
|
74
|
+
if (!agentsPath || !existsSync(agentsPath)) return [];
|
|
75
|
+
try {
|
|
76
|
+
const raw = JSON.parse(readFileSync(agentsPath, "utf-8"));
|
|
77
|
+
return Array.isArray(raw) ? raw : (raw.agents ?? []);
|
|
78
|
+
} catch (e) {
|
|
79
|
+
console.error(`[directory] agents file unreadable: ${e.message}`);
|
|
80
|
+
return [];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const enrollRate = new Map(); // ip -> [ms]
|
|
85
|
+
function enrollAllowed(ip) {
|
|
86
|
+
const now = Date.now();
|
|
87
|
+
const arr = (enrollRate.get(ip) ?? []).filter(t => t > now - 3600_000);
|
|
88
|
+
if (arr.length >= MAX_ENROLLMENTS_PER_HOUR) { enrollRate.set(ip, arr); return false; }
|
|
89
|
+
arr.push(now);
|
|
90
|
+
enrollRate.set(ip, arr);
|
|
91
|
+
if (enrollRate.size > 10_000) {
|
|
92
|
+
for (const [k, v] of enrollRate) if (!v.some(t => t > now - 3600_000)) enrollRate.delete(k);
|
|
93
|
+
}
|
|
94
|
+
return true;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function send(res, code, obj) {
|
|
98
|
+
const body = JSON.stringify(obj, null, 2);
|
|
99
|
+
res.writeHead(code, {
|
|
100
|
+
"content-type": "application/json",
|
|
101
|
+
"content-length": Buffer.byteLength(body),
|
|
102
|
+
// The directory is public bootstrap data; browsers fetch it directly.
|
|
103
|
+
"access-control-allow-origin": "*",
|
|
104
|
+
"cache-control": "public, max-age=60",
|
|
105
|
+
});
|
|
106
|
+
res.end(body);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function readBody(req) {
|
|
110
|
+
return new Promise((resolve, reject) => {
|
|
111
|
+
let size = 0; const chunks = [];
|
|
112
|
+
req.on("data", c => {
|
|
113
|
+
size += c.length;
|
|
114
|
+
if (size > MAX_BODY) { reject(new Error("body too large")); req.destroy(); return; }
|
|
115
|
+
chunks.push(c);
|
|
116
|
+
});
|
|
117
|
+
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
|
|
118
|
+
req.on("error", reject);
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const clientIp = (req) => {
|
|
123
|
+
const fwd = req.headers["x-forwarded-for"];
|
|
124
|
+
return fwd ? String(fwd).split(",")[0].trim() : (req.socket.remoteAddress ?? "unknown");
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
const server = createServer(async (req, res) => {
|
|
128
|
+
try {
|
|
129
|
+
const url = new URL(req.url, `http://${req.headers.host ?? "localhost"}`);
|
|
130
|
+
|
|
131
|
+
if (req.method === "GET" && (url.pathname === "/directory.json" || url.pathname === "/")) {
|
|
132
|
+
return send(res, 200, registry.directory({ agents: agents() }));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (req.method === "POST" && url.pathname === "/v1/seeds") {
|
|
136
|
+
const ip = clientIp(req);
|
|
137
|
+
if (!enrollAllowed(ip)) return send(res, 429, { ok: false, reason: "rate-limited" });
|
|
138
|
+
let payload;
|
|
139
|
+
try { payload = JSON.parse(await readBody(req)); }
|
|
140
|
+
catch { return send(res, 400, { ok: false, reason: "bad-json" }); }
|
|
141
|
+
const r = await registry.enroll({
|
|
142
|
+
url: payload?.url,
|
|
143
|
+
operator: payload?.operator ?? null,
|
|
144
|
+
region: payload?.region ?? null,
|
|
145
|
+
});
|
|
146
|
+
if (!r.ok) console.log(`[directory] enroll refused ${payload?.url}: ${r.reason}`);
|
|
147
|
+
else if (!r.already) console.log(`[directory] enrolled ${r.url} as ${r.uuaid}`);
|
|
148
|
+
return send(res, r.ok ? 200 : 400, r);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (req.method === "GET" && url.pathname === "/v1/seeds") {
|
|
152
|
+
return send(res, 200, { seeds: registry.all().map(r => registry.status(r.uuaid)) });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const grantMatch = /^\/v1\/seeds\/([^/]+)\/grant$/.exec(url.pathname);
|
|
156
|
+
if (req.method === "GET" && grantMatch) {
|
|
157
|
+
const uuaid = decodeURIComponent(grantMatch[1]);
|
|
158
|
+
// Served unauthenticated on purpose: a grant is only usable by the
|
|
159
|
+
// identity it names, and using it means signing an envelope with that
|
|
160
|
+
// identity's key. Holding someone else's grant buys nothing.
|
|
161
|
+
const grant = registry.grantFor(uuaid);
|
|
162
|
+
if (!grant) {
|
|
163
|
+
const known = registry.get(uuaid);
|
|
164
|
+
return send(res, known ? 200 : 404,
|
|
165
|
+
known ? { ok: true, grant: null, reason: "tier-earns-no-grant", tier: known.tier }
|
|
166
|
+
: { ok: false, reason: "unknown-seed" });
|
|
167
|
+
}
|
|
168
|
+
return send(res, 200, { ok: true, grant });
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const seedMatch = /^\/v1\/seeds\/([^/]+)$/.exec(url.pathname);
|
|
172
|
+
if (req.method === "GET" && seedMatch) {
|
|
173
|
+
const st = registry.status(decodeURIComponent(seedMatch[1]));
|
|
174
|
+
return send(res, st.ok ? 200 : 404, st);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
if (req.method === "GET" && url.pathname === "/v1/health") {
|
|
178
|
+
return send(res, 200, {
|
|
179
|
+
ok: true,
|
|
180
|
+
seeds: registry.all().length,
|
|
181
|
+
issuer: registry.issuerKey,
|
|
182
|
+
uptimeMs: Date.now() - startedAt,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
send(res, 404, { error: "not-found" });
|
|
187
|
+
} catch (e) {
|
|
188
|
+
console.error(`[directory] 500: ${e.message}`);
|
|
189
|
+
send(res, 500, { error: "internal-error" });
|
|
190
|
+
}
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
const startedAt = Date.now();
|
|
194
|
+
await new Promise(r => server.listen(port, host, r));
|
|
195
|
+
console.log(`pillar-directory listening on http://${host}:${port}`);
|
|
196
|
+
console.log(` issuer: ${registry.issuerKey}`);
|
|
197
|
+
console.log(` carrier: ${carrier ?? "(none — set --carrier)"}`);
|
|
198
|
+
console.log(` db: ${dbPath}`);
|
|
199
|
+
console.log(` seeds: ${registry.all().length}`);
|
|
200
|
+
console.log(` probing every ${probeMinutes} min`);
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The observation loop. Every seed is probed on the same cadence, so the
|
|
204
|
+
* probe counts in the ladder translate directly into wall-clock time:
|
|
205
|
+
* at the default 10 minutes, `steady`'s 20 probes is ~3.3 hours of proof on
|
|
206
|
+
* top of its 1-day minimum age.
|
|
207
|
+
*/
|
|
208
|
+
let probing = false;
|
|
209
|
+
const probeTimer = setInterval(async () => {
|
|
210
|
+
if (probing) return; // never overlap: a slow seed must not stack rounds
|
|
211
|
+
probing = true;
|
|
212
|
+
try {
|
|
213
|
+
for (const r of await registry.observeAll()) {
|
|
214
|
+
if (r.changed) console.log(`[directory] ${r.url} ${r.previousTier} -> ${r.tier}`);
|
|
215
|
+
else if (!r.alive) console.log(`[directory] ${r.url} probe failed`);
|
|
216
|
+
}
|
|
217
|
+
} catch (e) {
|
|
218
|
+
console.error(`[directory] probe round failed: ${e.message}`);
|
|
219
|
+
} finally {
|
|
220
|
+
probing = false;
|
|
221
|
+
}
|
|
222
|
+
}, Math.max(1, probeMinutes) * 60_000);
|
|
223
|
+
|
|
224
|
+
const shutdown = async () => {
|
|
225
|
+
clearInterval(probeTimer);
|
|
226
|
+
await new Promise(r => server.close(r));
|
|
227
|
+
registry.close();
|
|
228
|
+
process.exit(0);
|
|
229
|
+
};
|
|
230
|
+
process.on("SIGINT", shutdown);
|
|
231
|
+
process.on("SIGTERM", shutdown);
|
package/bin/pillar-seed.mjs
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
* the p2p store-and-forward mesh, like a LimeWire/BitTorrent seed peer.
|
|
5
5
|
*
|
|
6
6
|
* pillar-seed --port 8799 --db /opt/pillar-seed/seed.db
|
|
7
|
+
* pillar-seed --public-url https://seed.example.org --announce https://pillar.uuaid.org
|
|
8
|
+
* pillar-seed --status --announce https://pillar.uuaid.org
|
|
7
9
|
*
|
|
8
10
|
* A seed IS a carrier server with a public URL — nothing else. Agents
|
|
9
11
|
* anywhere list your URL in their carriers[] and then:
|
|
@@ -13,18 +15,35 @@
|
|
|
13
15
|
* Minimal ongoing requirements: one process, one SQLite file, outbound-free
|
|
14
16
|
* (all traffic comes TO you). That is the whole contract.
|
|
15
17
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
18
|
+
* JOINING
|
|
19
|
+
*
|
|
20
|
+
* Announce yourself to a registry with --announce and you are enrolled: the
|
|
21
|
+
* registry fetches /v1/seed-info from the URL you gave, checks that it is
|
|
22
|
+
* signed by your seed key, and lists you. No account, no review, no waiting
|
|
23
|
+
* on whoever owns the directory file.
|
|
24
|
+
*
|
|
25
|
+
* WHAT YOU EARN
|
|
26
|
+
*
|
|
27
|
+
* The registry then probes you — liveness plus a real envelope round-tripped
|
|
28
|
+
* through your seed — and promotes you as the evidence accumulates:
|
|
29
|
+
*
|
|
30
|
+
* probation -> steady (a day) -> durable (a week) -> anchor (a month)
|
|
19
31
|
*
|
|
20
|
-
*
|
|
32
|
+
* From `steady` up, your OWN identity is issued a signed tier grant: raised
|
|
33
|
+
* send budgets at every carrier that trusts the registry's issuer key. From
|
|
34
|
+
* `durable` up that grant also carries priority, which reserves you
|
|
35
|
+
* admission capacity at those carriers when they are shedding load. Run
|
|
36
|
+
* --status to see your rung and exactly what is left to reach the next one.
|
|
21
37
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
* only your seed between them, no official carrier involved.
|
|
38
|
+
* Defaults to loopback (RedTeam F5). To become a real seed, put it behind a
|
|
39
|
+
* TLS-terminating proxy (nginx/Caddy) and pass the PUBLIC https:// URL as
|
|
40
|
+
* --public-url — never the loopback one.
|
|
26
41
|
*/
|
|
42
|
+
import { mkdirSync } from "node:fs";
|
|
43
|
+
import { homedir } from "node:os";
|
|
44
|
+
import { join } from "node:path";
|
|
27
45
|
import { CarrierServer } from "../src/net/carrier-server.mjs";
|
|
46
|
+
import { Keychain } from "../src/identity/keychain.mjs";
|
|
28
47
|
|
|
29
48
|
function parseArgs(argv) {
|
|
30
49
|
const args = {};
|
|
@@ -39,16 +58,68 @@ function parseArgs(argv) {
|
|
|
39
58
|
}
|
|
40
59
|
|
|
41
60
|
const args = parseArgs(process.argv.slice(2));
|
|
61
|
+
const home = args.home ?? process.env.SEED_HOME ?? join(homedir(), ".pillar-seed");
|
|
42
62
|
const port = Number(args.port ?? process.env.SEED_PORT ?? 8799);
|
|
43
63
|
const host = args.host ?? process.env.SEED_HOST ?? "127.0.0.1";
|
|
44
64
|
const dbPath = args.db ?? process.env.SEED_DB ?? "pillar-seed.db";
|
|
45
65
|
const ttlDays = Number(args.ttl ?? process.env.SEED_TTL_DAYS ?? 14);
|
|
66
|
+
const publicUrl = args["public-url"] ?? process.env.SEED_PUBLIC_URL ?? null;
|
|
67
|
+
const registryUrl = typeof args.announce === "string" ? args.announce.replace(/\/+$/, "") : null;
|
|
68
|
+
const operator = args.operator ?? process.env.SEED_OPERATOR ?? null;
|
|
69
|
+
const region = args.region ?? process.env.SEED_REGION ?? null;
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* The seed's identity. Distinct from any agent keychain on the box: it names
|
|
73
|
+
* the SEED, is what /v1/seed-info is signed with, and is the subject of any
|
|
74
|
+
* tier grant the seed earns.
|
|
75
|
+
*/
|
|
76
|
+
mkdirSync(home, { recursive: true });
|
|
77
|
+
const keychain = new Keychain(join(home, "seed-keychain.json"));
|
|
78
|
+
if (!keychain.exists()) {
|
|
79
|
+
keychain.save(Keychain.generate());
|
|
80
|
+
console.log(`minted seed identity -> ${join(home, "seed-keychain.json")}`);
|
|
81
|
+
}
|
|
82
|
+
keychain.load();
|
|
46
83
|
|
|
47
|
-
|
|
84
|
+
/** Ask a registry where we stand and print the ladder position. */
|
|
85
|
+
async function printStanding(registry, uuaid) {
|
|
86
|
+
try {
|
|
87
|
+
const res = await fetch(`${registry}/v1/seeds/${encodeURIComponent(uuaid)}`);
|
|
88
|
+
if (res.status === 404) {
|
|
89
|
+
console.log(` standing: not enrolled at ${registry} — run with --announce ${registry}`);
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
if (!res.ok) { console.log(` standing: registry ${res.status}`); return; }
|
|
93
|
+
const s = await res.json();
|
|
94
|
+
console.log(` tier: ${s.tier}${s.demoted ? " (DEMOTED — failing probes)" : ""} — ${s.blurb}`);
|
|
95
|
+
console.log(` earned: ${s.grantTier ? `${s.grantTier} grant${s.priority ? " + priority delivery" : ""}` : "nothing yet"}`);
|
|
96
|
+
const o = s.observed ?? {};
|
|
97
|
+
console.log(` observed: ${o.probes?.passed ?? 0}/${o.probes?.total ?? 0} probes (${((o.uptime ?? 0) * 100).toFixed(1)}% uptime), ${o.roundTrips?.passed ?? 0} relay round-trips`);
|
|
98
|
+
if (s.next) console.log(` next: ${s.next} — needs ${s.missing.join(", ")}`);
|
|
99
|
+
else if (!s.demoted) console.log(` next: top of the ladder`);
|
|
100
|
+
} catch (e) {
|
|
101
|
+
console.log(` standing: unreachable — ${e.message}`);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// --status is a query, not a server: report and exit.
|
|
106
|
+
if (args.status) {
|
|
107
|
+
if (!registryUrl) {
|
|
108
|
+
console.error("--status needs --announce <registry-url> to know which registry to ask");
|
|
109
|
+
process.exit(1);
|
|
110
|
+
}
|
|
111
|
+
console.log(`seed ${keychain._identity.uuaid}`);
|
|
112
|
+
await printStanding(registryUrl, keychain._identity.uuaid);
|
|
113
|
+
process.exit(0);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const server = new CarrierServer({ dbPath, ttlDays, keychain, publicUrl });
|
|
48
117
|
const addr = await server.listen(port, host);
|
|
49
118
|
console.log(`pillar-seed listening on http://${addr.address}:${addr.port}`);
|
|
119
|
+
console.log(` uuaid: ${keychain._identity.uuaid}`);
|
|
50
120
|
console.log(` db: ${dbPath}`);
|
|
51
121
|
console.log(` ttl: ${ttlDays} days`);
|
|
122
|
+
if (publicUrl) console.log(` public url: ${publicUrl}`);
|
|
52
123
|
|
|
53
124
|
// Boot self-check on the address we actually serve — verify before claim.
|
|
54
125
|
try {
|
|
@@ -60,11 +131,39 @@ try {
|
|
|
60
131
|
console.log(` health: unreachable — ${e.message}`);
|
|
61
132
|
}
|
|
62
133
|
|
|
63
|
-
if (
|
|
134
|
+
if (registryUrl) {
|
|
135
|
+
if (!publicUrl) {
|
|
136
|
+
console.log(`\n --announce needs --public-url <https://your-host>: the registry enrolls a URL,`);
|
|
137
|
+
console.log(` and it must be one it can reach and that you can prove you operate.`);
|
|
138
|
+
} else {
|
|
139
|
+
console.log(`\n announcing ${publicUrl} to ${registryUrl} ...`);
|
|
140
|
+
try {
|
|
141
|
+
const res = await fetch(`${registryUrl}/v1/seeds`, {
|
|
142
|
+
method: "POST",
|
|
143
|
+
headers: { "content-type": "application/json" },
|
|
144
|
+
body: JSON.stringify({ url: publicUrl, operator, region }),
|
|
145
|
+
});
|
|
146
|
+
const body = await res.json().catch(() => ({}));
|
|
147
|
+
if (res.ok && body.ok) {
|
|
148
|
+
console.log(` enrolled${body.already ? " (already)" : ""} as ${body.uuaid}`);
|
|
149
|
+
await printStanding(registryUrl, keychain._identity.uuaid);
|
|
150
|
+
} else {
|
|
151
|
+
console.log(` enrollment refused: ${body.reason ?? res.status}`);
|
|
152
|
+
if (String(body.reason ?? "").startsWith("seed-unreachable")) {
|
|
153
|
+
console.log(` (the registry could not fetch ${publicUrl}/v1/seed-info — check DNS, TLS, and your proxy)`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} catch (e) {
|
|
157
|
+
console.log(` enrollment failed: ${e.message}`);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
} else if (host === "127.0.0.1") {
|
|
64
161
|
console.log(`\n loopback seed — reachable only from this machine.`);
|
|
65
|
-
console.log(` To join the mesh as a real seed: TLS-terminate a public URL
|
|
66
|
-
console.log(`
|
|
67
|
-
console.log(`
|
|
162
|
+
console.log(` To join the mesh as a real seed: TLS-terminate a public URL, then`);
|
|
163
|
+
console.log(` pillar-seed --host 0.0.0.0 --public-url https://<your-host> \\`);
|
|
164
|
+
console.log(` --announce https://pillar.uuaid.org --operator <you> --region <region>`);
|
|
165
|
+
console.log(` Enrollment is permissionless: the registry verifies the URL against your`);
|
|
166
|
+
console.log(` seed key and lists you. Staying up is what earns the rest.`);
|
|
68
167
|
}
|
|
69
168
|
|
|
70
169
|
// Vacuum expired envelopes hourly.
|
package/bin/pillar.mjs
CHANGED
|
@@ -436,8 +436,12 @@ async function cmdInbox(args) {
|
|
|
436
436
|
// senders loaded, or every fresh envelope counts as a stranger.
|
|
437
437
|
loadFleetAddressBook(fleetPath(args), p);
|
|
438
438
|
const pulled = await p.drainCarrierInbox();
|
|
439
|
+
// fromPublicKey: the sender's verified Ed25519 key (transportSignature —
|
|
440
|
+
// open() binds it to `from`). Lets a drain-and-reply consumer (the fleet
|
|
441
|
+
// courier's auto-ack, a bot) seal back to a sender who is NOT in fleet.json
|
|
442
|
+
// via `pillar send … --to-pubkey <fromPublicKey>`. Additive field.
|
|
439
443
|
for (const env of fresh) {
|
|
440
|
-
console.log(JSON.stringify({ new: true, id: env.id, from: env.sender, kind: env.kind, at: env.createdAt, payload: env.payload }));
|
|
444
|
+
console.log(JSON.stringify({ new: true, id: env.id, from: env.sender, fromPublicKey: env.transportSignature?.publicKey ?? null, kind: env.kind, at: env.createdAt, payload: env.payload }));
|
|
441
445
|
}
|
|
442
446
|
// Backlog: rows stored earlier (e.g. by a recv daemon) but never surfaced.
|
|
443
447
|
const { decrypt } = await import("../src/net/envelope.mjs");
|
|
@@ -448,7 +452,7 @@ async function cmdInbox(args) {
|
|
|
448
452
|
const env = JSON.parse(row.envelope_json);
|
|
449
453
|
let payload = null;
|
|
450
454
|
try { payload = decrypt(p.keychain, env); } catch (_e) { payload = { error: "decrypt failed" }; }
|
|
451
|
-
console.log(JSON.stringify({ new: false, id: env.id, from: env.sender, kind: env.kind, at: env.createdAt, payload }));
|
|
455
|
+
console.log(JSON.stringify({ new: false, id: env.id, from: env.sender, fromPublicKey: env.transportSignature?.publicKey ?? null, kind: env.kind, at: env.createdAt, payload }));
|
|
452
456
|
}
|
|
453
457
|
}
|
|
454
458
|
const qCount = (p.mailbox.inboxQuarantined?.(500) ?? []).length;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uuaid/pillar",
|
|
3
|
-
"version": "0.2.0-alpha.
|
|
4
|
-
"description": "Pillar (Protocol for Inter-agent Linked Ledger And Relay)
|
|
3
|
+
"version": "0.2.0-alpha.16",
|
|
4
|
+
"description": "Pillar (Protocol for Inter-agent Linked Ledger And Relay) \u2014 the secure delivery layer of UACP, the Universal Agent Communication Protocol. UACP = UUAID (identity) + Envelope (signed, encrypted container) + Pillar (relay, mailbox, routing, store-and-forward). Reference implementation of the UACP delivery layer; includes VDA-1 (IAASO-3901, in ballot) delivery attestation and a blind escrow for the Zilligon ZGN / EVM rails.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "src/index.mjs",
|
|
@@ -11,17 +11,26 @@
|
|
|
11
11
|
"pillar-ask": "bin/pillar-ask.mjs",
|
|
12
12
|
"pillar-responder": "bin/pillar-responder.mjs",
|
|
13
13
|
"pillar-seed": "bin/pillar-seed.mjs",
|
|
14
|
-
"pillar-
|
|
14
|
+
"pillar-directory": "bin/pillar-directory.mjs",
|
|
15
|
+
"pillar-escrow": "bin/pillar-escrow.mjs",
|
|
16
|
+
"pillar-blob": "bin/pillar-blob.mjs"
|
|
15
17
|
},
|
|
16
18
|
"exports": {
|
|
17
|
-
".":
|
|
18
|
-
"./keychain":
|
|
19
|
-
"./jcs":
|
|
20
|
-
"./tier":
|
|
21
|
-
"./envelope":
|
|
22
|
-
"./
|
|
23
|
-
"./
|
|
24
|
-
"./
|
|
19
|
+
".": "./src/index.mjs",
|
|
20
|
+
"./keychain": "./src/identity/keychain.mjs",
|
|
21
|
+
"./jcs": "./src/identity/jcs.mjs",
|
|
22
|
+
"./tier": "./src/identity/tier.mjs",
|
|
23
|
+
"./envelope": "./src/net/envelope.mjs",
|
|
24
|
+
"./delegation": "./src/net/delegation.mjs",
|
|
25
|
+
"./e2e": "./src/crypto/e2e.mjs",
|
|
26
|
+
"./carrier-client": "./src/net/carrier-client.mjs",
|
|
27
|
+
"./seed-tier": "./src/net/seed-tier.mjs",
|
|
28
|
+
"./seed-registry": "./src/net/seed-registry.mjs",
|
|
29
|
+
"./blob": "./src/blob/blob.mjs",
|
|
30
|
+
"./blob/http": "./src/blob/http.mjs",
|
|
31
|
+
"./blob/store": "./src/blob/store.mjs",
|
|
32
|
+
"./blob/broker": "./src/blob/broker.mjs",
|
|
33
|
+
"./package.json": "./package.json"
|
|
25
34
|
},
|
|
26
35
|
"files": [
|
|
27
36
|
"src",
|
|
@@ -41,7 +50,6 @@
|
|
|
41
50
|
},
|
|
42
51
|
"dependencies": {
|
|
43
52
|
"@chainsafe/libp2p-noise": "^17.0.0",
|
|
44
|
-
"@noble/curves": "^2.3.0",
|
|
45
53
|
"@noble/hashes": "^2.3.0",
|
|
46
54
|
"@chainsafe/libp2p-yamux": "^8.0.1",
|
|
47
55
|
"@libp2p/autonat": "^3.0.26",
|