Pithos: a control panel for s3d, the Sia S3 gateway
s3d is driven entirely by a CLI and a two-route admin API, which keeps
Sia storage out of reach for anyone who does not live in a terminal.
Pithos wraps it: install the pinned s3d release (checksum verified),
start/stop and logs, indexer login, users and access keys, a bucket and
object browser with share links and public-read policies, and the
upload pipeline stats.
One control server (core/) serves one UI (ui/), and three hosts run it:
a web console, an Electron desktop window and a Theseus add-on, so a
feature lands everywhere at once. The server binds loopback, rejects
foreign Host headers, and signs views in with a one-time secret that
becomes a SameSite=Strict cookie. s3d login reads the recovery phrase
with term.ReadPassword, so it is driven through a pty, with a console
window fallback where the pty module is not shipped.
2026-10-03 19:02:50 +02:00
|
|
|
// Cross-checks the SigV4 signer against the real AWS CLI: a capture server
|
|
|
|
|
// records what `aws` sends, and we re-sign the same request ourselves. Skips
|
|
|
|
|
// when the aws CLI is not installed.
|
|
|
|
|
|
|
|
|
|
import test from 'node:test';
|
|
|
|
|
import assert from 'node:assert/strict';
|
|
|
|
|
import http from 'node:http';
|
|
|
|
|
import { execFileSync, execFile } from 'node:child_process';
|
|
|
|
|
import { S3Client, objectPath } from '../core/s3.js';
|
|
|
|
|
|
|
|
|
|
const AK = 'AKIDEXAMPLE12345678';
|
|
|
|
|
const SK = 'wJalrXUtnFEMI/K7MDENG+bPxRfiCYEXAMPLEKEY';
|
|
|
|
|
|
|
|
|
|
let hasAws = true;
|
|
|
|
|
try { execFileSync('aws', ['--version'], { stdio: 'ignore' }); } catch { hasAws = false; }
|
|
|
|
|
|
|
|
|
|
function capture(fn) {
|
|
|
|
|
return new Promise((resolve, reject) => {
|
|
|
|
|
const server = http.createServer((req, res) => {
|
|
|
|
|
const chunks = [];
|
|
|
|
|
req.on('data', (c) => chunks.push(c));
|
|
|
|
|
req.on('end', () => {
|
|
|
|
|
res.writeHead(200, { 'content-type': 'application/xml' });
|
|
|
|
|
res.end('<?xml version="1.0"?><ListBucketResult><IsTruncated>false</IsTruncated></ListBucketResult>');
|
|
|
|
|
server.close();
|
|
|
|
|
resolve({ method: req.method, url: req.url, headers: req.headers });
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
server.listen(0, '127.0.0.1', () => fn(server.address().port).catch(reject));
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function runAws(args) {
|
|
|
|
|
return new Promise((resolve) => execFile('aws', args, {
|
|
|
|
|
env: { ...process.env, AWS_ACCESS_KEY_ID: AK, AWS_SECRET_ACCESS_KEY: SK, AWS_REGION: 'us-east-1', AWS_CONFIG_FILE: 'nul', AWS_SHARED_CREDENTIALS_FILE: 'nul' },
|
|
|
|
|
}, () => resolve()));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function resign(port, req, bucket, key) {
|
|
|
|
|
const client = new S3Client({ endpoint: `http://127.0.0.1:${port}`, accessKeyId: AK, secretKey: SK });
|
|
|
|
|
const auth = req.headers.authorization;
|
|
|
|
|
const signedNames = auth.match(/SignedHeaders=([^,]+)/)[1].split(';');
|
|
|
|
|
const headers = {};
|
|
|
|
|
for (const n of signedNames) if (!['host', 'x-amz-date', 'x-amz-content-sha256'].includes(n)) headers[n] = req.headers[n];
|
|
|
|
|
const u = new URL(req.url, 'http://x');
|
|
|
|
|
const query = {};
|
|
|
|
|
for (const [k, v] of u.searchParams) query[k] = v;
|
|
|
|
|
const ours = client.sign(req.method, objectPath(bucket, key), query, headers, {
|
|
|
|
|
now: req.headers['x-amz-date'], payloadHash: req.headers['x-amz-content-sha256'],
|
|
|
|
|
});
|
|
|
|
|
return { theirs: auth.match(/Signature=(\w+)/)[1], ours: ours.authorization.match(/Signature=(\w+)/)[1] };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
test('list with awkward prefix matches aws cli', { skip: !hasAws && 'aws cli not installed' }, async () => {
|
|
|
|
|
let port;
|
|
|
|
|
const req = await capture((p) => { port = p; return runAws(['s3api', 'list-objects-v2', '--bucket', 'my-bucket', '--prefix', "a b/ü+x=(1)*'~", '--endpoint-url', `http://127.0.0.1:${p}`]); });
|
|
|
|
|
const { theirs, ours } = resign(port, req, 'my-bucket');
|
|
|
|
|
assert.equal(ours, theirs);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('object key path encoding matches aws cli', { skip: !hasAws && 'aws cli not installed' }, async () => {
|
|
|
|
|
let port;
|
|
|
|
|
const key = "dir/a b+c(1)!ü*'~.txt";
|
|
|
|
|
const req = await capture((p) => { port = p; return runAws(['s3api', 'delete-object', '--bucket', 'my-bucket', '--key', key, '--endpoint-url', `http://127.0.0.1:${p}`]); });
|
|
|
|
|
const { theirs, ours } = resign(port, req, 'my-bucket', key);
|
|
|
|
|
assert.equal(ours, theirs);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('presigned URL matches aws cli', { skip: !hasAws && 'aws cli not installed' }, () => {
|
|
|
|
|
const key = 'dir/report 2026 (final).pdf';
|
|
|
|
|
const theirs = execFileSync('aws', ['s3', 'presign', `s3://my-bucket/${key}`, '--expires-in', '3600', '--endpoint-url', 'http://127.0.0.1:18000'], {
|
|
|
|
|
env: { ...process.env, AWS_ACCESS_KEY_ID: AK, AWS_SECRET_ACCESS_KEY: SK, AWS_REGION: 'us-east-1', AWS_CONFIG_FILE: 'nul', AWS_SHARED_CREDENTIALS_FILE: 'nul' },
|
|
|
|
|
}).toString().trim();
|
|
|
|
|
const t = new URL(theirs);
|
|
|
|
|
const client = new S3Client({ endpoint: 'http://127.0.0.1:18000', accessKeyId: AK, secretKey: SK });
|
|
|
|
|
const ours = new URL(client.presign('my-bucket', key, 3600, undefined, t.searchParams.get('X-Amz-Date')));
|
|
|
|
|
assert.equal(ours.pathname, t.pathname);
|
|
|
|
|
assert.equal(ours.searchParams.get('X-Amz-Signature'), t.searchParams.get('X-Amz-Signature'));
|
|
|
|
|
});
|
2026-10-03 22:12:44 +02:00
|
|
|
|
|
|
|
|
// Regression: empty query values (prefix=, policy) were sent as "/" and
|
|
|
|
|
// every bucket listing failed with SignatureDoesNotMatch against s3d.
|
|
|
|
|
test('empty query values stay empty and match the aws cli', { skip: !hasAws && 'aws cli not installed' }, async () => {
|
|
|
|
|
let port;
|
|
|
|
|
const req = await capture((p) => { port = p; return runAws(['s3api', 'list-objects-v2', '--bucket', 'my-bucket', '--prefix', '', '--delimiter', '/', '--endpoint-url', `http://127.0.0.1:${p}`]); });
|
|
|
|
|
const { theirs, ours } = resign(port, req, 'my-bucket');
|
|
|
|
|
assert.equal(ours, theirs);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('client sends empty values as empty', async () => {
|
|
|
|
|
const seen = await new Promise((resolve) => {
|
|
|
|
|
const server = http.createServer((req, res) => { res.end('<ListBucketResult><IsTruncated>false</IsTruncated></ListBucketResult>'); server.close(); resolve(req.url); });
|
|
|
|
|
server.listen(0, '127.0.0.1', () => new S3Client({ endpoint: `http://127.0.0.1:${server.address().port}`, accessKeyId: AK, secretKey: SK }).listObjects('b').catch(() => {}));
|
|
|
|
|
});
|
|
|
|
|
assert.match(seen, /[?&]prefix=(&|$)/);
|
|
|
|
|
});
|