signalk-siparu 0.2.2 → 0.2.3
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 +22 -24
- package/package.json +1 -1
- package/plugin/dist/anchor.d.ts +11 -0
- package/plugin/dist/anchor.js +103 -0
- package/plugin/dist/approval.d.ts +40 -0
- package/plugin/dist/approval.js +170 -0
- package/plugin/dist/config-routes.d.ts +1 -0
- package/plugin/dist/config-routes.js +8 -2
- package/plugin/dist/config.d.ts +8 -1
- package/plugin/dist/config.js +12 -5
- package/plugin/dist/contract.d.ts +16 -62
- package/plugin/dist/index.js +72 -52
- package/plugin/dist/keystore.js +1 -0
- package/plugin/dist/keysync.d.ts +1 -0
- package/plugin/dist/keysync.js +31 -5
- package/plugin/dist/live.d.ts +2 -7
- package/plugin/dist/live.js +2 -58
- package/plugin/dist/metrics.js +7 -1
- package/plugin/dist/pairing.d.ts +10 -0
- package/plugin/dist/pairing.js +49 -8
- package/plugin/dist/rollup.js +2 -1
- package/plugin/dist/sealer.d.ts +5 -8
- package/plugin/dist/sealer.js +9 -71
- package/plugin/dist/sealing.d.ts +3 -5
- package/plugin/dist/voyage-routes.d.ts +1 -0
- package/plugin/dist/voyage-routes.js +8 -2
- package/public/assets/{Logbook-DKQgrjzR.js → Logbook-CZb9OaA4.js} +1 -1
- package/public/assets/{Map-DeGKZhDf.js → Map-CN4rx5A-.js} +4 -4
- package/public/assets/Voyage-CzIqnUBA.js +5 -0
- package/public/assets/{index-DFYgRoHp.js → index-5gz9-vQd.js} +4 -4
- package/public/assets/{style-DhMV3_OD.js → style-CSszoHyM.js} +1 -1
- package/public/index.html +1 -1
- package/plugin/dist/alarms.d.ts +0 -23
- package/plugin/dist/alarms.js +0 -118
- package/plugin/dist/alertrules.d.ts +0 -74
- package/plugin/dist/alertrules.js +0 -282
- package/public/assets/Voyage-csfbROs0.js +0 -5
package/README.md
CHANGED
|
@@ -47,16 +47,24 @@ generator tab. The same gauges are also served over the API (`GET /live`,
|
|
|
47
47
|
|
|
48
48
|
Signal K ships with security switched off, and nothing in the setup makes you turn
|
|
49
49
|
it on. With it off, every plugin's HTTP surface is open to anyone who can reach
|
|
50
|
-
your boat's network, this one included
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
50
|
+
your boat's network, this one included. (The relay credential itself is out of
|
|
51
|
+
reach: it lives in a mode-600 file under the plugin's data directory, not in the
|
|
52
|
+
config. The relay address is not a plugin option at all - it comes from the
|
|
53
|
+
server process's environment, so nothing that writes plugin options can redirect
|
|
54
|
+
the boat.)
|
|
55
|
+
|
|
56
|
+
Because of that, the plugin locks its own writes while security is off: pairing,
|
|
57
|
+
unpairing and log edits refuse until you either add an admin user in Signal K or
|
|
58
|
+
tick "Allow pairing and log edits while Signal K security is off" in the plugin
|
|
59
|
+
settings. The tick exists for installs that cannot easily turn security on; it is
|
|
60
|
+
an acceptance of the risk, not a removal of it.
|
|
61
|
+
|
|
62
|
+
The lock cannot make an unsecured server safe. On such a server
|
|
63
|
+
`GET /skServer/plugins` already discloses every plugin's configuration, the
|
|
64
|
+
server's own config route can change any plugin's settings - including that very
|
|
65
|
+
tick - and the App Store will install code. The consequence peculiar to Siparu is
|
|
66
|
+
what a stranger on the marina wifi could do with an open door: link your boat to
|
|
67
|
+
**their** account while your own screen goes on saying "paired".
|
|
60
68
|
|
|
61
69
|
So before you pair, add an admin user in Signal K (Security > Users). It takes a
|
|
62
70
|
minute and it is the difference between a boat you share and a boat you leak.
|
|
@@ -148,20 +156,10 @@ that sends a position in the clear, because a boat whose privacy depends on the
|
|
|
148
156
|
state of a list held elsewhere does not have any.
|
|
149
157
|
|
|
150
158
|
Once a device is named, the report is encrypted to it and the relay carries a
|
|
151
|
-
block it cannot read.
|
|
152
|
-
stating plainly because they are what a carrier can see:
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
- How loud she is: `normal`, `warning` or `alarm`, and nothing about what is
|
|
156
|
-
wrong. A carrier has to know a notification is due, and this field is inside
|
|
157
|
-
her signature so that it cannot decide one is not.
|
|
158
|
-
- When the newest audible condition began, as a moment in time. Without it a
|
|
159
|
-
second alarm raised while a first one stands is indistinguishable from the
|
|
160
|
-
first one still standing, and the second would never be announced. It says
|
|
161
|
-
that an event is a different event; what the event is stays encrypted.
|
|
162
|
-
|
|
163
|
-
Taken together, a carrier can see that something is wrong aboard, roughly how
|
|
164
|
-
serious, and when each one started. It cannot see what any of them is.
|
|
159
|
+
block it cannot read. Two things stay legible beside it, and they are worth
|
|
160
|
+
stating plainly because they are what a carrier can see: her identifier and
|
|
161
|
+
the time she sent the frame, so it can be routed at all. Both are inside her
|
|
162
|
+
signature, so neither can be rewritten in transit.
|
|
165
163
|
|
|
166
164
|
Voyage detection opens after sustained movement, closes after sustained
|
|
167
165
|
stillness, and folds short docking manoeuvres into the preceding voyage. Its
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-siparu",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.3",
|
|
4
4
|
"description": "Kept aboard, proven ashore. An impartial, timestamped record of every voyage: position, wind, depth and logbook, written on the boat and readable from anywhere. A read-only Signal K plugin with a built-in dashboard.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"signalk-node-server-plugin",
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { DeviceAnchor } from './approval';
|
|
2
|
+
export declare class AnchorStore {
|
|
3
|
+
private readonly file;
|
|
4
|
+
private writeChain;
|
|
5
|
+
constructor(dataDir: string);
|
|
6
|
+
load(boatId: string | undefined, pairedAt: string | undefined): DeviceAnchor | null | 'broken';
|
|
7
|
+
set(boatId: string, pairedAt: string, anchor: DeviceAnchor): Promise<void>;
|
|
8
|
+
clear(): Promise<void>;
|
|
9
|
+
flush(): Promise<void>;
|
|
10
|
+
private write;
|
|
11
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.AnchorStore = void 0;
|
|
37
|
+
const fs = __importStar(require("fs"));
|
|
38
|
+
const path = __importStar(require("path"));
|
|
39
|
+
const FILE_VERSION = 1;
|
|
40
|
+
const PUB_SHAPE = /^[A-Za-z0-9_-]{43}$/;
|
|
41
|
+
class AnchorStore {
|
|
42
|
+
file;
|
|
43
|
+
writeChain = Promise.resolve();
|
|
44
|
+
constructor(dataDir) {
|
|
45
|
+
this.file = path.join(dataDir, 'anchor.json');
|
|
46
|
+
}
|
|
47
|
+
load(boatId, pairedAt) {
|
|
48
|
+
let text;
|
|
49
|
+
try {
|
|
50
|
+
text = fs.readFileSync(this.file, 'utf8');
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
return e?.code === 'ENOENT' ? null : 'broken';
|
|
54
|
+
}
|
|
55
|
+
let raw;
|
|
56
|
+
try {
|
|
57
|
+
raw = JSON.parse(text);
|
|
58
|
+
}
|
|
59
|
+
catch {
|
|
60
|
+
return 'broken';
|
|
61
|
+
}
|
|
62
|
+
if (raw.v !== FILE_VERSION ||
|
|
63
|
+
typeof raw.boat !== 'string' ||
|
|
64
|
+
typeof raw.paired_at !== 'string' ||
|
|
65
|
+
typeof raw.kid !== 'string' ||
|
|
66
|
+
raw.kid.length === 0 ||
|
|
67
|
+
typeof raw.pub !== 'string' ||
|
|
68
|
+
!PUB_SHAPE.test(raw.pub)) {
|
|
69
|
+
return 'broken';
|
|
70
|
+
}
|
|
71
|
+
if (!boatId || raw.boat !== boatId)
|
|
72
|
+
return null;
|
|
73
|
+
if (!pairedAt || raw.paired_at !== pairedAt)
|
|
74
|
+
return null;
|
|
75
|
+
return { kid: raw.kid, pub: raw.pub };
|
|
76
|
+
}
|
|
77
|
+
set(boatId, pairedAt, anchor) {
|
|
78
|
+
return this.write(async () => {
|
|
79
|
+
const tmp = `${this.file}.tmp`;
|
|
80
|
+
const shape = {
|
|
81
|
+
v: FILE_VERSION,
|
|
82
|
+
boat: boatId,
|
|
83
|
+
paired_at: pairedAt,
|
|
84
|
+
kid: anchor.kid,
|
|
85
|
+
pub: anchor.pub
|
|
86
|
+
};
|
|
87
|
+
await fs.promises.writeFile(tmp, JSON.stringify(shape, null, 2), { mode: 0o600 });
|
|
88
|
+
await fs.promises.chmod(tmp, 0o600);
|
|
89
|
+
await fs.promises.rename(tmp, this.file);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
clear() {
|
|
93
|
+
return this.write(() => fs.promises.rm(this.file, { force: true }));
|
|
94
|
+
}
|
|
95
|
+
flush() {
|
|
96
|
+
return this.writeChain.catch(() => undefined);
|
|
97
|
+
}
|
|
98
|
+
write(run) {
|
|
99
|
+
this.writeChain = this.writeChain.then(run, run);
|
|
100
|
+
return this.writeChain;
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
exports.AnchorStore = AnchorStore;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { type KeyObject } from 'crypto';
|
|
2
|
+
import type { DevicePublicKey } from './contract';
|
|
3
|
+
export declare const MAC_BYTES = 32;
|
|
4
|
+
export interface DeviceAnchor {
|
|
5
|
+
kid: string;
|
|
6
|
+
pub: string;
|
|
7
|
+
}
|
|
8
|
+
export declare function approvalInput(boat: string, approverKid: string, subjectKid: string, subjectPub: Buffer): Buffer;
|
|
9
|
+
export declare function computeApproval(opts: {
|
|
10
|
+
approverPriv: KeyObject;
|
|
11
|
+
inboxPub: Buffer;
|
|
12
|
+
boat: string;
|
|
13
|
+
approverKid: string;
|
|
14
|
+
subjectKid: string;
|
|
15
|
+
subjectPub: Buffer;
|
|
16
|
+
}): Buffer;
|
|
17
|
+
export declare function verifyApproval(opts: {
|
|
18
|
+
inboxPriv: KeyObject;
|
|
19
|
+
approverPub: Buffer;
|
|
20
|
+
boat: string;
|
|
21
|
+
approverKid: string;
|
|
22
|
+
subjectKid: string;
|
|
23
|
+
subjectPub: Buffer;
|
|
24
|
+
mac: Buffer;
|
|
25
|
+
}): boolean;
|
|
26
|
+
export interface SkippedDevice {
|
|
27
|
+
kid: string;
|
|
28
|
+
reason: string;
|
|
29
|
+
}
|
|
30
|
+
export interface AcceptResult {
|
|
31
|
+
accepted: DevicePublicKey[];
|
|
32
|
+
skipped: SkippedDevice[];
|
|
33
|
+
pinned: boolean;
|
|
34
|
+
}
|
|
35
|
+
export declare function acceptDevices(opts: {
|
|
36
|
+
anchor: DeviceAnchor | null | 'broken';
|
|
37
|
+
entries: DevicePublicKey[];
|
|
38
|
+
inboxPriv: KeyObject | undefined;
|
|
39
|
+
boat: string | undefined;
|
|
40
|
+
}): AcceptResult;
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.MAC_BYTES = void 0;
|
|
4
|
+
exports.approvalInput = approvalInput;
|
|
5
|
+
exports.computeApproval = computeApproval;
|
|
6
|
+
exports.verifyApproval = verifyApproval;
|
|
7
|
+
exports.acceptDevices = acceptDevices;
|
|
8
|
+
const crypto_1 = require("crypto");
|
|
9
|
+
const sealing_1 = require("./sealing");
|
|
10
|
+
const LABEL = 'siparu/device-approval/v1';
|
|
11
|
+
const APPROVAL_VERSION = 1;
|
|
12
|
+
const KEY_BYTES = 32;
|
|
13
|
+
exports.MAC_BYTES = 32;
|
|
14
|
+
function approvalInput(boat, approverKid, subjectKid, subjectPub) {
|
|
15
|
+
return Buffer.concat([
|
|
16
|
+
Buffer.from(LABEL, 'utf8'),
|
|
17
|
+
(0, sealing_1.u16be)(APPROVAL_VERSION),
|
|
18
|
+
(0, sealing_1.lp)(Buffer.from(boat, 'utf8')),
|
|
19
|
+
(0, sealing_1.lp)(Buffer.from(approverKid, 'utf8')),
|
|
20
|
+
(0, sealing_1.lp)(Buffer.from(subjectKid, 'utf8')),
|
|
21
|
+
(0, sealing_1.lp)(subjectPub)
|
|
22
|
+
]);
|
|
23
|
+
}
|
|
24
|
+
function approvalKey(shared, approverPub, boat, approverKid, subjectKid) {
|
|
25
|
+
const info = `${LABEL}/${boat}/${approverKid}/${subjectKid}`;
|
|
26
|
+
return Buffer.from((0, crypto_1.hkdfSync)('sha256', shared, approverPub, info, KEY_BYTES));
|
|
27
|
+
}
|
|
28
|
+
function computeApproval(opts) {
|
|
29
|
+
const shared = (0, crypto_1.diffieHellman)({
|
|
30
|
+
privateKey: opts.approverPriv,
|
|
31
|
+
publicKey: (0, sealing_1.x25519PublicFromRaw)(opts.inboxPub)
|
|
32
|
+
});
|
|
33
|
+
const approverPub = (0, sealing_1.publicFromPrivate)(opts.approverPriv);
|
|
34
|
+
const key = approvalKey(shared, approverPub, opts.boat, opts.approverKid, opts.subjectKid);
|
|
35
|
+
return (0, crypto_1.createHmac)('sha256', key)
|
|
36
|
+
.update(approvalInput(opts.boat, opts.approverKid, opts.subjectKid, opts.subjectPub))
|
|
37
|
+
.digest();
|
|
38
|
+
}
|
|
39
|
+
function verifyApproval(opts) {
|
|
40
|
+
if (opts.mac.length !== exports.MAC_BYTES)
|
|
41
|
+
return false;
|
|
42
|
+
let expected;
|
|
43
|
+
try {
|
|
44
|
+
const shared = (0, crypto_1.diffieHellman)({
|
|
45
|
+
privateKey: opts.inboxPriv,
|
|
46
|
+
publicKey: (0, sealing_1.x25519PublicFromRaw)(opts.approverPub)
|
|
47
|
+
});
|
|
48
|
+
const key = approvalKey(shared, opts.approverPub, opts.boat, opts.approverKid, opts.subjectKid);
|
|
49
|
+
expected = (0, crypto_1.createHmac)('sha256', key)
|
|
50
|
+
.update(approvalInput(opts.boat, opts.approverKid, opts.subjectKid, opts.subjectPub))
|
|
51
|
+
.digest();
|
|
52
|
+
}
|
|
53
|
+
catch {
|
|
54
|
+
return false;
|
|
55
|
+
}
|
|
56
|
+
return (0, crypto_1.timingSafeEqual)(expected, opts.mac);
|
|
57
|
+
}
|
|
58
|
+
function acceptDevices(opts) {
|
|
59
|
+
const { anchor, entries, inboxPriv, boat } = opts;
|
|
60
|
+
if (anchor === null) {
|
|
61
|
+
return { accepted: [...entries], skipped: [], pinned: false };
|
|
62
|
+
}
|
|
63
|
+
const skipped = [];
|
|
64
|
+
if (anchor === 'broken' || !boat) {
|
|
65
|
+
return {
|
|
66
|
+
accepted: [],
|
|
67
|
+
skipped: entries.map((e) => ({
|
|
68
|
+
kid: e.kid,
|
|
69
|
+
reason: anchor === 'broken'
|
|
70
|
+
? 'the anchor on disk cannot be read; re-pair to pin the screens again'
|
|
71
|
+
: 'the boat is not paired'
|
|
72
|
+
})),
|
|
73
|
+
pinned: true
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
const trusted = new Map();
|
|
77
|
+
const anchorPub = decodePub(anchor.pub);
|
|
78
|
+
if (anchorPub)
|
|
79
|
+
trusted.set(anchor.kid, anchorPub);
|
|
80
|
+
const accepted = [];
|
|
81
|
+
const pending = new Map();
|
|
82
|
+
for (const entry of entries) {
|
|
83
|
+
if (entry.kid === anchor.kid) {
|
|
84
|
+
const entryPub = decodePub(entry.pub);
|
|
85
|
+
if (anchorPub && entryPub?.equals(anchorPub)) {
|
|
86
|
+
accepted.push(entry);
|
|
87
|
+
}
|
|
88
|
+
else {
|
|
89
|
+
skipped.push({
|
|
90
|
+
kid: entry.kid,
|
|
91
|
+
reason: 'carries the anchor id over a different key'
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
pending.set(entry, '');
|
|
97
|
+
}
|
|
98
|
+
let grew = true;
|
|
99
|
+
while (grew) {
|
|
100
|
+
grew = false;
|
|
101
|
+
for (const [entry] of pending) {
|
|
102
|
+
const verdict = admissible(entry, trusted, inboxPriv, boat);
|
|
103
|
+
if (verdict === true) {
|
|
104
|
+
accepted.push(entry);
|
|
105
|
+
const pub = decodePub(entry.pub);
|
|
106
|
+
if (pub)
|
|
107
|
+
trusted.set(entry.kid, pub);
|
|
108
|
+
pending.delete(entry);
|
|
109
|
+
grew = true;
|
|
110
|
+
}
|
|
111
|
+
else {
|
|
112
|
+
pending.set(entry, verdict);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
for (const [entry, reason] of pending) {
|
|
117
|
+
skipped.push({ kid: entry.kid, reason });
|
|
118
|
+
}
|
|
119
|
+
return { accepted, skipped, pinned: true };
|
|
120
|
+
}
|
|
121
|
+
function admissible(entry, trusted, inboxPriv, boat) {
|
|
122
|
+
const approvals = entry.approvals ?? [];
|
|
123
|
+
if (approvals.length === 0) {
|
|
124
|
+
return 'carries no approval from a device this boat trusts';
|
|
125
|
+
}
|
|
126
|
+
if (!inboxPriv) {
|
|
127
|
+
return 'the boat holds no inbox key yet to verify the approval with';
|
|
128
|
+
}
|
|
129
|
+
const subjectPub = decodePub(entry.pub);
|
|
130
|
+
const refusals = [];
|
|
131
|
+
for (const { by, mac } of approvals) {
|
|
132
|
+
const approverPub = trusted.get(by);
|
|
133
|
+
if (!approverPub) {
|
|
134
|
+
refusals.push({ rank: 2, text: `approved by "${by}", which this boat does not trust` });
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
const raw = decodeMac(mac);
|
|
138
|
+
if (!subjectPub || !raw) {
|
|
139
|
+
refusals.push({ rank: 1, text: 'the approval is malformed' });
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const ok = verifyApproval({
|
|
143
|
+
inboxPriv,
|
|
144
|
+
approverPub,
|
|
145
|
+
boat,
|
|
146
|
+
approverKid: by,
|
|
147
|
+
subjectKid: entry.kid,
|
|
148
|
+
subjectPub,
|
|
149
|
+
mac: raw
|
|
150
|
+
});
|
|
151
|
+
if (ok)
|
|
152
|
+
return true;
|
|
153
|
+
refusals.push({ rank: 0, text: 'the approval does not verify' });
|
|
154
|
+
}
|
|
155
|
+
return worst(refusals);
|
|
156
|
+
}
|
|
157
|
+
function worst(refusals) {
|
|
158
|
+
const first = refusals.reduce((best, r) => (r.rank < best.rank ? r : best));
|
|
159
|
+
return refusals.length === 1
|
|
160
|
+
? first.text
|
|
161
|
+
: `${first.text} (${refusals.length} approvals, none accepted)`;
|
|
162
|
+
}
|
|
163
|
+
function decodePub(pub) {
|
|
164
|
+
const raw = Buffer.from(pub, 'base64url');
|
|
165
|
+
return raw.length === 32 ? raw : null;
|
|
166
|
+
}
|
|
167
|
+
function decodeMac(mac) {
|
|
168
|
+
const raw = Buffer.from(mac, 'base64url');
|
|
169
|
+
return raw.length === exports.MAC_BYTES ? raw : null;
|
|
170
|
+
}
|
|
@@ -40,12 +40,18 @@ function readJsonBody(req) {
|
|
|
40
40
|
});
|
|
41
41
|
}
|
|
42
42
|
function registerConfigRoutes(router, deps) {
|
|
43
|
-
const { app, getConfig, fuelPathsView, restart } = deps;
|
|
43
|
+
const { app, acceptOpenNetwork, getConfig, fuelPathsView, restart } = deps;
|
|
44
44
|
router.get('/config/fuel-paths', (_req, res) => {
|
|
45
45
|
res.json(fuelPathsView());
|
|
46
46
|
});
|
|
47
47
|
router.post('/config/fuel-paths', (0, origin_guard_1.sameOrigin)((req, res) => {
|
|
48
|
-
if (
|
|
48
|
+
if ((0, pairing_1.securityOff)(app, req)) {
|
|
49
|
+
if ((0, pairing_1.writeLocked)(app, req, acceptOpenNetwork())) {
|
|
50
|
+
res.status(403).json({ error: 'security_off', message: pairing_1.WRITE_LOCKED_MESSAGE });
|
|
51
|
+
return;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
else if (!allowConfigure(app, req)) {
|
|
49
55
|
res.status(403).json({ error: 'admin_required' });
|
|
50
56
|
return;
|
|
51
57
|
}
|
package/plugin/dist/config.d.ts
CHANGED
|
@@ -21,10 +21,10 @@ export interface Options {
|
|
|
21
21
|
maxStorageMB: number;
|
|
22
22
|
chartsRemoteUrl: string;
|
|
23
23
|
chartsBasemapUrl: string;
|
|
24
|
-
relayUrl: string;
|
|
25
24
|
ports: PortEntry[];
|
|
26
25
|
voyage: VoyageOptions;
|
|
27
26
|
fuelRatePaths: string[];
|
|
27
|
+
acceptOpenNetwork: boolean;
|
|
28
28
|
}
|
|
29
29
|
export declare const DEFAULTS: Options;
|
|
30
30
|
export declare const INTERNAL: {
|
|
@@ -37,6 +37,7 @@ export declare const INTERNAL: {
|
|
|
37
37
|
export declare const KN_TO_MS = 0.514444;
|
|
38
38
|
export declare function isCalendarMonthDay(s: string): boolean;
|
|
39
39
|
export declare function safeRelayUrl(raw: unknown): string | undefined;
|
|
40
|
+
export declare const RELAY_URL: string;
|
|
40
41
|
export declare function resolveOptions(raw: unknown): Options;
|
|
41
42
|
export declare const CONFIG_SCHEMA: {
|
|
42
43
|
type: string;
|
|
@@ -165,6 +166,12 @@ export declare const CONFIG_SCHEMA: {
|
|
|
165
166
|
};
|
|
166
167
|
};
|
|
167
168
|
};
|
|
169
|
+
acceptOpenNetwork: {
|
|
170
|
+
type: string;
|
|
171
|
+
title: string;
|
|
172
|
+
description: string;
|
|
173
|
+
default: boolean;
|
|
174
|
+
};
|
|
168
175
|
fuelRatePaths: {
|
|
169
176
|
type: string;
|
|
170
177
|
title: string;
|
package/plugin/dist/config.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.CONFIG_SCHEMA = exports.KN_TO_MS = exports.INTERNAL = exports.DEFAULTS = void 0;
|
|
3
|
+
exports.CONFIG_SCHEMA = exports.RELAY_URL = exports.KN_TO_MS = exports.INTERNAL = exports.DEFAULTS = void 0;
|
|
4
4
|
exports.isCalendarMonthDay = isCalendarMonthDay;
|
|
5
5
|
exports.safeRelayUrl = safeRelayUrl;
|
|
6
6
|
exports.resolveOptions = resolveOptions;
|
|
@@ -12,7 +12,6 @@ exports.DEFAULTS = {
|
|
|
12
12
|
maxStorageMB: 500,
|
|
13
13
|
chartsRemoteUrl: 'https://tiles.siparu.app',
|
|
14
14
|
chartsBasemapUrl: 'https://tiles.openfreemap.org/planet',
|
|
15
|
-
relayUrl: 'https://relay.siparu.app',
|
|
16
15
|
ports: [],
|
|
17
16
|
voyage: {
|
|
18
17
|
openKnots: 1.5,
|
|
@@ -23,7 +22,8 @@ exports.DEFAULTS = {
|
|
|
23
22
|
mergeShortNm: 1.0,
|
|
24
23
|
phaseMinMinutes: 10
|
|
25
24
|
},
|
|
26
|
-
fuelRatePaths: []
|
|
25
|
+
fuelRatePaths: [],
|
|
26
|
+
acceptOpenNetwork: false
|
|
27
27
|
};
|
|
28
28
|
exports.INTERNAL = {
|
|
29
29
|
samplePeriodMs: 2000,
|
|
@@ -49,6 +49,7 @@ function safeRelayUrl(raw) {
|
|
|
49
49
|
return undefined;
|
|
50
50
|
return raw.replace(/\/+$/, '');
|
|
51
51
|
}
|
|
52
|
+
exports.RELAY_URL = safeRelayUrl(process.env.SIPARU_RELAY_URL) ?? 'https://relay.siparu.app';
|
|
52
53
|
function resolveOptions(raw) {
|
|
53
54
|
const c = (raw ?? {});
|
|
54
55
|
const v = (c.voyage ?? {});
|
|
@@ -65,7 +66,6 @@ function resolveOptions(raw) {
|
|
|
65
66
|
chartsBasemapUrl: /^https?:\/\/\S+$/.test(c.chartsBasemapUrl ?? '')
|
|
66
67
|
? c.chartsBasemapUrl.replace(/\/+$/, '')
|
|
67
68
|
: exports.DEFAULTS.chartsBasemapUrl,
|
|
68
|
-
relayUrl: safeRelayUrl(c.relayUrl) ?? exports.DEFAULTS.relayUrl,
|
|
69
69
|
ports: Array.isArray(c.ports)
|
|
70
70
|
? c.ports
|
|
71
71
|
.filter((p) => !!p &&
|
|
@@ -89,7 +89,8 @@ function resolveOptions(raw) {
|
|
|
89
89
|
},
|
|
90
90
|
fuelRatePaths: Array.isArray(c.fuelRatePaths)
|
|
91
91
|
? c.fuelRatePaths.filter((p) => typeof p === 'string' && p.trim().length > 0).map((p) => p.trim())
|
|
92
|
-
: exports.DEFAULTS.fuelRatePaths
|
|
92
|
+
: exports.DEFAULTS.fuelRatePaths,
|
|
93
|
+
acceptOpenNetwork: c.acceptOpenNetwork === true
|
|
93
94
|
};
|
|
94
95
|
}
|
|
95
96
|
exports.CONFIG_SCHEMA = {
|
|
@@ -206,6 +207,12 @@ exports.CONFIG_SCHEMA = {
|
|
|
206
207
|
}
|
|
207
208
|
}
|
|
208
209
|
},
|
|
210
|
+
acceptOpenNetwork: {
|
|
211
|
+
type: 'boolean',
|
|
212
|
+
title: 'Allow pairing and log edits while Signal K security is off',
|
|
213
|
+
description: 'With Signal K security off, anyone on the boat\'s network can pair this boat to their own account, unpair her or edit her log, so Siparu refuses all three until you answer for it here. It cannot protect this page: the server writes these settings itself, and on an unsecured server anyone can change them, this tick included. The real fix is an admin user in Signal K (Security > Users). Tick this only on a network you trust.',
|
|
214
|
+
default: exports.DEFAULTS.acceptOpenNetwork
|
|
215
|
+
},
|
|
209
216
|
fuelRatePaths: {
|
|
210
217
|
type: 'array',
|
|
211
218
|
title: 'Fuel consumption source (advanced)',
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
export type DepthDatum = 'belowTransducer' | 'belowKeel' | 'belowSurface';
|
|
1
2
|
export interface Snapshot {
|
|
2
3
|
ts: number;
|
|
3
4
|
lat: number | null;
|
|
@@ -18,6 +19,7 @@ export interface Snapshot {
|
|
|
18
19
|
air_temp_k: number | null;
|
|
19
20
|
air_pressure_pa: number | null;
|
|
20
21
|
depth: number | null;
|
|
22
|
+
depth_datum?: DepthDatum | null;
|
|
21
23
|
water_temp_k: number | null;
|
|
22
24
|
gps_satellites: number | null;
|
|
23
25
|
ais_class: string | null;
|
|
@@ -221,6 +223,15 @@ export interface HealthResult {
|
|
|
221
223
|
mode: 'sealed' | 'blocked' | 'none';
|
|
222
224
|
reason: string | null;
|
|
223
225
|
screens: string[];
|
|
226
|
+
screens_pinned: boolean;
|
|
227
|
+
screens_skipped: {
|
|
228
|
+
kid: string;
|
|
229
|
+
reason: string;
|
|
230
|
+
}[];
|
|
231
|
+
screens_rejected: {
|
|
232
|
+
kid: string;
|
|
233
|
+
reason: string;
|
|
234
|
+
}[];
|
|
224
235
|
};
|
|
225
236
|
}
|
|
226
237
|
export type PhaseKind = 'underway' | 'anchored' | 'moored' | 'stopped';
|
|
@@ -323,73 +334,16 @@ export interface SealedFrame {
|
|
|
323
334
|
sig: string;
|
|
324
335
|
[extension: string]: unknown;
|
|
325
336
|
}
|
|
326
|
-
export type AlertLevel = 'normal' | 'warning' | 'alarm';
|
|
327
|
-
export interface StandingCondition {
|
|
328
|
-
path: string;
|
|
329
|
-
state: Exclude<AlertLevel, 'normal'>;
|
|
330
|
-
message: string | null;
|
|
331
|
-
since: number;
|
|
332
|
-
}
|
|
333
|
-
export interface AlertNote {
|
|
334
|
-
path: string;
|
|
335
|
-
state: Exclude<AlertLevel, 'normal'>;
|
|
336
|
-
message: string | null;
|
|
337
|
-
since: number;
|
|
338
|
-
}
|
|
339
|
-
export type AlertRing = 'warning' | 'alarm' | 'never';
|
|
340
|
-
export interface AlertRule {
|
|
341
|
-
path: string;
|
|
342
|
-
ring_from: AlertRing;
|
|
343
|
-
}
|
|
344
|
-
export interface SetAlertRulesRequest {
|
|
345
|
-
type: 'setalertrules';
|
|
346
|
-
v: number;
|
|
347
|
-
id: string;
|
|
348
|
-
kid: string;
|
|
349
|
-
ts: number;
|
|
350
|
-
rules: AlertRule[];
|
|
351
|
-
proof: string;
|
|
352
|
-
}
|
|
353
|
-
export interface AlertRulesResult {
|
|
354
|
-
rules: AlertRule[];
|
|
355
|
-
ts: number;
|
|
356
|
-
}
|
|
357
|
-
export type SetAlertRulesResponse = {
|
|
358
|
-
type: 'setalertrules';
|
|
359
|
-
id: string;
|
|
360
|
-
result: AlertRulesResult;
|
|
361
|
-
} | {
|
|
362
|
-
type: 'setalertrules';
|
|
363
|
-
id: string;
|
|
364
|
-
error: {
|
|
365
|
-
code: string;
|
|
366
|
-
message: string;
|
|
367
|
-
};
|
|
368
|
-
};
|
|
369
|
-
export interface AlertRulesRequest {
|
|
370
|
-
type: 'alertrules';
|
|
371
|
-
id: string;
|
|
372
|
-
}
|
|
373
|
-
export interface AlertRulesReadResult extends AlertRulesResult {
|
|
374
|
-
known: string[];
|
|
375
|
-
}
|
|
376
|
-
export type AlertRulesResponse = {
|
|
377
|
-
type: 'alertrules';
|
|
378
|
-
id: string;
|
|
379
|
-
result: AlertRulesReadResult;
|
|
380
|
-
} | {
|
|
381
|
-
type: 'alertrules';
|
|
382
|
-
id: string;
|
|
383
|
-
error: {
|
|
384
|
-
code: string;
|
|
385
|
-
message: string;
|
|
386
|
-
};
|
|
387
|
-
};
|
|
388
337
|
export interface BoatPublicKeys {
|
|
389
338
|
identity: string;
|
|
390
339
|
inbox: string;
|
|
391
340
|
}
|
|
341
|
+
export interface DeviceApproval {
|
|
342
|
+
by: string;
|
|
343
|
+
mac: string;
|
|
344
|
+
}
|
|
392
345
|
export interface DevicePublicKey {
|
|
393
346
|
kid: string;
|
|
394
347
|
pub: string;
|
|
348
|
+
approvals?: DeviceApproval[];
|
|
395
349
|
}
|