signalk-ssl 0.4.0 → 0.6.0
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 +2 -2
- package/dist/plugin/api.d.ts +5 -1
- package/dist/plugin/api.js +4 -0
- package/dist/plugin/index.d.ts +9 -0
- package/dist/plugin/index.js +64 -10
- package/dist/plugin/schema.d.ts +13 -1
- package/dist/plugin/schema.js +21 -0
- package/package.json +2 -2
- package/public/index.html +2 -2
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ The Appstore installs plugins with `npm install --ignore-scripts`, so this packa
|
|
|
31
31
|
- **Passphrase mode** — `convenience` is the default and just works.
|
|
32
32
|
- Defaults for CA validity (10 years), leaf validity (397 days), renewal threshold (30 days), clock-skew backdate (24 hours) are fine for most boats.
|
|
33
33
|
3. Save the config. The plugin generates the CA, signs a leaf certificate, and writes both to SignalK's TLS path (`ssl-cert.pem`, `ssl-key.pem`, `ssl-chain.pem` in the configured config directory).
|
|
34
|
-
4. Open the plugin webapp at `/
|
|
34
|
+
4. Open the plugin webapp at `/signalk-ssl/`.
|
|
35
35
|
5. Restart SignalK so the new certificate is picked up by the HTTPS listener. (The webapp shows a banner reminding you.)
|
|
36
36
|
|
|
37
37
|
## Distribute the CA to phones
|
|
@@ -76,7 +76,7 @@ or `webapp` mode first.
|
|
|
76
76
|
|
|
77
77
|
## Routes
|
|
78
78
|
|
|
79
|
-
- `GET /
|
|
79
|
+
- `GET /signalk-ssl/` — webapp static files (served by signalk-server at the module name, admin auth required)
|
|
80
80
|
- `GET /plugins/signalk-ssl/status` — JSON status (admin auth required)
|
|
81
81
|
- `POST /plugins/signalk-ssl/renew` — issue / renew leaf (admin auth required)
|
|
82
82
|
- `POST /plugins/signalk-ssl/unlock` — supply passphrase (webapp mode, admin auth required)
|
package/dist/plugin/api.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { IRouter } from 'express';
|
|
2
|
-
import type { SslService } from './service.js';
|
|
2
|
+
import type { SslService, ServiceStatus } from './service.js';
|
|
3
3
|
import type { SignalkSslConfig } from './schema.js';
|
|
4
4
|
import type { PassphraseSource } from './passphrase-source.js';
|
|
5
5
|
import type { CertStore } from './storage.js';
|
|
@@ -13,6 +13,10 @@ export interface AdminApiDeps extends ApiDeps {
|
|
|
13
13
|
/** Returns the raw hostname signalk-server uses for mDNS advertisement,
|
|
14
14
|
* or '' if unavailable. See ExtendedServerAPI in src/plugin/index.ts. */
|
|
15
15
|
readonly getRawHostname: () => string;
|
|
16
|
+
/** Called with each fresh status the admin routes compute, so the plugin can
|
|
17
|
+
* keep its synchronous statusMessage() cache current (the webapp polls
|
|
18
|
+
* /status, and /renew recomputes it). Optional. */
|
|
19
|
+
readonly onStatus?: (status: ServiceStatus) => void;
|
|
16
20
|
}
|
|
17
21
|
/** Mounted by the server at /plugins/signalk-ssl/* — admin auth applied. */
|
|
18
22
|
export declare const registerAdminRoutes: (router: IRouter, deps: AdminApiDeps) => void;
|
package/dist/plugin/api.js
CHANGED
|
@@ -12,6 +12,7 @@ const asyncRoute = (fn) => (req, res, next) => {
|
|
|
12
12
|
export const registerAdminRoutes = (router, deps) => {
|
|
13
13
|
router.get('/status', asyncRoute(async (_req, res) => {
|
|
14
14
|
const s = await deps.service.status();
|
|
15
|
+
deps.onStatus?.(s);
|
|
15
16
|
sendJson(res, 200, s);
|
|
16
17
|
}));
|
|
17
18
|
router.get('/api/local-ips', (_req, res) => {
|
|
@@ -22,6 +23,9 @@ export const registerAdminRoutes = (router, deps) => {
|
|
|
22
23
|
});
|
|
23
24
|
router.post('/renew', asyncRoute(async (_req, res) => {
|
|
24
25
|
const out = await deps.service.issueIfNeeded();
|
|
26
|
+
if (deps.onStatus) {
|
|
27
|
+
deps.onStatus(await deps.service.status());
|
|
28
|
+
}
|
|
25
29
|
sendJson(res, 200, out);
|
|
26
30
|
}));
|
|
27
31
|
router.post('/unlock', asyncRoute(async (req, res) => {
|
package/dist/plugin/index.d.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import 'reflect-metadata';
|
|
2
2
|
import type { PluginConstructor } from '@signalk/server-api';
|
|
3
|
+
import { type ServiceStatus } from './service.js';
|
|
4
|
+
/**
|
|
5
|
+
* One-line status for the admin plugin list. statusMessage() must be
|
|
6
|
+
* synchronous, so this formats a cached {@link ServiceStatus} snapshot rather
|
|
7
|
+
* than reading the cert state from disk. Describes issuance state — not config —
|
|
8
|
+
* so an operator can tell "issued and healthy" from "no cert yet" or "error" at
|
|
9
|
+
* a glance. `null` snapshot means startup hasn't produced one yet.
|
|
10
|
+
*/
|
|
11
|
+
export declare const formatStatusMessage: (status: ServiceStatus | null) => string;
|
|
3
12
|
declare const pluginConstructor: PluginConstructor;
|
|
4
13
|
export default pluginConstructor;
|
package/dist/plugin/index.js
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
import 'reflect-metadata';
|
|
6
6
|
import { CertStore } from './storage.js';
|
|
7
7
|
import { PassphraseSource } from './passphrase-source.js';
|
|
8
|
-
import { SslService } from './service.js';
|
|
8
|
+
import { SslService, discoverAdvertisedHostname } from './service.js';
|
|
9
9
|
import { startRenewalScheduler } from './scheduler.js';
|
|
10
10
|
import { buildPublicRoutes, registerAdminRoutes } from './api.js';
|
|
11
|
-
import {
|
|
11
|
+
import { buildConfigSchema, DEFAULT_CONFIG } from './schema.js';
|
|
12
12
|
const PLUGIN_ID = 'signalk-ssl';
|
|
13
13
|
const PLUGIN_NAME = 'SignalK SSL';
|
|
14
14
|
const PLUGIN_DESCRIPTION = 'Generate a local CA and issue trusted HTTPS certificates for your SignalK server.';
|
|
@@ -18,18 +18,61 @@ const resolveConfig = (raw) => {
|
|
|
18
18
|
const resolveConfigPath = (app, fallback) => {
|
|
19
19
|
return app.config?.configPath ?? fallback;
|
|
20
20
|
};
|
|
21
|
+
/**
|
|
22
|
+
* One-line status for the admin plugin list. statusMessage() must be
|
|
23
|
+
* synchronous, so this formats a cached {@link ServiceStatus} snapshot rather
|
|
24
|
+
* than reading the cert state from disk. Describes issuance state — not config —
|
|
25
|
+
* so an operator can tell "issued and healthy" from "no cert yet" or "error" at
|
|
26
|
+
* a glance. `null` snapshot means startup hasn't produced one yet.
|
|
27
|
+
*/
|
|
28
|
+
export const formatStatusMessage = (status) => {
|
|
29
|
+
if (status === null) {
|
|
30
|
+
return 'starting';
|
|
31
|
+
}
|
|
32
|
+
if (status.permissionWarning !== null) {
|
|
33
|
+
return `error: ${status.permissionWarning}`;
|
|
34
|
+
}
|
|
35
|
+
if (!status.hasCa) {
|
|
36
|
+
return 'no CA yet — enable and save config';
|
|
37
|
+
}
|
|
38
|
+
if (!status.hasLeaf) {
|
|
39
|
+
return 'CA ready, no certificate issued yet';
|
|
40
|
+
}
|
|
41
|
+
const name = status.leafSansDns[0] ?? status.leafSansIp[0] ?? 'certificate';
|
|
42
|
+
const days = status.leafDaysRemaining;
|
|
43
|
+
const expiry = days === null ? '' : ` · ${days.toString()}d left`;
|
|
44
|
+
const restart = status.restartRequired ? ' · restart to apply' : '';
|
|
45
|
+
return `${name}${expiry}${restart}`;
|
|
46
|
+
};
|
|
21
47
|
const pluginConstructor = (app) => {
|
|
22
48
|
const extended = app;
|
|
49
|
+
// The raw hostname signalk-server uses for mDNS (EXTERNALHOST → proxy_host →
|
|
50
|
+
// settings.hostname → os.hostname()), '' when unavailable.
|
|
51
|
+
const rawHostname = () => extended.config?.getExternalHostname?.() ?? '';
|
|
23
52
|
let scheduler = null;
|
|
24
53
|
let service = null;
|
|
25
54
|
let store = null;
|
|
26
55
|
let passphrase = null;
|
|
27
56
|
let config = DEFAULT_CONFIG;
|
|
57
|
+
// Cached snapshot for the synchronous statusMessage(); refreshed after each
|
|
58
|
+
// issue attempt. Approximate between refreshes (the webapp /status is live).
|
|
59
|
+
let lastStatus = null;
|
|
60
|
+
const refreshStatus = async (svc) => {
|
|
61
|
+
try {
|
|
62
|
+
lastStatus = await svc.status();
|
|
63
|
+
}
|
|
64
|
+
catch (e) {
|
|
65
|
+
app.error(`${PLUGIN_ID} status refresh failed: ${String(e)}`);
|
|
66
|
+
}
|
|
67
|
+
};
|
|
28
68
|
const plugin = {
|
|
29
69
|
id: PLUGIN_ID,
|
|
30
70
|
name: PLUGIN_NAME,
|
|
31
71
|
description: PLUGIN_DESCRIPTION,
|
|
32
|
-
|
|
72
|
+
// Re-evaluated by signalk-server on every config-screen load, so the
|
|
73
|
+
// discovered hostname is injected as the dnsNames default and shows up
|
|
74
|
+
// pre-filled in the form *before* the plugin is enabled.
|
|
75
|
+
schema: () => buildConfigSchema(discoverAdvertisedHostname(rawHostname())),
|
|
33
76
|
start(rawConfig, _restart) {
|
|
34
77
|
config = resolveConfig(rawConfig);
|
|
35
78
|
const dataDir = app.getDataDirPath();
|
|
@@ -38,10 +81,13 @@ const pluginConstructor = (app) => {
|
|
|
38
81
|
passphrase = new PassphraseSource(config.passphraseMode, store);
|
|
39
82
|
service = new SslService({ store, passphrase, config, configPath });
|
|
40
83
|
const svcLocal = service;
|
|
84
|
+
const storeLocal = store;
|
|
41
85
|
const logSchedulerError = (err) => {
|
|
42
86
|
app.error(`${PLUGIN_ID} scheduled renewal failed: ${String(err)}`);
|
|
43
87
|
};
|
|
44
|
-
void
|
|
88
|
+
void storeLocal
|
|
89
|
+
.init()
|
|
90
|
+
.then(async () => {
|
|
45
91
|
try {
|
|
46
92
|
const warning = await svcLocal.checkWritePermissions();
|
|
47
93
|
if (warning !== null) {
|
|
@@ -58,9 +104,18 @@ const pluginConstructor = (app) => {
|
|
|
58
104
|
catch (e) {
|
|
59
105
|
app.error(`${PLUGIN_ID} initial issue failed: ${String(e)}`);
|
|
60
106
|
}
|
|
107
|
+
await refreshStatus(svcLocal);
|
|
61
108
|
// Start the scheduler only after init + initial issue have completed,
|
|
62
109
|
// so a short test interval can't race against an uninitialised store.
|
|
63
110
|
scheduler = startRenewalScheduler(svcLocal, logSchedulerError);
|
|
111
|
+
})
|
|
112
|
+
// Terminal guard: store.init() (mkdir on a read-only / UID-shifted data
|
|
113
|
+
// dir) or a throw from startRenewalScheduler would otherwise surface as
|
|
114
|
+
// an unhandled rejection. The per-block try/catch above keep the issue
|
|
115
|
+
// flow going; this only catches what they can't. scheduler is left null
|
|
116
|
+
// on failure (the assignment is the last statement), so stop() is safe.
|
|
117
|
+
.catch((e) => {
|
|
118
|
+
app.error(`${PLUGIN_ID} startup failed: ${String(e)}`);
|
|
64
119
|
});
|
|
65
120
|
app.debug(`${PLUGIN_ID} started; data dir=${dataDir}; configPath=${configPath}`);
|
|
66
121
|
},
|
|
@@ -79,7 +134,10 @@ const pluginConstructor = (app) => {
|
|
|
79
134
|
store,
|
|
80
135
|
passphrase,
|
|
81
136
|
config,
|
|
82
|
-
getRawHostname:
|
|
137
|
+
getRawHostname: rawHostname,
|
|
138
|
+
onStatus: (s) => {
|
|
139
|
+
lastStatus = s;
|
|
140
|
+
}
|
|
83
141
|
});
|
|
84
142
|
},
|
|
85
143
|
signalKApiRoutes(router) {
|
|
@@ -89,11 +147,7 @@ const pluginConstructor = (app) => {
|
|
|
89
147
|
return buildPublicRoutes(router, { service, store, passphrase, config });
|
|
90
148
|
},
|
|
91
149
|
statusMessage() {
|
|
92
|
-
|
|
93
|
-
return 'starting';
|
|
94
|
-
}
|
|
95
|
-
// statusMessage must be synchronous; just describe the cached config.
|
|
96
|
-
return `mode=${config.mode}, sans=${(config.sans.dnsNames.length + config.sans.ipAddresses.length).toString()}`;
|
|
150
|
+
return formatStatusMessage(lastStatus);
|
|
97
151
|
}
|
|
98
152
|
};
|
|
99
153
|
return plugin;
|
package/dist/plugin/schema.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Static } from '@sinclair/typebox';
|
|
1
|
+
import { type Static, type TSchema } from '@sinclair/typebox';
|
|
2
2
|
export declare const SansSchema: import("@sinclair/typebox").TObject<{
|
|
3
3
|
dnsNames: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>;
|
|
4
4
|
ipAddresses: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>;
|
|
@@ -25,5 +25,17 @@ export declare const ConfigSchema: import("@sinclair/typebox").TObject<{
|
|
|
25
25
|
renewalThresholdDays: import("@sinclair/typebox").TInteger;
|
|
26
26
|
clockSkewHours: import("@sinclair/typebox").TInteger;
|
|
27
27
|
}>;
|
|
28
|
+
/**
|
|
29
|
+
* Return the config schema with `dnsNames` pre-filled with the discovered mDNS
|
|
30
|
+
* hostname, so the server-rendered config form shows it as a suggested default
|
|
31
|
+
* before the plugin is enabled. Falls back to the static {@link ConfigSchema}
|
|
32
|
+
* (empty default) when no useful hostname is available.
|
|
33
|
+
*
|
|
34
|
+
* `schema()` is re-invoked by signalk-server on every config-screen load, so
|
|
35
|
+
* this is evaluated fresh each time — no caching, picks up hostname changes.
|
|
36
|
+
* A default is a non-forcing suggestion: a user who clears it gets an empty
|
|
37
|
+
* list, no provenance tracking needed.
|
|
38
|
+
*/
|
|
39
|
+
export declare const buildConfigSchema: (dnsDefault: string | null) => TSchema;
|
|
28
40
|
export type SignalkSslConfig = Static<typeof ConfigSchema>;
|
|
29
41
|
export declare const DEFAULT_CONFIG: SignalkSslConfig;
|
package/dist/plugin/schema.js
CHANGED
|
@@ -71,6 +71,27 @@ export const ConfigSchema = Type.Object({
|
|
|
71
71
|
title: 'SignalK SSL',
|
|
72
72
|
description: 'Manage a local Certificate Authority and HTTPS leaf certificates for this server.'
|
|
73
73
|
});
|
|
74
|
+
/**
|
|
75
|
+
* Return the config schema with `dnsNames` pre-filled with the discovered mDNS
|
|
76
|
+
* hostname, so the server-rendered config form shows it as a suggested default
|
|
77
|
+
* before the plugin is enabled. Falls back to the static {@link ConfigSchema}
|
|
78
|
+
* (empty default) when no useful hostname is available.
|
|
79
|
+
*
|
|
80
|
+
* `schema()` is re-invoked by signalk-server on every config-screen load, so
|
|
81
|
+
* this is evaluated fresh each time — no caching, picks up hostname changes.
|
|
82
|
+
* A default is a non-forcing suggestion: a user who clears it gets an empty
|
|
83
|
+
* list, no provenance tracking needed.
|
|
84
|
+
*/
|
|
85
|
+
export const buildConfigSchema = (dnsDefault) => {
|
|
86
|
+
if (dnsDefault === null) {
|
|
87
|
+
return ConfigSchema;
|
|
88
|
+
}
|
|
89
|
+
// ConfigSchema is a plain JSON object at runtime; clone so we never mutate the
|
|
90
|
+
// shared static schema, then swap in the hostname default.
|
|
91
|
+
const clone = structuredClone(ConfigSchema);
|
|
92
|
+
clone.properties.sans.properties.dnsNames.default = [dnsDefault];
|
|
93
|
+
return clone;
|
|
94
|
+
};
|
|
74
95
|
export const DEFAULT_CONFIG = {
|
|
75
96
|
mode: 'generate',
|
|
76
97
|
commonName: 'SignalK Local CA',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-ssl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
4
4
|
"description": "SSL/TLS certificate management plugin for SignalK Node Server — generate a local CA, issue server certs, distribute the root via QR to phones/tablets",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/plugin/index.js",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"webapp": {
|
|
59
59
|
"name": "SignalK SSL",
|
|
60
60
|
"description": "SSL/TLS certificate management for SignalK",
|
|
61
|
-
"location": "/
|
|
61
|
+
"location": "/signalk-ssl/"
|
|
62
62
|
}
|
|
63
63
|
},
|
|
64
64
|
"peerDependencies": {
|
package/public/index.html
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
<meta charset="UTF-8" />
|
|
5
5
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
6
6
|
<title>SignalK SSL</title>
|
|
7
|
-
<script type="module" crossorigin src="/
|
|
8
|
-
<link rel="stylesheet" crossorigin href="/
|
|
7
|
+
<script type="module" crossorigin src="/signalk-ssl/assets/index-CVVhdLBc.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="/signalk-ssl/assets/index-xrow3Xg7.css">
|
|
9
9
|
</head>
|
|
10
10
|
<body class="bg-slate-50 text-slate-900">
|
|
11
11
|
<div id="root"></div>
|