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 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 `/plugins/signalk-ssl/`.
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 /plugins/signalk-ssl/` — webapp (admin auth required)
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)
@@ -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;
@@ -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) => {
@@ -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;
@@ -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 { ConfigSchema, DEFAULT_CONFIG } from './schema.js';
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
- schema: () => ConfigSchema,
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 store.init().then(async () => {
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: () => extended.config?.getExternalHostname?.() ?? ''
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
- if (service === null) {
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;
@@ -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;
@@ -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.4.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": "/plugins/signalk-ssl/"
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="/plugins/signalk-ssl/assets/index-CVVhdLBc.js"></script>
8
- <link rel="stylesheet" crossorigin href="/plugins/signalk-ssl/assets/index-xrow3Xg7.css">
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>