Serve the shared Mempool explorer through the Angor indexer
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Opt-in disposable rootless Angor gateway integration checks. No native app changes."""
|
||||
import subprocess,pathlib,json,urllib.request,urllib.error,time,tempfile,os,uuid,shlex
|
||||
import subprocess,pathlib,json,urllib.request,urllib.error,time,tempfile,os,uuid,shlex,socket
|
||||
import yaml
|
||||
if os.environ.get('ARCHY_ALLOW_DISPOSABLE_CONTAINERS') != '1':
|
||||
raise SystemExit('Set ARCHY_ALLOW_DISPOSABLE_CONTAINERS=1 to run isolated test containers')
|
||||
@@ -8,17 +8,20 @@ manifest=yaml.safe_load((pathlib.Path(__file__).resolve().parents[2]/'apps/angor
|
||||
health=manifest['health_check']
|
||||
health_url=health['endpoint'].rstrip('/')+health.get('path','/')
|
||||
run_id=uuid.uuid4().hex[:12]
|
||||
net='archy-angor-test-'+run_id;backend='angor-test-backend-'+run_id;gateway='angor-test-gateway-'+run_id
|
||||
net='archy-angor-test-'+run_id;backend='angor-test-backend-'+run_id;gateway='angor-test-gateway-'+run_id;frontend='angor-test-frontend-'+run_id
|
||||
port=None
|
||||
def run(*a):
|
||||
r=subprocess.run(a,capture_output=True,text=True)
|
||||
if r.returncode:raise RuntimeError(r.stderr)
|
||||
return r.stdout.strip()
|
||||
def req(path,data=None,method=None,headers={}):
|
||||
r=urllib.request.Request('http://127.0.0.1:19098'+path,data=data,method=method,headers=headers)
|
||||
r=urllib.request.Request(f'http://127.0.0.1:{port}'+path,data=data,method=method,headers=headers)
|
||||
try:
|
||||
with urllib.request.urlopen(r,timeout=10) as f:return f.status,f.headers,f.read()
|
||||
except urllib.error.HTTPError as e:return e.code,e.headers,e.read()
|
||||
script="""require('http').createServer((q,r)=>{let b='';q.on('data',x=>b+=x);q.on('end',()=>{r.setHeader('Access-Control-Allow-Origin','https://wrong.example');if(q.url==='/api/v1/blocks/tip/height'){r.end('900000');return}r.setHeader('Content-Type','application/json');r.end(JSON.stringify({url:q.url,method:q.method,body:b,cookie:q.headers.cookie||null,auth:q.headers.authorization||null}))})}).listen(8999,'0.0.0.0')"""
|
||||
script="""const server=require('http').createServer((q,r)=>{let b='';q.on('data',x=>b+=x);q.on('end',()=>{r.setHeader('Access-Control-Allow-Origin','https://wrong.example');if(q.url==='/api/v1/blocks/tip/height'){r.end('900000');return}r.setHeader('Content-Type','application/json');r.end(JSON.stringify({url:q.url,method:q.method,body:b,cookie:q.headers.cookie||null,auth:q.headers.authorization||null}))})});server.on('upgrade',(q,s)=>{if(q.url!=='/api/v1/ws'||q.headers.cookie||q.headers.authorization){s.destroy();return}const accept=require('crypto').createHash('sha1').update(q.headers['sec-websocket-key']+'258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest('base64');s.end('HTTP/1.1 101 Switching Protocols\\r\\nUpgrade: websocket\\r\\nConnection: Upgrade\\r\\nSec-WebSocket-Accept: '+accept+'\\r\\n\\r\\n'+String.fromCharCode(129,11)+'fixture-tip','latin1')});server.listen(8999,'0.0.0.0')"""
|
||||
frontend_script="""require('http').createServer((q,r)=>{if(q.headers.cookie||q.headers.authorization){r.writeHead(500);r.end('credential leak');return}if(q.url==='/asset.js'){r.setHeader('Content-Type','application/javascript');r.end('window.explorer=true');return}if(q.url==='/'||q.url==='/tx/fixture'){r.setHeader('Content-Type','text/html');r.end('<html>Mempool explorer fixture</html>');return}r.writeHead(404);r.end('not found')}).listen(8080,'0.0.0.0')"""
|
||||
def start_frontend():run('podman','run','-d','--name',frontend,'--network',net,'--network-alias','mempool','--cap-drop=all','--security-opt=no-new-privileges','docker.io/library/node:24-alpine','node','-e',frontend_script)
|
||||
def start_backend():run('podman','run','-d','--name',backend,'--network',net,'--network-alias','mempool-api','--cap-drop=all','--security-opt=no-new-privileges','docker.io/library/node:24-alpine','node','-e',script)
|
||||
def ready(seconds=40):
|
||||
end=time.monotonic()+seconds
|
||||
@@ -31,8 +34,9 @@ def ready(seconds=40):
|
||||
assert subprocess.run(['podman','network','exists',net]).returncode==1
|
||||
run('podman','network','create',net)
|
||||
try:
|
||||
start_backend()
|
||||
run('podman','run','-d','--name',gateway,'--network',net,'--read-only','--cap-drop=all','--security-opt=no-new-privileges','--memory','128m','--health-cmd','wget -q -T 5 -O /dev/null '+shlex.quote(health_url),'--health-interval','5s','--health-retries','2','-p','127.0.0.1:19098:8080','source.archipelago-foundation.org/chaum/angor-indexer:1.0.1')
|
||||
start_backend();start_frontend()
|
||||
run('podman','run','-d','--name',gateway,'--network',net,'--read-only','--cap-drop=all','--security-opt=no-new-privileges','--memory','128m','--health-cmd','wget -q -T 5 -O /dev/null '+shlex.quote(health_url),'--health-interval','5s','--health-retries','2','-p','127.0.0.1::8080',manifest['container']['image'])
|
||||
port=int(json.loads(run('podman','inspect',gateway))[0]['NetworkSettings']['Ports']['8080/tcp'][0]['HostPort'])
|
||||
ready()
|
||||
run('podman','healthcheck','run',gateway)
|
||||
assert json.loads(run('podman','inspect',gateway))[0]['State']['Health']['Status']=='healthy'
|
||||
@@ -48,6 +52,29 @@ try:
|
||||
assert req('/api/v1/tx',method='OPTIONS')[0]==204
|
||||
assert req('/api/v1/tx',b'x'*(4*1024*1024+1))[0]==413
|
||||
assert req('/unknown')[0]==404
|
||||
for path in ['/', '/tx/fixture', '/asset.js']:
|
||||
status,headers,body=req(path,headers={'Cookie':'private=secret','Authorization':'Bearer secret'})
|
||||
assert status==200 and (b'explorer' in body), (path,status,body)
|
||||
assert req('/',b'not-allowed')[0]==403
|
||||
with socket.create_connection(('127.0.0.1',port),timeout=10) as stream:
|
||||
stream.sendall(b'GET /api/v1/ws HTTP/1.1\r\nHost: indexer.example\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Version: 13\r\nSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==\r\nCookie: private=secret\r\nAuthorization: Bearer secret\r\n\r\n')
|
||||
response=b''
|
||||
while b'fixture-tip' not in response:
|
||||
chunk=stream.recv(4096)
|
||||
if not chunk:break
|
||||
response+=chunk
|
||||
assert b'101 Switching Protocols' in response and b'fixture-tip' in response,response
|
||||
print('PASS shared explorer root/assets/deep links and real WebSocket upgrade/frame; credentials stripped',flush=True)
|
||||
run('podman','stop',frontend)
|
||||
assert req('/')[0]==503
|
||||
assert req('/health')[0]==200
|
||||
run('podman','rm',frontend);start_frontend()
|
||||
end=time.monotonic()+40
|
||||
while time.monotonic()<end:
|
||||
if req('/')[0]==200:break
|
||||
time.sleep(1)
|
||||
else:raise RuntimeError('Frontend recreation did not recover')
|
||||
print('PASS frontend outage is explicit; API stays available; frontend DNS recreation recovers',flush=True)
|
||||
d=json.loads(run('podman','inspect',gateway))[0];assert d['Config']['User']=='101:101' and not d['BoundingCaps']
|
||||
print('PASS API paths/query/body, transaction-only POST, method/size limits, CORS, credential stripping and unprivileged read-only image',flush=True)
|
||||
run('podman','stop',backend)
|
||||
@@ -58,5 +85,5 @@ except BaseException:
|
||||
subprocess.run(['podman','logs','--tail','15',gateway],check=False)
|
||||
raise
|
||||
finally:
|
||||
for name in [gateway,backend]:subprocess.run(['podman','rm','-f','--time','3',name],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
for name in [gateway,backend,frontend]:subprocess.run(['podman','rm','-f','--time','3',name],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
subprocess.run(['podman','network','rm',net],stdout=subprocess.DEVNULL,stderr=subprocess.DEVNULL)
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
#!/usr/bin/env node
|
||||
/* Opt-in, read-only public acceptance. Uses a disposable browser profile; no wallet
|
||||
* actions, transaction broadcast, relay publication, or browser TLS bypass. */
|
||||
const assert = require('node:assert/strict');
|
||||
const { createHash } = require('node:crypto');
|
||||
const path = require('node:path');
|
||||
const { createRequire } = require('node:module');
|
||||
const requireUi = createRequire(path.resolve(__dirname, '../../neode-ui/package.json'));
|
||||
const { chromium } = requireUi('playwright');
|
||||
const WebSocket = requireUi('ws');
|
||||
|
||||
const indexer = process.env.ANGOR_TEST_INDEXER;
|
||||
const relay = process.env.ANGOR_TEST_RELAY;
|
||||
const projectId = 'angor1qryhse38vcyqnp0j6976q9f00a9jpj2ary03nlc';
|
||||
const address = 'bc1qryhse38vcyqnp0j6976q9f00a9jpj2ar62vfzd';
|
||||
const fundingTx = '72d14227b78a260c9410a65585cb49e81e35eaa95b1b23ec702eb0512ca016a7';
|
||||
const eventId = 'adbf94d6152097f3d503cd68cc00dee34c1e1007914c00cfc3d6698d1ee01834';
|
||||
const relays = JSON.parse(process.env.ANGOR_TEST_RELAYS || JSON.stringify([relay]));
|
||||
const app = process.env.ANGOR_TEST_APP || 'https://angor.io';
|
||||
assert(indexer && new URL(indexer).protocol === 'https:', 'Set ANGOR_TEST_INDEXER to an HTTPS origin');
|
||||
assert(relay && new URL(relay).protocol === 'wss:', 'Set ANGOR_TEST_RELAY to a WSS origin');
|
||||
assert(relays.some(url => new URL(url).href === new URL(relay).href),
|
||||
'Discovery relays must include the relay under test');
|
||||
|
||||
async function read(endpoint, json = true) {
|
||||
const response = await fetch(new URL(endpoint, indexer), { signal: AbortSignal.timeout(15000) });
|
||||
assert.equal(response.status, 200, `${endpoint}: ${response.status}`);
|
||||
return json ? response.json() : response.text();
|
||||
}
|
||||
function relayEvent() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ws = new WebSocket(relay, { handshakeTimeout: 15000, origin: new URL(app).origin });
|
||||
const timer = setTimeout(() => finish(Error('Relay project event timeout')), 15000);
|
||||
function finish(err, value) {
|
||||
clearTimeout(timer);
|
||||
ws.removeAllListeners();
|
||||
ws.on('error', () => {});
|
||||
ws.close();
|
||||
err ? reject(err) : resolve(value);
|
||||
}
|
||||
ws.on('error', err => finish(err));
|
||||
ws.on('open', () => ws.send(JSON.stringify(['REQ', 'acceptance', { ids: [eventId] }])));
|
||||
ws.on('message', raw => {
|
||||
try {
|
||||
const m = JSON.parse(raw.toString());
|
||||
if (m[0] === 'EVENT' && m[1] === 'acceptance') finish(null, m[2]);
|
||||
if (m[0] === 'EOSE') finish(Error('Relay lacks the known signed project fixture; provision public history first'));
|
||||
if (m[0] === 'CLOSED') finish(Error(`Relay subscription rejected: ${m[2]}`));
|
||||
} catch (err) { finish(err); }
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
(async () => {
|
||||
assert.match((await read('/health', false)).trim(), /^\d+$/);
|
||||
assert(Number.isFinite((await read('/api/v1/fees/recommended')).fastestFee));
|
||||
const txs = await read(`/api/v1/address/${address}/txs`);
|
||||
const tx = txs.find(t => t.txid === fundingTx);
|
||||
assert(tx?.status.confirmed, 'Known project funding transaction missing');
|
||||
const event = await relayEvent();
|
||||
assert.equal(event.id, eventId);
|
||||
assert.equal(event.kind, 3030);
|
||||
assert.equal(createHash('sha256').update(JSON.stringify([0, event.pubkey, event.created_at,
|
||||
event.kind, event.tags, event.content])).digest('hex'), eventId);
|
||||
assert(tx.vout.some(v => v.scriptpubkey_type === 'op_return' && v.scriptpubkey.includes(eventId)),
|
||||
'Project announcement must match the immutable funding commitment');
|
||||
assert.equal(JSON.parse(event.content).projectIdentifier, projectId);
|
||||
// This checks a known immutable fixture's hash/chain binding, not a general
|
||||
// Schnorr verifier. Signed publish and invalid-signature rejection are separate gates.
|
||||
console.log('PASS public TLS, indexed funding data, relay WSS, immutable project/event commitment');
|
||||
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
const failures = [];
|
||||
let browserIndexerRead = false;
|
||||
page.on('requestfailed', r => {
|
||||
if (r.url().startsWith(new URL(indexer).origin)) failures.push(`${r.url()}: ${r.failure()?.errorText}`);
|
||||
});
|
||||
page.on('response', r => {
|
||||
if (r.url().startsWith(new URL(indexer).origin) && r.url().includes(`/address/${address}/txs`) && r.status() === 200)
|
||||
browserIndexerRead = true;
|
||||
});
|
||||
await page.addInitScript(({ indexer, relays }) => {
|
||||
localStorage.setItem('angor-network', 'main');
|
||||
localStorage.setItem('angor-indexers', JSON.stringify({ mainnet: [{ url: indexer.replace(/\/?$/, '/'), isPrimary: true }], testnet: [] }));
|
||||
localStorage.setItem('angor-hub-relays', JSON.stringify(relays));
|
||||
}, { indexer, relays });
|
||||
await page.goto(new URL('/explore', app).href, { waitUntil: 'domcontentloaded' });
|
||||
const link = page.locator(`a[href*="/project/${projectId}"]`).first();
|
||||
await link.waitFor({ state: 'visible', timeout: 60000 });
|
||||
assert(browserIndexerRead, 'Browser did not validate the project using the configured indexer');
|
||||
assert.deepEqual(failures, [], 'Browser indexer/CORS requests failed');
|
||||
console.log('PASS official Explore page discovers the known project with the configured services');
|
||||
await page.goto(new URL(`/project/${projectId}`, app).href, { waitUntil: 'domcontentloaded' });
|
||||
await page.getByText(projectId, { exact: true }).first().waitFor({ state: 'visible', timeout: 60000 });
|
||||
await page.getByText('Project Statistics', { exact: true }).waitFor({ state: 'visible', timeout: 30000 });
|
||||
assert.deepEqual(failures, [], 'Browser detail/indexer requests failed');
|
||||
console.log('PASS official project detail and statistics render; no funds sent');
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
})().catch(err => { console.error(err); process.exitCode = 1; });
|
||||
@@ -19,6 +19,13 @@ class ServiceMetadata(unittest.TestCase):
|
||||
self.assertEqual(app['ports'][0]['bind'], '127.0.0.1')
|
||||
self.assertEqual(app['security']['capabilities'], [])
|
||||
|
||||
def test_indexer_reuses_both_existing_mempool_components(self):
|
||||
app = yaml.safe_load((ROOT / 'apps/angor-indexer/manifest.yml').read_text())['app']
|
||||
self.assertEqual(set(app['install_prerequisites']), {'mempool', 'mempool-api'})
|
||||
self.assertTrue({'mempool', 'mempool-api'} <= {
|
||||
dep['app_id'] for dep in app['dependencies'] if 'app_id' in dep})
|
||||
self.assertEqual(app.get('volumes', []), [])
|
||||
|
||||
def test_indexer_health_targets_ipv4_listener(self):
|
||||
app = yaml.safe_load((ROOT / 'apps/angor-indexer/manifest.yml').read_text())['app']
|
||||
self.assertEqual(app['health_check']['endpoint'], 'http://127.0.0.1:8080')
|
||||
|
||||
Reference in New Issue
Block a user