signalk-ssl 0.4.0 → 0.5.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.
@@ -5,7 +5,7 @@
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
11
  import { ConfigSchema, DEFAULT_CONFIG } from './schema.js';
@@ -18,6 +18,37 @@ 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
+ /**
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.
32
+ */
33
+ const maybeSeedHostname = async (store, config, rawHostname, restart, log) => {
34
+ if (!sansAreEmpty(config) || (await store.hasSeededHostname())) {
35
+ return false;
36
+ }
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;
42
+ }
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;
51
+ };
21
52
  const pluginConstructor = (app) => {
22
53
  const extended = app;
23
54
  let scheduler = null;
@@ -30,7 +61,7 @@ const pluginConstructor = (app) => {
30
61
  name: PLUGIN_NAME,
31
62
  description: PLUGIN_DESCRIPTION,
32
63
  schema: () => ConfigSchema,
33
- start(rawConfig, _restart) {
64
+ start(rawConfig, restart) {
34
65
  config = resolveConfig(rawConfig);
35
66
  const dataDir = app.getDataDirPath();
36
67
  const configPath = resolveConfigPath(extended, dataDir);
@@ -38,10 +69,31 @@ const pluginConstructor = (app) => {
38
69
  passphrase = new PassphraseSource(config.passphraseMode, store);
39
70
  service = new SslService({ store, passphrase, config, configPath });
40
71
  const svcLocal = service;
72
+ const storeLocal = store;
73
+ const cfgLocal = config;
41
74
  const logSchedulerError = (err) => {
42
75
  app.error(`${PLUGIN_ID} scheduled renewal failed: ${String(err)}`);
43
76
  };
44
- void store.init().then(async () => {
77
+ void storeLocal
78
+ .init()
79
+ .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
+ }
45
97
  try {
46
98
  const warning = await svcLocal.checkWritePermissions();
47
99
  if (warning !== null) {
@@ -61,6 +113,14 @@ const pluginConstructor = (app) => {
61
113
  // Start the scheduler only after init + initial issue have completed,
62
114
  // so a short test interval can't race against an uninitialised store.
63
115
  scheduler = startRenewalScheduler(svcLocal, logSchedulerError);
116
+ })
117
+ // Terminal guard: store.init() (mkdir on a read-only / UID-shifted data
118
+ // dir) or a throw from startRenewalScheduler would otherwise surface as
119
+ // an unhandled rejection. The per-block try/catch above keep the issue
120
+ // flow going; this only catches what they can't. scheduler is left null
121
+ // on failure (the assignment is the last statement), so stop() is safe.
122
+ .catch((e) => {
123
+ app.error(`${PLUGIN_ID} startup failed: ${String(e)}`);
64
124
  });
65
125
  app.debug(`${PLUGIN_ID} started; data dir=${dataDir}; configPath=${configPath}`);
66
126
  },
@@ -34,4 +34,13 @@ 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>;
37
46
  }
@@ -9,7 +9,8 @@ const FILES = {
9
9
  state: 'state.json',
10
10
  leafState: 'leaf-state.json',
11
11
  settings: 'settings.json',
12
- convenience: 'passphrase.kdf.json'
12
+ convenience: 'passphrase.kdf.json',
13
+ hostnameSeeded: 'hostname-seeded.json'
13
14
  };
14
15
  const PEM_KEY_MODE = 0o600;
15
16
  const PEM_CERT_MODE = 0o644;
@@ -130,4 +131,19 @@ export class CertStore {
130
131
  await this.init();
131
132
  await atomicWrite(this.path('convenience'), JSON.stringify(env, null, 2), JSON_MODE);
132
133
  }
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
+ }
133
149
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-ssl",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",