feat(node): self-register derives node url from source IP; App Lab provisioner
A containerised App Lab node can't see its host LAN IP, but its self-register POST is SNAT'd to the host, so /nodes/self-register now derives http://<source-ip>:<port> when url is omitted (explicit url still wins; token defaults to open-lan). Adds deploy/uno-q/provision-node-app.sh to install the ZeroClaw Node app + carry in its runtime bits. Tests updated + derived-url coverage (64 pass). Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
8001e100c3
commit
40bee45842
+16
-5
@@ -150,15 +150,26 @@ export function createApp(opts: AppOptions): Express {
|
|||||||
return res.status(401).json({ error: 'unauthorized' })
|
return res.status(401).json({ error: 'unauthorized' })
|
||||||
}
|
}
|
||||||
const b = req.body ?? {}
|
const b = req.body ?? {}
|
||||||
if (![b.kitId, b.url, b.token, b.claimCode].every((v) => typeof v === 'string' && v)) {
|
// `url` is optional: an App-Lab-containerised node can't see its host LAN IP,
|
||||||
return res.status(400).json({ error: 'kitId, url, token and claimCode are required' })
|
// but its request is SNAT'd to the host, so we derive http://<source-ip>:<port>
|
||||||
|
// from what the server actually sees. An explicit url (e.g. from the shell
|
||||||
|
// self-register) still wins.
|
||||||
|
let url = typeof b.url === 'string' && b.url ? b.url : ''
|
||||||
|
if (!url) {
|
||||||
|
const port = Number.isFinite(Number(b.port)) ? Number(b.port) : 8080
|
||||||
|
const ip = (req.ip ?? '').replace(/^::ffff:/, '') // unwrap IPv4-mapped IPv6
|
||||||
|
if (ip) url = `http://${ip}:${port}`
|
||||||
}
|
}
|
||||||
const r = boards.announce({ kitId: b.kitId, url: b.url, token: b.token, claimCode: b.claimCode })
|
const token = typeof b.token === 'string' && b.token ? b.token : 'open-lan'
|
||||||
|
if (![b.kitId, b.claimCode].every((v) => typeof v === 'string' && v) || !url) {
|
||||||
|
return res.status(400).json({ error: 'kitId and claimCode are required (url derived from source IP if omitted)' })
|
||||||
|
}
|
||||||
|
const r = boards.announce({ kitId: b.kitId, url, token, claimCode: b.claimCode })
|
||||||
if (r.claimed && r.teamId && nodes) {
|
if (r.claimed && r.teamId && nodes) {
|
||||||
await nodes.register({ teamId: r.teamId, url: b.url, token: b.token })
|
await nodes.register({ teamId: r.teamId, url, token })
|
||||||
}
|
}
|
||||||
broadcastUnclaimed()
|
broadcastUnclaimed()
|
||||||
res.status(201).json({ kitId: b.kitId, claimed: r.claimed })
|
res.status(201).json({ kitId: b.kitId, url, claimed: r.claimed })
|
||||||
})
|
})
|
||||||
|
|
||||||
// A participant claims their powered-on board to their team by proving
|
// A participant claims their powered-on board to their team by proving
|
||||||
|
|||||||
@@ -51,13 +51,24 @@ describe('board self-register + claim', () => {
|
|||||||
await selfRegister({}, 'wrong-secret').expect(401)
|
await selfRegister({}, 'wrong-secret').expect(401)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('validates the body', async () => {
|
it('validates the body (kitId + claimCode required)', async () => {
|
||||||
await selfRegister({ token: '' }).expect(400)
|
await selfRegister({ kitId: '' }).expect(400)
|
||||||
|
await selfRegister({ claimCode: '' }).expect(400)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('accepts a board presenting the fleet secret', async () => {
|
it('accepts a board presenting the fleet secret', async () => {
|
||||||
const res = await selfRegister().expect(201)
|
const res = await selfRegister().expect(201)
|
||||||
expect(res.body).toEqual({ kitId: 'KIT-07', claimed: false })
|
expect(res.body).toMatchObject({ kitId: 'KIT-07', url: board.url, claimed: false })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('derives the node url from the source IP when url is omitted', async () => {
|
||||||
|
const res = await request(app)
|
||||||
|
.post('/nodes/self-register')
|
||||||
|
.set('x-fleet-secret', FLEET)
|
||||||
|
.send({ kitId: 'KIT-08', claimCode: '4821', port: 8080 })
|
||||||
|
.expect(201)
|
||||||
|
// supertest connects over loopback → derived host is 127.0.0.1
|
||||||
|
expect(res.body.url).toMatch(/^http:\/\/127\.0\.0\.1:8080$/)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('broadcasts the unclaimed pool and lists it for the instructor', async () => {
|
it('broadcasts the unclaimed pool and lists it for the instructor', async () => {
|
||||||
@@ -166,7 +177,7 @@ describe('board self-register + claim', () => {
|
|||||||
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201)
|
await request(app).post('/claim').send({ kit: 'KIT-07', teamId: 'team-07', code: '418302' }).expect(201)
|
||||||
// board reboots with a new IP + a fresh paired token and re-announces
|
// board reboots with a new IP + a fresh paired token and re-announces
|
||||||
const res = await selfRegister({ url: 'http://192.168.1.9:8080', token: 'zc_new_token' }).expect(201)
|
const res = await selfRegister({ url: 'http://192.168.1.9:8080', token: 'zc_new_token' }).expect(201)
|
||||||
expect(res.body).toEqual({ kitId: 'KIT-07', claimed: true })
|
expect(res.body).toMatchObject({ kitId: 'KIT-07', url: 'http://192.168.1.9:8080', claimed: true })
|
||||||
// the team's binding is refreshed (new url), still online, still not unclaimed
|
// the team's binding is refreshed (new url), still online, still not unclaimed
|
||||||
const list = await request(app).get('/nodes').set('x-access-code', ADMIN).expect(200)
|
const list = await request(app).get('/nodes').set('x-access-code', ADMIN).expect(200)
|
||||||
expect(list.body).toEqual([{ teamId: 'team-07', url: 'http://192.168.1.9:8080', online: true }])
|
expect(list.body).toEqual([{ teamId: 'team-07', url: 'http://192.168.1.9:8080', online: true }])
|
||||||
|
|||||||
Executable
+55
@@ -0,0 +1,55 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# provision-node-app.sh — install the "ZeroClaw Node" App Lab app onto a Uno Q and
|
||||||
|
# carry in its runtime bits (binary + config + .secret_key + token + node env), then
|
||||||
|
# start it. The app itself is secret-free; this script provisions the secrets.
|
||||||
|
#
|
||||||
|
# Usage (board on USB, secrets in env):
|
||||||
|
# export ANTHROPIC_OAUTH_TOKEN=sk-ant-oat01-…
|
||||||
|
# KIT_ID=crimson-otter CLAIM_CODE=4821 FLEET_SECRET=apress2026 \
|
||||||
|
# APESS_URL=http://192.168.x.x:3000 ./deploy/uno-q/provision-node-app.sh
|
||||||
|
#
|
||||||
|
# Env: SERIAL (65301572), NODE_APP_DIR (repo app dir), plus the node-env vars above.
|
||||||
|
set -u
|
||||||
|
SERIAL="${SERIAL:-65301572}"
|
||||||
|
NODE_APP_DIR="${NODE_APP_DIR:-$HOME/projects/zeroclaw/firmware/zeroclaw-node}"
|
||||||
|
DEST=/home/arduino/ArduinoApps/zeroclaw-node
|
||||||
|
S(){ adb -s "$SERIAL" shell "$@"; }
|
||||||
|
ok(){ printf ' \033[32m✓\033[0m %s\n' "$*"; }
|
||||||
|
bad(){ printf ' \033[31m✗\033[0m %s\n' "$*"; }
|
||||||
|
|
||||||
|
adb -s "$SERIAL" get-state >/dev/null 2>&1 || { bad "board $SERIAL not attached"; exit 1; }
|
||||||
|
[ -f "$NODE_APP_DIR/app.yaml" ] || { bad "app not found at $NODE_APP_DIR"; exit 1; }
|
||||||
|
[ -n "${ANTHROPIC_OAUTH_TOKEN:-}" ] || { bad "ANTHROPIC_OAUTH_TOKEN not set"; exit 1; }
|
||||||
|
|
||||||
|
echo "→ pushing the app to $DEST"
|
||||||
|
S "mkdir -p $DEST/bin $DEST/.zeroclaw"
|
||||||
|
adb -s "$SERIAL" push "$NODE_APP_DIR/app.yaml" "$DEST/app.yaml" >/dev/null
|
||||||
|
adb -s "$SERIAL" push "$NODE_APP_DIR/sketch" "$DEST/" >/dev/null
|
||||||
|
adb -s "$SERIAL" push "$NODE_APP_DIR/python" "$DEST/" >/dev/null
|
||||||
|
ok "app files"
|
||||||
|
|
||||||
|
echo "→ carrying in runtime bits (secret-bearing — provisioned, not committed)"
|
||||||
|
# binary: reuse the board's known-good one (already the right arch)
|
||||||
|
S "cp -f /home/arduino/zeroclaw $DEST/bin/zeroclaw && chmod +x $DEST/bin/zeroclaw"
|
||||||
|
# config + enc2 key (the config's secrets are bound to this key)
|
||||||
|
S "cp -f /home/arduino/.zeroclaw/config.toml $DEST/.zeroclaw/config.toml"
|
||||||
|
S "cp -f /home/arduino/.zeroclaw/.secret_key $DEST/.zeroclaw/.secret_key"
|
||||||
|
# cloud token (env-only → written to the board's app dir, never to the repo)
|
||||||
|
printf '%s' "$ANTHROPIC_OAUTH_TOKEN" | S "cat > $DEST/.zeroclaw/oauth_token"
|
||||||
|
# node env (self-register inputs)
|
||||||
|
S "cat > $DEST/.zeroclaw/apess-node.env" <<EOF
|
||||||
|
KIT_ID=${KIT_ID:-node-$SERIAL}
|
||||||
|
CLAIM_CODE=${CLAIM_CODE:-$(( (RANDOM % 9000) + 1000 ))}
|
||||||
|
FLEET_SECRET=${FLEET_SECRET:-}
|
||||||
|
APESS_URL=${APESS_URL:-}
|
||||||
|
GATEWAY_PORT=${GATEWAY_PORT:-8080}
|
||||||
|
EOF
|
||||||
|
ok "binary + config + .secret_key + oauth_token + apess-node.env"
|
||||||
|
|
||||||
|
echo "→ starting the app (compiles+flashes the matrix sketch, launches daemon in-container)"
|
||||||
|
S "cd $DEST && TMPDIR=/tmp timeout 300 arduino-app-cli app start $DEST 2>&1 | tail -4"
|
||||||
|
|
||||||
|
echo "→ (optional) enable Run-at-startup for boot persistence:"
|
||||||
|
echo " adb -s $SERIAL shell 'arduino-app-cli properties set default $DEST'"
|
||||||
|
ok "provisioned. In App Lab, open 'ZeroClaw Node' → Run."
|
||||||
|
</content>
|
||||||
Reference in New Issue
Block a user