#!/usr/bin/env node // Location sink: a tiny HTTP endpoint the phone POSTs its coordinates to. // // Why this exists: reading the phone's position from Home Assistant only works // while HA is up and on the LAN. When HA is down (or the LAN is), the avatar // silently falls back to home coords, so travelling shows neither the right // flag nor the right weather. The phone can always reach the NAS over the // always-on WireGuard tunnel, so let it push instead. // // Speaks OwnTracks' HTTP mode (the payload it posts is `{_type: "location", // lat, lon, tst}`) and also accepts a plain `{lat, lon}` body, so a Shortcut, // Tasker task, or curl works just as well. // // Coordinates are rounded to `coordPrecision` decimals (~1 km at 2) before // they're written to disk: enough for weather and country, not your address. // // POST / -> record a position (auth required) // GET /healthz -> "ok" (no auth), for uptime checks // GET /location -> the currently stored position (auth required) import { createServer } from 'node:http'; import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { loadConfig, ROOT } from './config.js'; import { SINK_FILE } from './location.js'; loadDotEnv(); const cfg = loadConfig(); const token = process.env.LOC_SINK_TOKEN || ''; const port = Number(process.env.LOC_SINK_PORT || cfg.settings.sinkPort || 8477); const dp = cfg.settings.coordPrecision; if (!token) { console.error('[sink] refusing to start: LOC_SINK_TOKEN is not set'); process.exit(1); } const round = (n) => Number(n.toFixed(dp)); // Bearer token, `?token=`, or HTTP Basic (OwnTracks only offers user/password). // Returns { ok, scheme, presented } so a rejection can say what was tried — // a silent 401 is very hard to debug from a phone. function authorised(req, url) { const header = req.headers.authorization || ''; if (header.startsWith('Bearer ')) { const presented = header.slice(7); return { ok: safeEqual(presented, token), scheme: 'bearer', presented }; } if (header.startsWith('Basic ')) { const decoded = Buffer.from(header.slice(6), 'base64').toString('utf8'); const presented = decoded.slice(decoded.indexOf(':') + 1); return { ok: safeEqual(presented, token), scheme: 'basic', presented }; } const q = url.searchParams.get('token'); if (q) return { ok: safeEqual(q, token), scheme: 'query', presented: q }; return { ok: false, scheme: 'none', presented: '' }; } // Describe a rejected credential without printing it: length plus a short // prefix is enough to spot a truncated paste, a stray space, or a blank field. function describe({ scheme, presented }) { if (scheme === 'none') return 'no credential sent (no Authorization header, no ?token=)'; const shape = presented ? `${presented.length} chars, starts "${presented.slice(0, 4)}"` : 'empty'; return `${scheme} credential did not match (${shape}; expected ${token.length} chars)`; } // Length-independent comparison that doesn't leak the token by timing. function safeEqual(a, b) { if (a.length !== b.length) return false; let diff = 0; for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); return diff === 0; } // Accept OwnTracks (`lat`/`lon`/`tst`) and plain `{latitude, longitude}` bodies. function parsePosition(body) { if (!body || typeof body !== 'object') return null; if (body._type && body._type !== 'location') return null; // ignore transitions, waypoints, … const lat = num(body.lat ?? body.latitude); const lon = num(body.lon ?? body.lng ?? body.longitude); if (lat === null || lon === null) return null; if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null; const ts = typeof body.tst === 'number' ? new Date(body.tst * 1000).toISOString() : new Date().toISOString(); return { lat: round(lat), lon: round(lon), ts, device: typeof body.tid === 'string' ? body.tid : 'unknown', via: body._type === 'location' ? 'owntracks' : 'http', }; } const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : null); function readBody(req, limitBytes = 64 * 1024) { return new Promise((resolve, reject) => { let size = 0; const chunks = []; req.on('data', (c) => { size += c.length; if (size > limitBytes) { reject(new Error('body too large')); req.destroy(); return; } chunks.push(c); }); req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8'))); req.on('error', reject); }); } const json = (res, code, payload) => { const body = JSON.stringify(payload); res.writeHead(code, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) }); res.end(body); }; const server = createServer(async (req, res) => { const url = new URL(req.url, 'http://localhost'); if (req.method === 'GET' && url.pathname === '/healthz') { res.writeHead(200, { 'content-type': 'text/plain' }); res.end('ok\n'); return; } const auth = authorised(req, url); if (!auth.ok) { console.error(`[sink] 401 ${req.method} ${url.pathname} — ${describe(auth)}`); json(res, 401, { error: 'unauthorised' }); return; } if (req.method === 'GET' && url.pathname === '/location') { if (!existsSync(SINK_FILE)) return json(res, 404, { error: 'no position recorded yet' }); res.writeHead(200, { 'content-type': 'application/json' }); res.end(readFileSync(SINK_FILE, 'utf8')); return; } if (req.method !== 'POST') { json(res, 405, { error: 'method not allowed' }); return; } let body; try { body = JSON.parse(await readBody(req)); } catch (e) { json(res, 400, { error: `bad body: ${e.message}` }); return; } const position = parsePosition(body); if (!position) { // OwnTracks also posts non-location messages; acknowledge and drop them. json(res, 200, []); return; } mkdirSync(dirname(SINK_FILE), { recursive: true }); writeFileSync(SINK_FILE, JSON.stringify(position, null, 2)); console.error(`[sink] ${position.via} ${position.device} -> ${position.lat},${position.lon} @ ${position.ts}`); // OwnTracks expects a JSON array back (it treats objects in it as commands). json(res, 200, []); }); // OkHttp (which OwnTracks uses) pools connections and reuses them well after // Node's 5s default idle timeout, which surfaces on the phone as "unexpected end // of stream". Outlive the client's idle window instead. server.keepAliveTimeout = 65_000; server.headersTimeout = 70_000; server.listen(port, '0.0.0.0', () => { console.error(`[sink] listening on 0.0.0.0:${port}, writing ${SINK_FILE} (coords rounded to ${dp} dp)`); }); // Minimal .env loader, mirroring index.js (no dependency, never overrides real env). function loadDotEnv() { const path = join(ROOT, '.env'); if (!existsSync(path)) return; for (const line of readFileSync(path, 'utf8').split('\n')) { const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i); if (!m) continue; const key = m[1]; const val = m[2].trim().replace(/^["']|["']$/g, ''); if (!(key in process.env)) process.env[key] = val; } }