infra/stacks/owntracks/dawarich-hook.lua
Viktor Barzin 602103ede1 [owntracks] Strip face avatar from hook payload + drop orphan PVC
Bundles two small follow-ups to the live bridge + port-fix work:

## Face avatar fix (dawarich-hook.lua)

After the Recorder ran in production for a while it began enriching
publish payloads with a `face` field — the base64-encoded user avatar
uploaded via the Recorder's web UI (~120 KB). Our Lua hook builds a
curl command that embeds the JSON payload as `-d '<payload>'`, which
hit `E2BIG` / `Argument list too long` (os.execute reason=code=7) on
Linux's `execve` argv limit (~128 KB). Every live POST stopped making
it to Dawarich, even though the HTTP POST from the phone to Owntracks
still returned 200 and the .rec write still happened.

Fix: `data.face = nil` before serializing. Dawarich doesn't use it
anyway (not persisted into any column — `raw_data` stored without it).

Also upgraded the debug log: on failure we now emit
`dawarich-bridge: FAIL tst=... reason=... code=... cmd=...` so any
future variant of this problem (next big field surfaced upstream, etc.)
is one log tail away from a diagnosis.

```
$ kubectl -n owntracks logs deploy/owntracks --tail=5 | grep dawarich-bridge
+ dawarich-bridge: init
+ dawarich-bridge: ok tst=1776600238
```

## Orphan PVC removal (main.tf)

`owntracks-data-proxmox` (1 Gi, proxmox-lvm, unencrypted) was a leftover
from the encrypted-migration attempt; the Deployment has been mounting
`owntracks-data-encrypted` the whole time. Verified `Used By: <none>`
on the live PVC before removal. Removing the resource from Terraform
destroys the PVC — harmless, no data loss.

## Test Plan

### Automated

```
$ ../../scripts/tg plan
Plan: 0 to add, 1 to change, 1 to destroy.

$ ../../scripts/tg apply --non-interactive
Apply complete! Resources: 0 added, 1 changed, 1 destroyed.

$ kubectl -n owntracks get pvc
NAME                       STATUS   VOLUME ...
owntracks-data-encrypted   Bound    ...
(owntracks-data-proxmox gone)
```

### Manual Verification

```
$ VIKTOR_PW=$(vault kv get -field=credentials secret/owntracks | jq -r .viktor)
$ TST=$(date +%s)
$ kubectl -n owntracks run t --rm -i --image=curlimages/curl -- \
    curl -s -w 'HTTP %{http_code}\n' -X POST -u "viktor:$VIKTOR_PW" \
    -H 'Content-Type: application/json' \
    -H 'X-Limit-U: viktor' -H 'X-Limit-D: iphone-15pro' \
    -d "{\"_type\":\"location\",\"lat\":51.5074,\"lon\":-0.1278,\"tst\":$TST,\"tid\":\"vb\"}" \
    https://owntracks.viktorbarzin.me/pub
HTTP 200

$ sleep 3 && kubectl -n dbaas exec pg-cluster-1 -c postgres -- \
    psql -U postgres -d dawarich -tAc \
    "SELECT ST_AsText(lonlat::geometry) FROM points WHERE user_id=1 AND timestamp=$TST"
POINT(-0.1278 51.5074)
```

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 12:05:18 +00:00

82 lines
2.9 KiB
Lua

-- ot-recorder Lua hook: forward every location publish to Dawarich.
-- Loaded by ot-recorder via `--lua-script`. The hook() function is invoked
-- synchronously per publish; we fork curl with `&` to keep it fire-and-forget.
-- Dawarich's points table has UNIQUE (lonlat, timestamp, user_id) — duplicates
-- are safely dropped. The .rec file is always written regardless of hook result,
-- so a Dawarich 5xx loses nothing long-term (re-playable via backfill Job).
local function escape_shell_single(s)
return "'" .. tostring(s):gsub("'", "'\\''") .. "'"
end
local function json_escape_string(s)
return (s:gsub("\\", "\\\\")
:gsub('"', '\\"')
:gsub("\n", "\\n")
:gsub("\r", "\\r")
:gsub("\t", "\\t"))
end
-- Minimal JSON serializer — scalars, arrays, maps. Owntracks payloads are
-- all primitive/flat; no bignum or cyclic-ref concerns.
local function to_json(v)
local t = type(v)
if t == "nil" then return "null" end
if t == "number" then return tostring(v) end
if t == "boolean" then return tostring(v) end
if t == "string" then return '"' .. json_escape_string(v) .. '"' end
if t == "table" then
if #v > 0 or next(v) == nil then
local parts = {}
for i, x in ipairs(v) do parts[i] = to_json(x) end
return "[" .. table.concat(parts, ",") .. "]"
end
local parts = {}
for k, x in pairs(v) do
parts[#parts + 1] = '"' .. json_escape_string(tostring(k)) .. '":' .. to_json(x)
end
return "{" .. table.concat(parts, ",") .. "}"
end
return "null"
end
function otr_init()
otr.log("dawarich-bridge: init")
if not os.getenv("DAWARICH_API_KEY") then
otr.log("dawarich-bridge: WARN DAWARICH_API_KEY unset — hook will skip")
end
end
function otr_exit()
otr.log("dawarich-bridge: exit")
end
function otr_hook(topic, _type, data)
if _type ~= "location" then return end
local api_key = os.getenv("DAWARICH_API_KEY")
if not api_key or api_key == "" then
otr.log("dawarich-bridge: DAWARICH_API_KEY missing — dropping point")
return
end
-- Strip the base64 user avatar: ot-recorder appends a ~120KB `face` field
-- to enriched payloads which pushes the curl command past ARG_MAX (code=7
-- "Argument list too long"). Dawarich doesn't need it.
data.face = nil
local url = "https://dawarich.viktorbarzin.me/api/v1/owntracks/points?api_key=" .. api_key
local payload = to_json(data)
local cmd = table.concat({
"curl -sS -o /dev/null --max-time 5 -X POST",
"-H 'Content-Type: application/json'",
"-d", escape_shell_single(payload),
escape_shell_single(url),
"&",
}, " ")
local ok, reason, code = os.execute(cmd)
if not ok then
otr.log("dawarich-bridge: FAIL tst=" .. tostring(data.tst) ..
" reason=" .. tostring(reason) .. " code=" .. tostring(code) ..
" cmd=" .. cmd)
else
otr.log("dawarich-bridge: ok tst=" .. tostring(data.tst))
end
end