signalk-ssl 0.5.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 +47 -53
- package/dist/plugin/schema.d.ts +13 -1
- package/dist/plugin/schema.js +21 -0
- package/dist/plugin/storage.d.ts +0 -9
- package/dist/plugin/storage.js +1 -17
- 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
|
@@ -8,7 +8,7 @@ import { PassphraseSource } from './passphrase-source.js';
|
|
|
8
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,50 +18,62 @@ const resolveConfig = (raw) => {
|
|
|
18
18
|
const resolveConfigPath = (app, fallback) => {
|
|
19
19
|
return app.config?.configPath ?? fallback;
|
|
20
20
|
};
|
|
21
|
-
const sansAreEmpty = (config) => config.sans.dnsNames.length === 0 && config.sans.ipAddresses.length === 0;
|
|
22
21
|
/**
|
|
23
|
-
* One-
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
* who deletes the seeded name will not see it re-added on the next restart
|
|
29
|
-
* (the SAN-provenance rule from AGENTS.md). Returns true when it triggered a
|
|
30
|
-
* restart, in which case the caller must stop — the plugin is being restarted
|
|
31
|
-
* with the new config.
|
|
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.
|
|
32
27
|
*/
|
|
33
|
-
const
|
|
34
|
-
if (
|
|
35
|
-
return
|
|
28
|
+
export const formatStatusMessage = (status) => {
|
|
29
|
+
if (status === null) {
|
|
30
|
+
return 'starting';
|
|
36
31
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
// No useful suggestion (container ID, localhost, etc.). Don't write the
|
|
40
|
-
// marker — if the host gets a real name later, we still want to seed then.
|
|
41
|
-
return false;
|
|
32
|
+
if (status.permissionWarning !== null) {
|
|
33
|
+
return `error: ${status.permissionWarning}`;
|
|
42
34
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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}`;
|
|
51
46
|
};
|
|
52
47
|
const pluginConstructor = (app) => {
|
|
53
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?.() ?? '';
|
|
54
52
|
let scheduler = null;
|
|
55
53
|
let service = null;
|
|
56
54
|
let store = null;
|
|
57
55
|
let passphrase = null;
|
|
58
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
|
+
};
|
|
59
68
|
const plugin = {
|
|
60
69
|
id: PLUGIN_ID,
|
|
61
70
|
name: PLUGIN_NAME,
|
|
62
71
|
description: PLUGIN_DESCRIPTION,
|
|
63
|
-
|
|
64
|
-
|
|
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())),
|
|
76
|
+
start(rawConfig, _restart) {
|
|
65
77
|
config = resolveConfig(rawConfig);
|
|
66
78
|
const dataDir = app.getDataDirPath();
|
|
67
79
|
const configPath = resolveConfigPath(extended, dataDir);
|
|
@@ -70,30 +82,12 @@ const pluginConstructor = (app) => {
|
|
|
70
82
|
service = new SslService({ store, passphrase, config, configPath });
|
|
71
83
|
const svcLocal = service;
|
|
72
84
|
const storeLocal = store;
|
|
73
|
-
const cfgLocal = config;
|
|
74
85
|
const logSchedulerError = (err) => {
|
|
75
86
|
app.error(`${PLUGIN_ID} scheduled renewal failed: ${String(err)}`);
|
|
76
87
|
};
|
|
77
88
|
void storeLocal
|
|
78
89
|
.init()
|
|
79
90
|
.then(async () => {
|
|
80
|
-
// First-run SAN seed must run before the issue flow: if it triggers a
|
|
81
|
-
// restart, start() runs again with the seeded config and the issue
|
|
82
|
-
// happens then. Bail out here so we don't issue against empty SANs and
|
|
83
|
-
// immediately get restarted out from under it.
|
|
84
|
-
try {
|
|
85
|
-
const rawHostname = extended.config?.getExternalHostname?.() ?? '';
|
|
86
|
-
const restarted = await maybeSeedHostname(storeLocal, cfgLocal, rawHostname, restart, (msg) => {
|
|
87
|
-
app.debug(msg);
|
|
88
|
-
});
|
|
89
|
-
if (restarted) {
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
catch (e) {
|
|
94
|
-
// A seed failure must never block cert issuance — log and continue.
|
|
95
|
-
app.error(`${PLUGIN_ID} hostname seed failed: ${String(e)}`);
|
|
96
|
-
}
|
|
97
91
|
try {
|
|
98
92
|
const warning = await svcLocal.checkWritePermissions();
|
|
99
93
|
if (warning !== null) {
|
|
@@ -110,6 +104,7 @@ const pluginConstructor = (app) => {
|
|
|
110
104
|
catch (e) {
|
|
111
105
|
app.error(`${PLUGIN_ID} initial issue failed: ${String(e)}`);
|
|
112
106
|
}
|
|
107
|
+
await refreshStatus(svcLocal);
|
|
113
108
|
// Start the scheduler only after init + initial issue have completed,
|
|
114
109
|
// so a short test interval can't race against an uninitialised store.
|
|
115
110
|
scheduler = startRenewalScheduler(svcLocal, logSchedulerError);
|
|
@@ -139,7 +134,10 @@ const pluginConstructor = (app) => {
|
|
|
139
134
|
store,
|
|
140
135
|
passphrase,
|
|
141
136
|
config,
|
|
142
|
-
getRawHostname:
|
|
137
|
+
getRawHostname: rawHostname,
|
|
138
|
+
onStatus: (s) => {
|
|
139
|
+
lastStatus = s;
|
|
140
|
+
}
|
|
143
141
|
});
|
|
144
142
|
},
|
|
145
143
|
signalKApiRoutes(router) {
|
|
@@ -149,11 +147,7 @@ const pluginConstructor = (app) => {
|
|
|
149
147
|
return buildPublicRoutes(router, { service, store, passphrase, config });
|
|
150
148
|
},
|
|
151
149
|
statusMessage() {
|
|
152
|
-
|
|
153
|
-
return 'starting';
|
|
154
|
-
}
|
|
155
|
-
// statusMessage must be synchronous; just describe the cached config.
|
|
156
|
-
return `mode=${config.mode}, sans=${(config.sans.dnsNames.length + config.sans.ipAddresses.length).toString()}`;
|
|
150
|
+
return formatStatusMessage(lastStatus);
|
|
157
151
|
}
|
|
158
152
|
};
|
|
159
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/dist/plugin/storage.d.ts
CHANGED
|
@@ -34,13 +34,4 @@ export declare class CertStore {
|
|
|
34
34
|
writeSettings(settings: SettingsOnDisk): Promise<void>;
|
|
35
35
|
readConvenienceEnvelope(): Promise<ConveniencePassphraseEnvelope | null>;
|
|
36
36
|
writeConvenienceEnvelope(env: ConveniencePassphraseEnvelope): Promise<void>;
|
|
37
|
-
/**
|
|
38
|
-
* Whether the plugin has already attempted its one-shot SAN seed (auto-adding
|
|
39
|
-
* the discovered mDNS hostname to an empty dnsNames list on first run). The
|
|
40
|
-
* marker makes the seed a one-time action: once it exists, a user who deletes
|
|
41
|
-
* the seeded name will not see it re-added on the next restart. Presence is
|
|
42
|
-
* the whole signal; the stored body is informational only.
|
|
43
|
-
*/
|
|
44
|
-
hasSeededHostname(): Promise<boolean>;
|
|
45
|
-
markHostnameSeeded(hostname: string): Promise<void>;
|
|
46
37
|
}
|
package/dist/plugin/storage.js
CHANGED
|
@@ -9,8 +9,7 @@ const FILES = {
|
|
|
9
9
|
state: 'state.json',
|
|
10
10
|
leafState: 'leaf-state.json',
|
|
11
11
|
settings: 'settings.json',
|
|
12
|
-
convenience: 'passphrase.kdf.json'
|
|
13
|
-
hostnameSeeded: 'hostname-seeded.json'
|
|
12
|
+
convenience: 'passphrase.kdf.json'
|
|
14
13
|
};
|
|
15
14
|
const PEM_KEY_MODE = 0o600;
|
|
16
15
|
const PEM_CERT_MODE = 0o644;
|
|
@@ -131,19 +130,4 @@ export class CertStore {
|
|
|
131
130
|
await this.init();
|
|
132
131
|
await atomicWrite(this.path('convenience'), JSON.stringify(env, null, 2), JSON_MODE);
|
|
133
132
|
}
|
|
134
|
-
/**
|
|
135
|
-
* Whether the plugin has already attempted its one-shot SAN seed (auto-adding
|
|
136
|
-
* the discovered mDNS hostname to an empty dnsNames list on first run). The
|
|
137
|
-
* marker makes the seed a one-time action: once it exists, a user who deletes
|
|
138
|
-
* the seeded name will not see it re-added on the next restart. Presence is
|
|
139
|
-
* the whole signal; the stored body is informational only.
|
|
140
|
-
*/
|
|
141
|
-
async hasSeededHostname() {
|
|
142
|
-
return (await readIfExists(this.path('hostnameSeeded'))) !== null;
|
|
143
|
-
}
|
|
144
|
-
async markHostnameSeeded(hostname) {
|
|
145
|
-
await this.init();
|
|
146
|
-
const body = JSON.stringify({ hostname, seededAt: new Date().toISOString() }, null, 2);
|
|
147
|
-
await atomicWrite(this.path('hostnameSeeded'), body, JSON_MODE);
|
|
148
|
-
}
|
|
149
133
|
}
|
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>
|