sailkick-boat 0.14.3 → 0.14.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -171,6 +171,12 @@ That is the whole handshake. The plugin resolves everything else locally (`bucke
171
171
  `<slug>_raw`, `org` = `sailkick`) and never calls the app for configuration, so setup
172
172
  works with no internet and there is nothing to re-fetch after a restart.
173
173
 
174
+ > **Upgrading from before 0.14?** Old versions had visible `influxUrl` / `sailkickUrl`
175
+ > fields. Whatever was typed into them is still in your saved config, and since 0.14.4
176
+ > those leftovers are **ignored** — the plugin says so in the log and the status line
177
+ > rather than silently obeying a dev address. To keep your own endpoints deliberately,
178
+ > set `sync.selfHosted: true` / `proxy.selfHosted: true` in the config JSON.
179
+ >
174
180
  > **Ignore the "Influx URL", "Organization" and "Bucket" on that screen** — they are for
175
181
  > the community `signalk-to-influxdb-v2` plugin. This plugin always writes to
176
182
  > `https://sync.sailkick.io`; the endpoint is fleet-wide and cannot be set from the UI,
@@ -231,6 +237,32 @@ a VE.Can↔Micro-C drop cable; leave the spare RJ45 unterminated) or a USB GPS/N
231
237
  gateway. Without a position source, the live boat / trends / area-download stay
232
238
  idle — energy telemetry still syncs to the cloud.
233
239
 
240
+ ## Troubleshooting: is telemetry actually leaving the boat?
241
+ The plugin logs one unconditional line at startup naming its real target:
242
+ ```
243
+ [sailkick-boat] sync -> https://sync.sailkick.io org=sailkick bucket=<slug>_raw
244
+ ```
245
+ If that shows anything other than the endpoint you expect, a stale config field is the
246
+ usual cause — see the upgrade note above.
247
+
248
+ Failures reach the **normal server log**, not just the debug channel:
249
+ - `cannot write to <url> — unreachable …; N failed attempt(s), telemetry is buffering
250
+ on disk` — once per outage, then every 5 min while it lasts, then a `recovered` line.
251
+ - `batch REJECTED (HTTP 401) and quarantined …` — the write token is not valid for that
252
+ bucket. These are never retried, so they are always logged.
253
+ - `sync: ⚠ writing to <url> — a private address` — a loopback/RFC1918 target, i.e.
254
+ nothing is reaching the cloud.
255
+
256
+ Nothing at all in the log means the plugin never started; check it is enabled. The
257
+ status line in Plugin Config carries the same information continuously.
258
+
259
+ To prove credentials independently of the plugin:
260
+ ```bash
261
+ curl -i -XPOST "https://sync.sailkick.io/api/v2/write?org=sailkick&bucket=<slug>_raw&precision=ns" \
262
+ -H "Authorization: Token $TOKEN" --data-binary "probe,context=vessels.self,self=true,source=manual value=1"
263
+ ```
264
+ `204` = good, `401` = token not valid for that bucket.
265
+
234
266
  ## Dev / tests
