mirror of
https://github.com/gadgethd/ukmesh.git
synced 2026-10-22 22:11:31 +00:00
fix(api): allowlist public node status fields
This commit is contained in:
1 parent
d44ef5e572
commit
865b5ddb2d
6 files changed
+195
-28
No files matched your search
@@ -0,0 +1,62 @@
|
||||
/** Approved fields for the unauthenticated node status response contracts. */
|
||||
export type PublicStatusCounters = {
|
||||
battery_mv: number | null;
|
||||
uptime_secs: number | null;
|
||||
tx_air_secs: number | null;
|
||||
rx_air_secs: number | null;
|
||||
channel_utilization: number | null;
|
||||
air_util_tx: number | null;
|
||||
};
|
||||
|
||||
export type PublicLatestNodeStatusDTO = PublicStatusCounters & {
|
||||
time: string;
|
||||
node_id: string;
|
||||
network: string | null;
|
||||
name: string | null;
|
||||
iata: string | null;
|
||||
};
|
||||
|
||||
export type PublicMqttNodeStatusDTO = PublicStatusCounters & {
|
||||
node_id: string;
|
||||
name: string | null;
|
||||
last_seen: string;
|
||||
packets_24h: string;
|
||||
};
|
||||
|
||||
type PublicStatusCounterSource = Partial<PublicStatusCounters>;
|
||||
|
||||
function publicStatusCounters(row: PublicStatusCounterSource): PublicStatusCounters {
|
||||
return {
|
||||
battery_mv: row.battery_mv ?? null,
|
||||
uptime_secs: row.uptime_secs ?? null,
|
||||
tx_air_secs: row.tx_air_secs ?? null,
|
||||
rx_air_secs: row.rx_air_secs ?? null,
|
||||
channel_utilization: row.channel_utilization ?? null,
|
||||
air_util_tx: row.air_util_tx ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export function toPublicLatestNodeStatusDTO(
|
||||
row: PublicLatestNodeStatusDTO,
|
||||
): PublicLatestNodeStatusDTO {
|
||||
return {
|
||||
time: row.time,
|
||||
node_id: row.node_id,
|
||||
network: row.network,
|
||||
name: row.name,
|
||||
iata: row.iata,
|
||||
...publicStatusCounters(row),
|
||||
};
|
||||
}
|
||||
|
||||
export function toPublicMqttNodeStatusDTO(
|
||||
row: PublicMqttNodeStatusDTO,
|
||||
): PublicMqttNodeStatusDTO {
|
||||
return {
|
||||
node_id: row.node_id,
|
||||
name: row.name,
|
||||
last_seen: row.last_seen,
|
||||
...publicStatusCounters(row),
|
||||
packets_24h: row.packets_24h,
|
||||
};
|
||||
}
|
||||
@@ -37,3 +37,64 @@ test('channel feed history route forwards its bounded channel and scope', async
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
|
||||
test('public MQTT node status omits owner-only and unknown telemetry fields', async () => {
|
||||
const storedRow = {
|
||||
node_id: 'b'.repeat(64),
|
||||
name: 'Public MQTT node',
|
||||
last_seen: '2026-09-23T12:00:00.000Z',
|
||||
battery_mv: 3900,
|
||||
uptime_secs: 4200,
|
||||
channel_utilization: 8,
|
||||
air_util_tx: 4,
|
||||
rx_air_secs: 18,
|
||||
tx_air_secs: 9,
|
||||
packets_24h: '12',
|
||||
hardware_model: 'private model',
|
||||
firmware_version: 'private firmware',
|
||||
stats: {
|
||||
wifi_ssid: 'private wifi',
|
||||
reset_reason: 'private reset detail',
|
||||
fs_free_bytes: 1234,
|
||||
mqtt: { broker_uri: 'mqtts://private', broker_username: 'private-user' },
|
||||
future_owner_field: 'private future value',
|
||||
},
|
||||
future_status_field: 'private future value',
|
||||
};
|
||||
const router = Router();
|
||||
registerMiscRoutes(router, {
|
||||
query: async <T extends import('pg').QueryResultRow = import('pg').QueryResultRow>(text) => {
|
||||
assert.doesNotMatch(text, /nss\.stats/);
|
||||
return { rows: [storedRow as unknown as T] };
|
||||
},
|
||||
getRecentPackets: async () => [],
|
||||
getRecentPacketEvents: async () => [],
|
||||
getPacketDetail: async () => null,
|
||||
getChannelMessageHistory: async () => [],
|
||||
getPublicVisibilityGeneration: async () => 1,
|
||||
packetDetailLimiter: passThroughLimiter as Parameters<typeof registerMiscRoutes>[1]['packetDetailLimiter'],
|
||||
});
|
||||
const app = express();
|
||||
app.use(router);
|
||||
const server = app.listen(0, '127.0.0.1');
|
||||
await once(server, 'listening');
|
||||
try {
|
||||
const { port } = server.address() as AddressInfo;
|
||||
const response = await fetch(`http://127.0.0.1:${port}/mqtt-nodes?network=ukmesh`);
|
||||
assert.equal(response.status, 200);
|
||||
assert.deepEqual(await response.json(), [{
|
||||
node_id: storedRow.node_id,
|
||||
name: storedRow.name,
|
||||
last_seen: storedRow.last_seen,
|
||||
battery_mv: storedRow.battery_mv,
|
||||
uptime_secs: storedRow.uptime_secs,
|
||||
channel_utilization: storedRow.channel_utilization,
|
||||
air_util_tx: storedRow.air_util_tx,
|
||||
rx_air_secs: storedRow.rx_air_secs,
|
||||
tx_air_secs: storedRow.tx_air_secs,
|
||||
packets_24h: storedRow.packets_24h,
|
||||
}]);
|
||||
} finally {
|
||||
server.close();
|
||||
}
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
decodePlannedNodeCursor,
|
||||
listPublicPlannedNodes,
|
||||
} from '../../repositories/plannedNodes.js';
|
||||
import { toPublicMqttNodeStatusDTO } from '../publicStatus.js';
|
||||
|
||||
type QueryFn = <T extends QueryResultRow = QueryResultRow>(
|
||||
text: string,
|
||||
@@ -218,7 +219,6 @@ export function registerMiscRoutes(router: Router, deps: MiscRouteDeps): void {
|
||||
air_util_tx: number | null;
|
||||
rx_air_secs: number | null;
|
||||
tx_air_secs: number | null;
|
||||
stats: Record<string, unknown> | null;
|
||||
packets_24h: string;
|
||||
}>(
|
||||
|
||||
@@ -232,7 +232,6 @@ export function registerMiscRoutes(router: Router, deps: MiscRouteDeps): void {
|
||||
nss.air_util_tx,
|
||||
nss.rx_air_secs,
|
||||
nss.tx_air_secs,
|
||||
nss.stats,
|
||||
COALESCE(pc.packet_count, 0) AS packets_24h
|
||||
FROM node_identity_status_samples nss
|
||||
LEFT JOIN node_identity_nodes n ON n.node_id = nss.node_id
|
||||
@@ -250,7 +249,9 @@ export function registerMiscRoutes(router: Router, deps: MiscRouteDeps): void {
|
||||
ORDER BY nss.node_id, COALESCE(nss.uptime_secs, 0) DESC, nss.time DESC`,
|
||||
params,
|
||||
);
|
||||
res.json(result.rows.filter((r) => Number(r.packets_24h) > 0));
|
||||
res.json(result.rows
|
||||
.filter((r) => Number(r.packets_24h) > 0)
|
||||
.map(toPublicMqttNodeStatusDTO));
|
||||
} catch (err) {
|
||||
console.error('[api] GET /mqtt-nodes', (err as Error).message);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { toPublicLatestNodeStatusDTO } from '../publicStatus.js';
|
||||
|
||||
test('latest node status DTO omits owner-only and unknown telemetry fields', () => {
|
||||
const storedRow = {
|
||||
time: '2026-09-23T12:00:00.000Z',
|
||||
node_id: 'a'.repeat(64),
|
||||
network: 'ukmesh',
|
||||
name: 'Public node',
|
||||
iata: 'NCL',
|
||||
battery_mv: 3910,
|
||||
uptime_secs: 7200,
|
||||
tx_air_secs: 14,
|
||||
rx_air_secs: 28,
|
||||
channel_utilization: 7,
|
||||
air_util_tx: 3,
|
||||
hardware_model: 'private model',
|
||||
firmware_version: 'private firmware',
|
||||
stats: {
|
||||
wifi_ssid: 'private wifi',
|
||||
reset_reason: 'private reset detail',
|
||||
fs_free_bytes: 1234,
|
||||
mqtt: { broker_uri: 'mqtts://private', broker_username: 'private-user' },
|
||||
future_owner_field: 'private future value',
|
||||
},
|
||||
future_status_field: 'private future value',
|
||||
};
|
||||
|
||||
assert.deepEqual(toPublicLatestNodeStatusDTO(storedRow), {
|
||||
time: storedRow.time,
|
||||
node_id: storedRow.node_id,
|
||||
network: storedRow.network,
|
||||
name: storedRow.name,
|
||||
iata: storedRow.iata,
|
||||
battery_mv: storedRow.battery_mv,
|
||||
uptime_secs: storedRow.uptime_secs,
|
||||
tx_air_secs: storedRow.tx_air_secs,
|
||||
rx_air_secs: storedRow.rx_air_secs,
|
||||
channel_utilization: storedRow.channel_utilization,
|
||||
air_util_tx: storedRow.air_util_tx,
|
||||
});
|
||||
});
|
||||
@@ -7,6 +7,10 @@ import {
|
||||
parseBoundedInteger,
|
||||
parseBoundedString,
|
||||
} from '../utils/input.js';
|
||||
import {
|
||||
toPublicLatestNodeStatusDTO,
|
||||
type PublicLatestNodeStatusDTO,
|
||||
} from '../publicStatus.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
@@ -26,28 +30,11 @@ router.get('/node-status/latest', async (req, res) => {
|
||||
}
|
||||
|
||||
const identityWhereClause = `WHERE ${identityConditions.join(' AND ')}`;
|
||||
const result = await query<{
|
||||
time: string;
|
||||
node_id: string;
|
||||
network: string | null;
|
||||
battery_mv: number | null;
|
||||
uptime_secs: number | null;
|
||||
tx_air_secs: number | null;
|
||||
rx_air_secs: number | null;
|
||||
channel_utilization: number | null;
|
||||
air_util_tx: number | null;
|
||||
stats: Record<string, unknown> | null;
|
||||
name: string | null;
|
||||
iata: string | null;
|
||||
hardware_model: string | null;
|
||||
firmware_version: string | null;
|
||||
}>(
|
||||
const result = await query<PublicLatestNodeStatusDTO>(
|
||||
`WITH identity_sources AS MATERIALIZED (
|
||||
SELECT n.node_id AS canonical_id,
|
||||
n.name,
|
||||
n.iata,
|
||||
n.hardware_model,
|
||||
n.firmware_version,
|
||||
source.source_node_id
|
||||
FROM node_identity_nodes n
|
||||
CROSS JOIN LATERAL unnest(n.identity_source_ids) AS source(source_node_id)
|
||||
@@ -63,15 +50,12 @@ router.get('/node-status/latest', async (req, res) => {
|
||||
nss.rx_air_secs,
|
||||
nss.channel_utilization,
|
||||
nss.air_util_tx,
|
||||
nss.stats,
|
||||
s.name,
|
||||
s.iata,
|
||||
s.hardware_model,
|
||||
s.firmware_version
|
||||
s.iata
|
||||
FROM identity_sources s
|
||||
JOIN LATERAL (
|
||||
SELECT time, network, battery_mv, uptime_secs, tx_air_secs,
|
||||
rx_air_secs, channel_utilization, air_util_tx, stats
|
||||
rx_air_secs, channel_utilization, air_util_tx
|
||||
FROM node_status_samples
|
||||
WHERE node_id = s.source_node_id
|
||||
AND network = ANY($1::text[])
|
||||
@@ -85,7 +69,7 @@ router.get('/node-status/latest', async (req, res) => {
|
||||
params,
|
||||
);
|
||||
|
||||
res.json(result.rows);
|
||||
res.json(result.rows.map(toPublicLatestNodeStatusDTO));
|
||||
} catch (err) {
|
||||
console.error('[api] GET /node-status/latest', (err as Error).message);
|
||||
res.status(500).json({ error: 'Internal server error' });
|
||||
|
||||
+17
-1
@@ -64,7 +64,23 @@ The service stores radio traffic observed on the MeshCore network.
|
||||
deletion procedure in the ops playbook (rows by src/rx node id across
|
||||
packets, packet_paths, decryptions, path facts).
|
||||
|
||||
## 3. Cookies
|
||||
## 3. Device status telemetry
|
||||
|
||||
Status samples retain a JSON telemetry object for the authenticated owner
|
||||
portal. The unauthenticated `/node-status/latest` and `/mqtt-nodes` APIs expose
|
||||
only their explicit public fields: node identity, sample/availability data,
|
||||
battery, uptime, airtime, channel utilization, and recent packet counts. They
|
||||
do not return the telemetry object or forward unrecognized fields. Wi-Fi
|
||||
identifiers, MQTT broker/account details, reset and configuration fields,
|
||||
firmware metadata, and filesystem details are available only through
|
||||
owner-authenticated endpoints. Unrecognized keys stay in storage until they
|
||||
are deliberately added to a response contract.
|
||||
|
||||
Existing status samples do not need a database rewrite: public responses are
|
||||
projected through the same field allowlist regardless of when the sample was
|
||||
stored.
|
||||
|
||||
## 4. Cookies
|
||||
|
||||
LocalStorage-only choices (cookie consent, theme). No third-party cookies.
|
||||
PECR does not apply to the mesh radio data (not a public electronic
|
||||
|
||||
Reference in new issue
Block a user