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 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;
@@ -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 { 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,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-shot seed: on first run with an empty SAN list, pre-populate dnsNames
24
- * with the name the server advertises on mDNS so the user sees a sensible,
25
- * server-specific default in the plugin config screen instead of a blank field.
26
- *
27
- * Guarded by a persistent marker so it runs at most once per install: a user
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 maybeSeedHostname = async (store, config, rawHostname, restart, log) => {
34
- if (!sansAreEmpty(config) || (await store.hasSeededHostname())) {
35
- return false;
28
+ export const formatStatusMessage = (status) => {
29
+ if (status === null) {
30
+ return 'starting';
36
31
  }
37
- const hostname = discoverAdvertisedHostname(rawHostname);
38
- if (hostname === null) {
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
- await store.markHostnameSeeded(hostname);
44
- const newConfig = {
45
- ...config,
46
- sans: { ...config.sans, dnsNames: [hostname] }
47
- };
48
- log(`${PLUGIN_ID} seeding empty SANs with discovered hostname ${hostname}`);
49
- restart(newConfig);
50
- return true;
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
- schema: () => ConfigSchema,
64
- start(rawConfig, restart) {
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: () => extended.config?.getExternalHostname?.() ?? ''
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
- if (service === null) {
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;
@@ -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',
@@ -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
  }
@@ -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.5.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>