235
267
  ```bash
236
268
  npm install && npm test # proxy: mirror/cache/offline + Express route; sync: subscribe+buffer
package/index.js CHANGED
@@ -14,10 +14,18 @@ const { resolveAccountConfig } = require('./lib/account')
14
14
  // Kept as separate modules so a proxy fault can't wedge the data-critical sync.
15
15
 
16
16
  // Fixed cloud endpoints. Boats never choose these, so they are constants rather than
17
- // config fields but every module still reads its option first (see startModules), so
18
- // a hand-edited plugin-config JSON can override any of them for self-hosting.
17
+ // config fields, and NOTHING in a saved config may override them unless `selfHosted` is
18
+ // explicitly set (see resolveEndpoint).
19
+ //
20
+ // v0.14.3 kept `sync.influxUrl` / `proxy.sailkickUrl` as a silent escape hatch, on the
21
+ // reasoning that a value could only get there by deliberate hand-editing. That was
22
+ // wrong: both were VISIBLE FIELDS before v0.14.0, so every upgraded install still has
23
+ // whatever was typed into them — and since they are no longer in the schema, the owner
24
+ // can neither see nor clear them from the UI. One boat spent a day POSTing valid cloud
25
+ // credentials at a dev LAN address for exactly this reason. Stale wins over intent, so
26
+ // leftovers are now ignored and reported rather than obeyed.
19
27
  const SAILKICK_APP_URL = 'https://www.sailkick.io' // signup + mirror upstream
20
- const SAILKICK_INFLUX_URL = 'https://sync.sailkick.io' // data egress; pairing overrides this
28
+ const SAILKICK_INFLUX_URL = 'https://sync.sailkick.io' // data egress
21
29
 
22
30
  // Tuning that has a right answer. Deliberately NOT on the config page: a boat owner has
23
31
  // no basis to choose these, and a wrong value silently degrades sync or the cache.
@@ -45,17 +53,32 @@ const HISTORY_TUNING = {
45
53
  ringSampleSec: 15
46
54
  }
47
55
 
48
- // A *cloud* endpoint on loopback means telemetry never leaves the boat. On the wire it
49
- // is indistinguishable from a normal offline backlog — the spool just grows — so it has
50
- // to be called out explicitly. (The local history DB is legitimately on 127.0.0.1; this
51
- // check is only ever applied to the sync path.)
52
- function isLoopbackUrl (u) {
56
+ // A *cloud* endpoint on a loopback or private address means telemetry never leaves the
57
+ // LAN. On the wire that is indistinguishable from a normal offline backlog — the spool
58
+ // just grows — so it has to be called out explicitly. Covers RFC1918 as well as
59
+ // loopback: the case that actually bit was a 192.168.x dev box, which a loopback-only
60
+ // check sailed straight past. (The local history DB is legitimately on 127.0.0.1; this
61
+ // is only ever applied to the sync path.)
62
+ function isPrivateHostUrl (u) {
53
63
  try {
54
64
  const h = new URL(u).hostname.replace(/^\[|\]$/g, '')
55
- return h === 'localhost' || h === '::1' || /^127\./.test(h)
65
+ if (h === 'localhost' || h === '::1' || /^127\./.test(h)) return true
66
+ if (/^10\./.test(h) || /^192\.168\./.test(h)) return true
67
+ if (/^172\.(1[6-9]|2\d|3[01])\./.test(h)) return true
68
+ return false
56
69
  } catch { return false }
57
70
  }
58
71
 
72
+ // Endpoint resolution with one rule: the constant wins unless self-hosting is declared.
73
+ // Returns { url, ignored } — `ignored` is the stale value we refused, so the caller can
74
+ // say so out loud instead of leaving the owner to guess why nothing arrives.
75
+ function resolveEndpoint (configured, constant, selfHosted) {
76
+ const v = typeof configured === 'string' ? configured.trim() : ''
77
+ if (!v || v === constant) return { url: constant, ignored: null }
78
+ if (selfHosted) return { url: v, ignored: null }
79
+ return { url: constant, ignored: v }
80
+ }
81
+
59
82
  module.exports = function (app) {
60
83
  let sync = null
61
84
  let proxy = null
@@ -151,17 +174,17 @@ module.exports = function (app) {
151
174
  const store = p.dataDir || p.storeDir || undefined
152
175
  const spoolDir = s.spoolDir || (p.dataDir ? path.join(p.dataDir, 'spool') : undefined)
153
176
 
177
+ // Self-hosting is opt-in and explicit. Without it, a saved endpoint is treated as a
178
+ // leftover from an older version rather than as intent.
179
+ const selfHosted = s.selfHosted === true || p.selfHosted === true
180
+ const influx = resolveEndpoint(s.influxUrl, SAILKICK_INFLUX_URL, selfHosted)
181
+ const upstream = resolveEndpoint(p.sailkickUrl, SAILKICK_APP_URL, selfHosted)
182
+
154
183
  // --- sync module (isolated: its failure must not affect the proxy) ---
155
- // Pairing wins over hand-written fields; hand-written fields win over the constants.
156
184
  if (s.enabled !== false) {
157
185
  const syncOpts = {
158
186
  ...SYNC_TUNING,
159
- // The cloud endpoint is fleet-wide and fixed. A pasted or cached bundle can
160
- // never set it — the app's signup screen hands out its own http://localhost:8086,
161
- // and a boat that believed it would spool to loopback forever, looking healthy.
162
- // `sync.influxUrl` survives only as a self-hosting escape hatch: it is not in
163
- // the schema, so it cannot arrive by pasting, only by editing the config JSON.
164
- influxUrl: s.influxUrl || SAILKICK_INFLUX_URL,
187
+ influxUrl: influx.url,
165
188
  org: b.org || s.org || SYNC_TUNING.org,
166
189
  bucket: b.bucket || s.bucket,
167
190
  token: b.writeToken || s.token,
@@ -174,9 +197,17 @@ module.exports = function (app) {
174
197
  retryMinMs: s.retryMinMs || SYNC_TUNING.retryMinMs,
175
198
  retryMaxMs: s.retryMaxMs || SYNC_TUNING.retryMaxMs
176
199
  }
177
- if (isLoopbackUrl(syncOpts.influxUrl)) {
178
- syncWarning = `sync: writing to ${syncOpts.influxUrl} telemetry is NOT reaching the cloud`
179
- ;(app.error || console.error)(`[sailkick-boat] sync target ${syncOpts.influxUrl} is a local address telemetry will not reach the cloud`)
200
+ // One unconditional line naming the actual target. This is the single most useful
201
+ // thing in the log: it would have shown the stale 192.168.x address instantly,
202
+ // instead of a day spent inferring it from an absence of errors.
203
+ console.log(`[sailkick-boat] sync -> ${syncOpts.influxUrl} org=${syncOpts.org} bucket=${syncOpts.bucket || '(none)'}${selfHosted ? ' [self-hosted]' : ''}`)
204
+
205
+ if (influx.ignored) {
206
+ syncWarning = `sync: ignoring stale influxUrl ${influx.ignored} — using ${influx.url}`
207
+ ;(app.error || console.error)(`[sailkick-boat] ignoring sync.influxUrl "${influx.ignored}" left over from an older config — using ${influx.url}. Set sync.selfHosted:true to keep your own endpoint.`)
208
+ } else if (isPrivateHostUrl(syncOpts.influxUrl)) {
209
+ syncWarning = `sync: ⚠ writing to ${syncOpts.influxUrl} — a private address, telemetry is NOT reaching the cloud`
210
+ ;(app.error || console.error)(`[sailkick-boat] sync target ${syncOpts.influxUrl} is a loopback/private address — telemetry will not reach the cloud`)
180
211
  }
181
212
  try { sync = createSync(app, syncOpts); sync.start() } catch (e) {
182
213
  (app.error || console.error)('[sailkick-boat] sync start failed: ' + e.message)
@@ -189,8 +220,11 @@ module.exports = function (app) {
189
220
  const oldSeed = p.seed || {}
190
221
  const oldPrefetch = p.prefetch || {}
191
222
  const oldHistory = p.history || {}
223
+ if (upstream.ignored) {
224
+ ;(app.error || console.error)(`[sailkick-boat] ignoring proxy.sailkickUrl "${upstream.ignored}" left over from an older config — mirroring ${upstream.url}. Set proxy.selfHosted:true to keep your own server.`)
225
+ }
192
226
  const pOpts = {
193
- sailkickUrl: p.sailkickUrl || SAILKICK_APP_URL,
227
+ sailkickUrl: upstream.url,
194
228
  storeDir: store,
195
229
  proxyPort: p.proxyPort == null ? 8080 : p.proxyPort,
196
230
  localSignalkUrl: p.localSignalkUrl || 'http://127.0.0.1:3000',
package/lib/sync/index.js CHANGED
@@ -13,6 +13,12 @@ const { deltaToLines } = require('./lineprotocol')
13
13
 
14
14
  function createSync (app, options) {
15
15
  const log = (m) => (app.debug ? app.debug('[sync] ' + m) : console.log('[sailkick-boat:sync]', m))
16
+ // Anything that stops telemetry reaching the cloud goes to `warn`, which lands in the
17
+ // normal server log. `log` is behind the plugin's debug key and is for detail only —
18
+ // an outage must never be visible ONLY in the status line, which is what made a
19
+ // day-long silent failure possible.
20
+ const warn = (m) => (app.error ? app.error('[sailkick-boat:sync] ' + m) : console.error('[sailkick-boat:sync]', m))
21
+ const WARN_REPEAT_MS = 300000 // re-state an ongoing outage every 5 min, not every retry
16
22
  let state = null
17
23
 
18
24
  function start () {
@@ -24,8 +30,9 @@ function createSync (app, options) {
24
30
  timeoutMs: options.requestTimeoutMs || 30000
25
31
  }
26
32
  if (!cfg.influxUrl || !cfg.bucket || !cfg.token) {
27
- log('not started missing influxUrl / bucket / token')
28
- state = { statusLine: 'sync: not configured', stopped: true }
33
+ const missing = [!cfg.influxUrl && 'influxUrl', !cfg.bucket && 'bucket', !cfg.token && 'write token'].filter(Boolean)
34
+ warn(`not started missing ${missing.join(' + ')}. Telemetry is NOT being sent.`)
35
+ state = { statusLine: `sync: not configured (missing ${missing.join(', ')})`, stopped: true }
29
36
  return
30
37
  }
31
38
 
@@ -39,7 +46,26 @@ function createSync (app, options) {
39
46
  flushTimer: null, pumpTimer: null, stopped: false, unsubscribes: [],
40
47
  retryMin: options.retryMinMs || 1000, retryMax: options.retryMaxMs || 60000,
41
48
  backoff: options.retryMinMs || 1000, idlePoll: Math.max(500, options.flushIntervalMs || 1000),
42
- lastOkAt: null, pumping: false, statusLine: 'sync: starting'
49
+ lastOkAt: null, pumping: false, statusLine: 'sync: starting',
50
+ failing: false, failCount: 0, lastWarnAt: 0
51
+ }
52
+
53
+ // Outage transitions, not per-retry spam: the first failure, then at most one line
54
+ // every WARN_REPEAT_MS while it persists, then a recovery line. A boat that can
55
+ // never reach its endpoint used to produce no log output at all.
56
+ const noteFailure = (res) => {
57
+ state.failCount++
58
+ const now = Date.now()
59
+ if (state.failing && now - state.lastWarnAt < WARN_REPEAT_MS) return
60
+ state.failing = true
61
+ state.lastWarnAt = now
62
+ const why = res && res.status ? `HTTP ${res.status}` : `unreachable${res && res.error ? ` (${res.error})` : ''}`
63
+ warn(`cannot write to ${cfg.influxUrl} — ${why}; ${state.failCount} failed attempt(s), telemetry is buffering on disk`)
64
+ }
65
+ const noteSuccess = () => {
66
+ if (state.failing) warn(`recovered — writing to ${cfg.influxUrl} again after ${state.failCount} failed attempt(s)`)
67
+ state.failing = false
68
+ state.failCount = 0
43
69
  }
44
70
 
45
71
  const handleDelta = (delta) => {
@@ -73,10 +99,15 @@ function createSync (app, options) {
73
99
  const res = await writeLines(cfg, body)
74
100
  if (res.ok) {
75
101
  await spool.remove(file); state.lastOkAt = new Date(); state.backoff = state.retryMin
102
+ noteSuccess()
76
103
  } else if (res.retryable) {
104
+ noteFailure(res)
77
105
  state.pumping = false; scheduleRetry(res); return
78
106
  } else {
79
- log(`batch rejected (HTTP ${res.status}) quarantined`); await spool.quarantine(file)
107
+ // 4xx: bad credentials or malformed data. Never retried, so if this is not
108
+ // loud the batch is simply gone.
109
+ warn(`batch REJECTED (HTTP ${res.status}) and quarantined to ${state.spool.deadDir} — ${res.status === 401 ? 'the write token is not valid for this bucket' : 'check bucket/org and the data'}`)
110
+ await spool.quarantine(file)
80
111
  }
81
112
  }
82
113
  } catch (e) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sailkick-boat",
3
- "version": "0.14.3",
3
+ "version": "0.14.4",
4
4
  "description": "EARLY ALPHA — cloud telemetry + offline maps for sailkick boats (www.sailkick.io; register on the web, paste the write token). Gapless boat→cloud telemetry sync to InfluxDB, and a local proxy that keeps the sailkick app and its charts/maps working fully offline on board.",
5
5
  "main": "index.js",
6
6
  "scripts": {