signalk-ssl 0.6.0 → 0.8.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
@@ -2,7 +2,16 @@
2
2
 
3
3
  SSL/TLS certificate management for [SignalK Node Server](https://signalk.org/).
4
4
 
5
- Generates a local Certificate Authority, signs server certificates for your boat's hostnames and IP addresses, and provides a QR code so phones and tablets can install the CA root without SSH.
5
+ `signalk-ssl` turns HTTPS on your SignalK server from an SSH-and-`openssl` chore into a two-minute, point-and-click task — it runs a local Certificate Authority, issues and auto-renews trusted server certificates, and hands you a QR code to install the CA root on every phone and tablet aboard. Built to slot seamlessly into the [SignalK Universal Installer](https://github.com/dirkwa/signalk-universal-installer) stack, it also runs perfectly standalone on any vanilla SignalK Node Server.
6
+
7
+ ## Features
8
+
9
+ - **Local CA + trusted certs, zero terminal** — generates an EC Certificate Authority and signs HTTPS certificates for your boat's hostname and IPs, so browsers show a green padlock instead of a scary warning.
10
+ - **One-scan device trust** — a built-in QR code installs the CA root on iOS (`.mobileconfig`) and Android / desktop (`.crt`); no SSH, no file copying, no per-device fiddling.
11
+ - **Set-and-forget renewal** — certificates auto-renew before expiry and re-issue automatically when your SANs change, with a 24-hour clock-skew backdate so an offline boat's lagging phone clock never breaks trust.
12
+ - **Smart, server-aware defaults** — pre-fills the SAN fields with the exact `.local` hostname your server broadcasts on mDNS plus its private-LAN IPs, so the cert covers both name and IP out of the box, and shows live certificate health (name + days remaining) right in the admin status line.
13
+ - **Encrypted at rest, your choice of key** — the CA private key is always stored as encrypted PKCS#8, with `convenience` (no typing), `env` (environment variable), or `webapp` (prompt-based) passphrase modes.
14
+ - **Runs anywhere SignalK runs** — pure-JS, no native modules; works identically on bare-metal, systemd, and Docker / Podman installs, and is tuned for drop-in use with the SignalK Universal Installer.
6
15
 
7
16
  ## Why
8
17
 
@@ -10,7 +19,7 @@ Generates a local Certificate Authority, signs server certificates for your boat
10
19
  - Self-signed certs without a CA make every browser scream.
11
20
  - Doing it by hand means SSH, `openssl`, and `update-ca-certificates` per device — for non-technical boaters that's not happening.
12
21
 
13
- `signalk-ssl` collapses the whole flow into "open plugin → configure SANs → scan QR on each phone".
22
+ `signalk-ssl` collapses the whole flow into "open plugin → save the pre-filled SANs → scan QR on each phone".
14
23
 
15
24
  ## Prerequisites
16
25
 
@@ -25,13 +34,12 @@ The Appstore installs plugins with `npm install --ignore-scripts`, so this packa
25
34
 
26
35
  ## Configure
27
36
 
28
- 1. Enable the plugin in the SignalK admin UI.
29
- 2. Open the plugin configuration screen and fill in:
30
- - **SANs** — at least one DNS name (e.g. `signalk.local`, `boat.local`) and/or the boat's LAN IP. The webapp shows the discovered IPv4 addresses.
37
+ 1. Open the plugin configuration screen. The **SANs** fields come **pre-filled**: the `.local` hostname your server broadcasts on mDNS and the host's private-LAN IPv4 addresses are suggested as defaults, so most boats can leave them as-is. (They're suggestions — clear any you don't want, e.g. a VPN-overlay IP, before saving.)
38
+ 2. Review the rest:
31
39
  - **Passphrase mode** — `convenience` is the default and just works.
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
- 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 `/signalk-ssl/`.
40
+ - Defaults for CA validity (3650 days ≈ 10 years), leaf validity (397 days), renewal threshold (30 days), clock-skew backdate (24 hours) are fine for most boats.
41
+ 3. Save the config to enable the plugin. It generates the CA, signs a leaf certificate covering your SANs, and writes the cert to SignalK's TLS path (`ssl-cert.pem`, `ssl-key.pem`, `ssl-chain.pem` in the configured config directory). The admin status line then shows the cert name and days remaining.
42
+ 4. Open the plugin webapp from the admin **Webapps** tile (or at `/signalk-ssl/`).
35
43
  5. Restart SignalK so the new certificate is picked up by the HTTPS listener. (The webapp shows a banner reminding you.)
36
44
 
37
45
  ## Distribute the CA to phones
@@ -82,16 +90,16 @@ or `webapp` mode first.
82
90
  - `POST /plugins/signalk-ssl/unlock` — supply passphrase (webapp mode, admin auth required)
83
91
  - `POST /plugins/signalk-ssl/lock` — drop in-memory passphrase
84
92
  - `POST /plugins/signalk-ssl/rotate` — re-encrypt the CA key under a new passphrase (admin auth required)
85
- - `GET /signalk/v1/api/ssl/ca.crt` — **public** download of CA cert (PEM)
86
- - `GET /signalk/v1/api/ssl/ca.mobileconfig` — **public** download of Apple profile
93
+ - `GET /signalk-ssl/ca.crt` — **public** download of CA cert (PEM)
94
+ - `GET /signalk-ssl/ca.mobileconfig` — **public** download of Apple profile
87
95
 
88
- The two `/ssl/` paths are intentionally unauthenticated so phones without SignalK accounts can fetch the CA via the QR-coded URL. (`PUT`/`POST` on `/signalk/v1/api/*` is auto-protected by the server, so we can't accidentally expose a destructive endpoint here.)
96
+ The two `ca.*` downloads are intentionally unauthenticated so phones without SignalK accounts can fetch the CA via the QR-coded URL. They are mounted on the raw Express app under the `/signalk-ssl/` prefix (where the webapp static files live), which sits behind only signalk-server's permissive root middleware. This matters: the obvious home for them — `/signalk/v1/api/ssl/*` is fronted by middleware that returns **401** to any tokenless request when `allow_readonly` is disabled, which would break the QR flow on a hardened server. The `/signalk-ssl/` prefix stays reachable without a login regardless of the security config, and only `GET`s are exposed there.
89
97
 
90
98
  ## Container (Podman / Docker) notes
91
99
 
92
100
  - The plugin uses `app.getDataDirPath()`, which means the per-plugin data lives under SignalK's data volume — survives container rebuilds.
93
101
  - Certs land at `${app.config.configPath}/ssl-{cert,key,chain}.pem`, again on the data volume.
94
- - mDNS `.local` resolution does **not** work through Podman's default bridge network. Either run with `--network=host` or use IP-based SANs and DNS on your router. The webapp can show the LAN IP URL as a fallback when it detects this case.
102
+ - mDNS `.local` resolution does **not** work through Podman's default bridge network. Either run with `--network=host` or reach the server by IP — the plugin pre-fills the host's private-LAN IPs as SANs, so the issued cert already covers the IP URL. (On a bridge network the auto-detected hostname may be the random container ID, in which case the hostname suggestion is suppressed and you rely on the IP SANs.)
95
103
  - For outbound trust **inside** the container (so the SignalK Node process trusts its own CA when calling itself or another boat service), set `NODE_EXTRA_CA_CERTS=/path/to/ca.crt` in the container env. Node reads this before plugins load, so the plugin can't set it on itself — the value must be in your Quadlet/Compose/run script.
96
104
 
97
105
  ## Troubleshooting
@@ -100,7 +108,7 @@ The two `/ssl/` paths are intentionally unauthenticated so phones without Signal
100
108
 
101
109
  Three usual causes:
102
110
 
103
- 1. The leaf cert doesn't list the hostname in the SAN. Check the SANs in the plugin config; re-issue if you added one after the fact.
111
+ 1. The leaf cert doesn't list the name you browsed to in its SAN — e.g. you opened the server by IP but only the `.local` hostname is in the SANs (or vice versa). The cert must contain whatever you type in the address bar. Add the missing name/IP to the SANs in the plugin config and re-issue (**Renew now**).
104
112
  2. The phone's clock is more than 24 hours behind. This plugin backdates `notBefore` by 24h to soften this; if it's still too far off, fix NTP on the boat.
105
113
  3. The CA root isn't installed (or full trust isn't enabled in Settings → General → About → Certificate Trust Settings).
106
114
 
@@ -1,4 +1,4 @@
1
- import type { IRouter } from 'express';
1
+ import type { IRouter, RequestHandler } from 'express';
2
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';
@@ -22,3 +22,25 @@ export interface AdminApiDeps extends ApiDeps {
22
22
  export declare const registerAdminRoutes: (router: IRouter, deps: AdminApiDeps) => void;
23
23
  /** Mounted by the server at /signalk/v1/api/* via signalKApiRoutes — read-only public. */
24
24
  export declare const buildPublicRoutes: (router: IRouter, deps: ApiDeps) => IRouter;
25
+ /**
26
+ * Mount the public CA download on the raw Express app at /signalk-ssl/ca.*.
27
+ *
28
+ * The /signalk/v1/api/ssl/* surface (buildPublicRoutes) is fronted by
29
+ * signalk-server's `http_authorize` middleware, which hard-401s any tokenless
30
+ * request when `allow_readonly` is disabled — so a phone scanning the QR with
31
+ * no SignalK account gets "Unauthorized". The /signalk-ssl/ prefix (where the
32
+ * webapp static files live) is only under the permissive root middleware, so it
33
+ * stays reachable without auth even on hardened servers. We mount the CA files
34
+ * there so the QR-code flow works regardless of the security config.
35
+ */
36
+ export interface RawRouteMounter {
37
+ get: (path: string, handler: RequestHandler) => unknown;
38
+ }
39
+ /**
40
+ * These routes are mounted once per process on the raw Express app (Express
41
+ * keeps handlers across plugin restarts). So the handlers must not close over a
42
+ * captured `deps`: a config save/restart swaps the plugin's config and store for
43
+ * fresh objects, and a stale closure would keep serving the old CA metadata.
44
+ * Take a `getDeps` accessor instead and resolve it per request.
45
+ */
46
+ export declare const registerPublicCaRoutes: (app: RawRouteMounter, getDeps: () => ApiDeps) => void;
@@ -66,27 +66,41 @@ export const registerAdminRoutes = (router, deps) => {
66
66
  sendJson(res, status, out);
67
67
  }));
68
68
  };
69
+ // Shared handlers for the public CA download, used by both route surfaces below.
70
+ const sendCaCrt = (deps) => asyncRoute(async (_req, res) => {
71
+ const ca = await deps.store.readCaState();
72
+ if (ca === null) {
73
+ sendJson(res, 404, { error: 'no CA configured' });
74
+ return;
75
+ }
76
+ res.type('application/x-x509-ca-cert').send(ca.certificatePem);
77
+ });
78
+ const sendCaMobileconfig = (deps) => asyncRoute(async (_req, res) => {
79
+ const ca = await deps.store.readCaState();
80
+ if (ca === null) {
81
+ sendJson(res, 404, { error: 'no CA configured' });
82
+ return;
83
+ }
84
+ const xml = buildMobileconfig(ca.certificatePem, {
85
+ caName: deps.config.commonName,
86
+ organization: deps.config.organization
87
+ });
88
+ res.type('application/x-apple-aspen-config').send(xml);
89
+ });
69
90
  /** Mounted by the server at /signalk/v1/api/* via signalKApiRoutes — read-only public. */
70
91
  export const buildPublicRoutes = (router, deps) => {
71
- router.get('/ssl/ca.crt', asyncRoute(async (_req, res) => {
72
- const ca = await deps.store.readCaState();
73
- if (ca === null) {
74
- sendJson(res, 404, { error: 'no CA configured' });
75
- return;
76
- }
77
- res.type('application/x-x509-ca-cert').send(ca.certificatePem);
78
- }));
79
- router.get('/ssl/ca.mobileconfig', asyncRoute(async (_req, res) => {
80
- const ca = await deps.store.readCaState();
81
- if (ca === null) {
82
- sendJson(res, 404, { error: 'no CA configured' });
83
- return;
84
- }
85
- const xml = buildMobileconfig(ca.certificatePem, {
86
- caName: deps.config.commonName,
87
- organization: deps.config.organization
88
- });
89
- res.type('application/x-apple-aspen-config').send(xml);
90
- }));
92
+ router.get('/ssl/ca.crt', sendCaCrt(deps));
93
+ router.get('/ssl/ca.mobileconfig', sendCaMobileconfig(deps));
91
94
  return router;
92
95
  };
96
+ /**
97
+ * These routes are mounted once per process on the raw Express app (Express
98
+ * keeps handlers across plugin restarts). So the handlers must not close over a
99
+ * captured `deps`: a config save/restart swaps the plugin's config and store for
100
+ * fresh objects, and a stale closure would keep serving the old CA metadata.
101
+ * Take a `getDeps` accessor instead and resolve it per request.
102
+ */
103
+ export const registerPublicCaRoutes = (app, getDeps) => {
104
+ app.get('/signalk-ssl/ca.crt', (req, res, next) => sendCaCrt(getDeps())(req, res, next));
105
+ app.get('/signalk-ssl/ca.mobileconfig', (req, res, next) => sendCaMobileconfig(getDeps())(req, res, next));
106
+ };
@@ -5,13 +5,16 @@
5
5
  import 'reflect-metadata';
6
6
  import { CertStore } from './storage.js';
7
7
  import { PassphraseSource } from './passphrase-source.js';
8
- import { SslService, discoverAdvertisedHostname } from './service.js';
8
+ import { SslService, discoverAdvertisedHostname, discoverPrivateLanIps } from './service.js';
9
9
  import { startRenewalScheduler } from './scheduler.js';
10
- import { buildPublicRoutes, registerAdminRoutes } from './api.js';
10
+ import { buildPublicRoutes, registerAdminRoutes, registerPublicCaRoutes } from './api.js';
11
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.';
15
+ // Raw Express routes accumulate across plugin restarts; mount the public CA
16
+ // download only once per process.
17
+ let publicCaMounted = false;
15
18
  const resolveConfig = (raw) => {
16
19
  return { ...DEFAULT_CONFIG, ...raw };
17
20
  };
@@ -70,16 +73,53 @@ const pluginConstructor = (app) => {
70
73
  name: PLUGIN_NAME,
71
74
  description: PLUGIN_DESCRIPTION,
72
75
  // 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
+ // discovered hostname and private-LAN IPs are injected as SAN defaults and
77
+ // show up pre-filled in the form *before* the plugin is enabled.
78
+ schema: () => buildConfigSchema({
79
+ dnsName: discoverAdvertisedHostname(rawHostname()),
80
+ ipAddresses: discoverPrivateLanIps()
81
+ }),
76
82
  start(rawConfig, _restart) {
77
83
  config = resolveConfig(rawConfig);
78
84
  const dataDir = app.getDataDirPath();
79
85
  const configPath = resolveConfigPath(extended, dataDir);
80
86
  store = new CertStore(dataDir);
81
87
  passphrase = new PassphraseSource(config.passphraseMode, store);
82
- service = new SslService({ store, passphrase, config, configPath });
88
+ service = new SslService({
89
+ store,
90
+ passphrase,
91
+ config,
92
+ configPath,
93
+ // Evaluated per /status call so an `ssl: true` flip + restart is
94
+ // reflected immediately (the webapp polls /status). Preserves null when
95
+ // the server doesn't expose settings.ssl — collapsing that to false
96
+ // would falsely trigger the enable-HTTPS panel on older servers.
97
+ getServerNetState: () => {
98
+ const settings = extended.config?.settings;
99
+ return {
100
+ sslEnabled: typeof settings?.ssl === 'boolean' ? settings.ssl : null,
101
+ httpPort: typeof settings?.port === 'number' ? settings.port : null,
102
+ sslPort: typeof settings?.sslport === 'number' ? settings.sslport : null
103
+ };
104
+ }
105
+ });
106
+ // Mount the public CA download on the raw Express app (outside the
107
+ // auth-guarded /signalk/v1/api/*), so a phone scanning the QR can fetch
108
+ // the CA without a SignalK login even when allow_readonly is off. Once per
109
+ // process — Express keeps handlers across plugin restarts. The handlers
110
+ // read deps via an accessor so they pick up the current config/store after
111
+ // a restart instead of capturing the first start's objects.
112
+ if (!publicCaMounted && typeof extended.get === 'function') {
113
+ registerPublicCaRoutes({ get: extended.get.bind(extended) }, () => {
114
+ // start() always sets these before the routes can be hit; the closure
115
+ // reads them live so a restart's fresh objects are picked up.
116
+ if (service === null || store === null || passphrase === null) {
117
+ throw new Error(`${PLUGIN_ID} public CA route hit before start completed`);
118
+ }
119
+ return { service, store, passphrase, config };
120
+ });
121
+ publicCaMounted = true;
122
+ }
83
123
  const svcLocal = service;
84
124
  const storeLocal = store;
85
125
  const logSchedulerError = (err) => {
@@ -25,17 +25,23 @@ 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
+ export interface SchemaDefaults {
29
+ /** Discovered mDNS hostname (e.g. `pi5radar.local`), or null if none. */
30
+ readonly dnsName: string | null;
31
+ /** Discovered private-LAN IPv4 addresses to suggest as IP SANs. */
32
+ readonly ipAddresses: readonly string[];
33
+ }
28
34
  /**
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.
35
+ * Return the config schema with the SAN fields pre-filled with the discovered
36
+ * mDNS hostname and private-LAN IPs, so the server-rendered config form shows
37
+ * them as suggested defaults before the plugin is enabled. Falls back to the
38
+ * static {@link ConfigSchema} when nothing useful was discovered.
33
39
  *
34
40
  * `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.
41
+ * this is evaluated fresh each time — no caching, picks up hostname/IP changes.
42
+ * A default is a non-forcing suggestion: a user who clears a field gets an empty
43
+ * list, so no provenance tracking is needed.
38
44
  */
39
- export declare const buildConfigSchema: (dnsDefault: string | null) => TSchema;
45
+ export declare const buildConfigSchema: (defaults: SchemaDefaults) => TSchema;
40
46
  export type SignalkSslConfig = Static<typeof ConfigSchema>;
41
47
  export declare const DEFAULT_CONFIG: SignalkSslConfig;
@@ -72,24 +72,26 @@ export const ConfigSchema = Type.Object({
72
72
  description: 'Manage a local Certificate Authority and HTTPS leaf certificates for this server.'
73
73
  });
74
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.
75
+ * Return the config schema with the SAN fields pre-filled with the discovered
76
+ * mDNS hostname and private-LAN IPs, so the server-rendered config form shows
77
+ * them as suggested defaults before the plugin is enabled. Falls back to the
78
+ * static {@link ConfigSchema} when nothing useful was discovered.
79
79
  *
80
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.
81
+ * this is evaluated fresh each time — no caching, picks up hostname/IP changes.
82
+ * A default is a non-forcing suggestion: a user who clears a field gets an empty
83
+ * list, so no provenance tracking is needed.
84
84
  */
85
- export const buildConfigSchema = (dnsDefault) => {
86
- if (dnsDefault === null) {
85
+ export const buildConfigSchema = (defaults) => {
86
+ if (defaults.dnsName === null && defaults.ipAddresses.length === 0) {
87
87
  return ConfigSchema;
88
88
  }
89
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.
90
+ // shared static schema, then swap in the discovered SAN defaults.
91
91
  const clone = structuredClone(ConfigSchema);
92
- clone.properties.sans.properties.dnsNames.default = [dnsDefault];
92
+ clone.properties.sans.properties.dnsNames.default =
93
+ defaults.dnsName === null ? [] : [defaults.dnsName];
94
+ clone.properties.sans.properties.ipAddresses.default = [...defaults.ipAddresses];
93
95
  return clone;
94
96
  };
95
97
  export const DEFAULT_CONFIG = {
@@ -27,6 +27,17 @@ export type RotateOutcome = {
27
27
  readonly kind: 'error';
28
28
  readonly message: string;
29
29
  };
30
+ /** Runtime snapshot of signalk-server's HTTP/HTTPS binding state. */
31
+ export interface ServerNetState {
32
+ /** True/false when signalk-server reports settings.ssl, null when it doesn't
33
+ * (older server, or any case where the runtime can't tell). The webapp only
34
+ * shows the enable-HTTPS help on an explicit `false`, never on null. */
35
+ readonly sslEnabled: boolean | null;
36
+ /** Plain-HTTP port (signalk-server settings.port; default 3000). */
37
+ readonly httpPort: number | null;
38
+ /** HTTPS port (signalk-server settings.sslport; default 443). */
39
+ readonly sslPort: number | null;
40
+ }
30
41
  export interface ServiceStatus {
31
42
  readonly hasCa: boolean;
32
43
  readonly caFingerprint: string | null;
@@ -38,12 +49,23 @@ export interface ServiceStatus {
38
49
  readonly leafSansIp: readonly string[];
39
50
  readonly restartRequired: boolean;
40
51
  readonly permissionWarning: string | null;
52
+ /** Whether signalk-server is actually serving HTTPS. The plugin can install a
53
+ * cert and the server still serve plain HTTP if settings.ssl is false — the
54
+ * webapp surfaces help for that case. Null when the runtime can't tell. */
55
+ readonly serverSslEnabled: boolean | null;
56
+ /** Ports the server reports for the help/URL hints. Both null when unknown. */
57
+ readonly serverHttpPort: number | null;
58
+ readonly serverSslPort: number | null;
41
59
  }
42
60
  export interface SslServiceDeps {
43
61
  readonly store: CertStore;
44
62
  readonly passphrase: PassphraseSource;
45
63
  readonly config: SignalkSslConfig;
46
64
  readonly configPath: string;
65
+ /** Snapshot of signalk-server's net state, evaluated at request time so a
66
+ * settings flip is reflected immediately. Optional — when absent the status
67
+ * exposes nulls and the webapp suppresses the help. */
68
+ readonly getServerNetState?: () => ServerNetState;
47
69
  }
48
70
  export declare class SslService {
49
71
  private readonly deps;
@@ -78,10 +100,19 @@ export declare class SslService {
78
100
  private importCa;
79
101
  private signAndStoreLeaf;
80
102
  private ensureFilesOnDisk;
103
+ private serverState;
81
104
  status(): Promise<ServiceStatus>;
82
105
  acknowledgeRestart(): void;
83
106
  }
84
107
  export declare const discoverLocalIps: () => string[];
108
+ /**
109
+ * Discovered LAN IPv4 addresses suitable for seeding into the cert's
110
+ * `ipAddresses` SAN default — same source as {@link discoverLocalIps} but
111
+ * filtered to RFC-1918 private ranges. Returned as a non-forcing suggestion;
112
+ * the user can trim any (e.g. a VPN-overlay address) in the config form before
113
+ * saving.
114
+ */
115
+ export declare const discoverPrivateLanIps: () => string[];
85
116
  /**
86
117
  * Derive a DNS-name suggestion to add as a SAN, given the raw hostname
87
118
  * signalk-server uses for its mDNS advertisement
@@ -168,9 +168,17 @@ export class SslService {
168
168
  async ensureFilesOnDisk(leaf, ca) {
169
169
  await installCerts(this.targets(), leaf.certificatePem, leaf.privateKeyPem, ca.certificatePem);
170
170
  }
171
+ serverState() {
172
+ if (this.deps.getServerNetState === undefined) {
173
+ return { sslEnabled: null, httpPort: null, sslPort: null };
174
+ }
175
+ const s = this.deps.getServerNetState();
176
+ return { sslEnabled: s.sslEnabled, httpPort: s.httpPort, sslPort: s.sslPort };
177
+ }
171
178
  async status() {
172
179
  const caState = await this.deps.store.readCaState();
173
180
  const leafState = await this.deps.store.readLeafState();
181
+ const net = this.serverState();
174
182
  if (leafState === null) {
175
183
  return {
176
184
  hasCa: caState !== null,
@@ -182,7 +190,10 @@ export class SslService {
182
190
  leafSansDns: [],
183
191
  leafSansIp: [],
184
192
  restartRequired: this.restartRequired,
185
- permissionWarning: this.permissionWarning
193
+ permissionWarning: this.permissionWarning,
194
+ serverSslEnabled: net.sslEnabled,
195
+ serverHttpPort: net.httpPort,
196
+ serverSslPort: net.sslPort
186
197
  };
187
198
  }
188
199
  const decision = needsRenewal(leafState.certificatePem, this.resolveSans(), this.deps.config.renewalThresholdDays);
@@ -196,7 +207,10 @@ export class SslService {
196
207
  leafSansDns: this.deps.config.sans.dnsNames.map((d) => sanitizeHostname(d).toLowerCase()),
197
208
  leafSansIp: this.deps.config.sans.ipAddresses,
198
209
  restartRequired: this.restartRequired,
199
- permissionWarning: this.permissionWarning
210
+ permissionWarning: this.permissionWarning,
211
+ serverSslEnabled: net.sslEnabled,
212
+ serverHttpPort: net.httpPort,
213
+ serverSslPort: net.sslPort
200
214
  };
201
215
  }
202
216
  acknowledgeRestart() {
@@ -234,6 +248,36 @@ export const discoverLocalIps = () => {
234
248
  }
235
249
  return [...out];
236
250
  };
251
+ /**
252
+ * True for RFC-1918 private IPv4 ranges (10/8, 172.16/12, 192.168/16) — the
253
+ * address space a boat LAN actually uses. Public IPs are excluded so we never
254
+ * bake a routable address into a long-lived cert.
255
+ */
256
+ const isPrivateIpv4 = (ip) => {
257
+ const octets = ip.split('.').map(Number);
258
+ if (octets.length !== 4 || octets.some((o) => Number.isNaN(o) || o < 0 || o > 255)) {
259
+ return false;
260
+ }
261
+ const [a, b] = octets;
262
+ if (a === 10) {
263
+ return true;
264
+ }
265
+ if (a === 172 && b >= 16 && b <= 31) {
266
+ return true;
267
+ }
268
+ if (a === 192 && b === 168) {
269
+ return true;
270
+ }
271
+ return false;
272
+ };
273
+ /**
274
+ * Discovered LAN IPv4 addresses suitable for seeding into the cert's
275
+ * `ipAddresses` SAN default — same source as {@link discoverLocalIps} but
276
+ * filtered to RFC-1918 private ranges. Returned as a non-forcing suggestion;
277
+ * the user can trim any (e.g. a VPN-overlay address) in the config form before
278
+ * saving.
279
+ */
280
+ export const discoverPrivateLanIps = () => discoverLocalIps().filter(isPrivateIpv4);
237
281
  /**
238
282
  * Derive a DNS-name suggestion to add as a SAN, given the raw hostname
239
283
  * signalk-server uses for its mDNS advertisement
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "signalk-ssl",
3
- "version": "0.6.0",
3
+ "version": "0.8.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",
@@ -0,0 +1,2 @@
1
+ /*! tailwindcss v4.3.0 | MIT License | https://tailwindcss.com */
2
+ @layer properties{@supports (((-webkit-hyphens:none)) and (not (margin-trim:inline))) or ((-moz-orient:inline) and (not (color:rgb(from red r g b)))){*,:before,:after,::backdrop{--tw-space-y-reverse:0;--tw-border-style:solid;--tw-font-weight:initial;--tw-tracking:initial;--tw-shadow:0 0 #0000;--tw-shadow-color:initial;--tw-shadow-alpha:100%;--tw-inset-shadow:0 0 #0000;--tw-inset-shadow-color:initial;--tw-inset-shadow-alpha:100%;--tw-ring-color:initial;--tw-ring-shadow:0 0 #0000;--tw-inset-ring-color:initial;--tw-inset-ring-shadow:0 0 #0000;--tw-ring-inset:initial;--tw-ring-offset-width:0px;--tw-ring-offset-color:#fff;--tw-ring-offset-shadow:0 0 #0000}}}@layer theme{:root,:host{--font-sans:ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";--font-mono:ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;--color-red-50:oklch(97.1% .013 17.38);--color-red-300:oklch(80.8% .114 19.571);--color-red-500:oklch(63.7% .237 25.331);--color-red-800:oklch(44.4% .177 26.899);--color-red-900:oklch(39.6% .141 25.723);--color-amber-50:oklch(98.7% .022 95.277);--color-amber-100:oklch(96.2% .059 95.617);--color-amber-300:oklch(87.9% .169 91.605);--color-amber-500:oklch(76.9% .188 70.08);--color-amber-700:oklch(55.5% .163 48.998);--color-amber-800:oklch(47.3% .137 46.201);--color-amber-900:oklch(41.4% .112 45.904);--color-green-50:oklch(98.2% .018 155.826);--color-green-800:oklch(44.8% .119 151.328);--color-emerald-500:oklch(69.6% .17 162.48);--color-sky-50:oklch(97.7% .013 236.62);--color-sky-500:oklch(68.5% .169 237.323);--color-sky-600:oklch(58.8% .158 241.966);--color-sky-700:oklch(50% .134 242.749);--color-slate-50:oklch(98.4% .003 247.858);--color-slate-200:oklch(92.9% .013 255.508);--color-slate-300:oklch(86.9% .022 252.894);--color-slate-400:oklch(70.4% .04 256.788);--color-slate-500:oklch(55.4% .046 257.417);--color-slate-600:oklch(44.6% .043 257.281);--color-slate-700:oklch(37.2% .044 257.287);--color-slate-900:oklch(20.8% .042 265.755);--color-white:#fff;--spacing:.25rem;--container-3xl:48rem;--text-xs:.75rem;--text-xs--line-height:calc(1 / .75);--text-sm:.875rem;--text-sm--line-height:calc(1.25 / .875);--text-base:1rem;--text-base--line-height:calc(1.5 / 1);--text-lg:1.125rem;--text-lg--line-height:calc(1.75 / 1.125);--text-2xl:1.5rem;--text-2xl--line-height:calc(2 / 1.5);--font-weight-medium:500;--font-weight-semibold:600;--tracking-tight:-.025em;--radius-md:.375rem;--radius-lg:.5rem;--radius-xl:.75rem;--default-font-family:var(--font-sans);--default-mono-font-family:var(--font-mono)}}@layer base{*,:after,:before,::backdrop{box-sizing:border-box;border:0 solid;margin:0;padding:0}::file-selector-button{box-sizing:border-box;border:0 solid;margin:0;padding:0}html,:host{-webkit-text-size-adjust:100%;tab-size:4;line-height:1.5;font-family:var(--default-font-family,ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");font-feature-settings:var(--default-font-feature-settings,normal);font-variation-settings:var(--default-font-variation-settings,normal);-webkit-tap-highlight-color:transparent}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;-webkit-text-decoration:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:var(--default-mono-font-family,ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);font-feature-settings:var(--default-mono-font-feature-settings,normal);font-variation-settings:var(--default-mono-font-variation-settings,normal);font-size:1em}small{font-size:80%}sub,sup{vertical-align:baseline;font-size:75%;line-height:0;position:relative}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}:-moz-focusring{outline:auto}progress{vertical-align:baseline}summary{display:list-item}ol,ul,menu{list-style:none}img,svg,video,canvas,audio,iframe,embed,object{vertical-align:middle;display:block}img,video{max-width:100%;height:auto}button,input,select,optgroup,textarea{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}::file-selector-button{font:inherit;font-feature-settings:inherit;font-variation-settings:inherit;letter-spacing:inherit;color:inherit;opacity:1;background-color:#0000;border-radius:0}:where(select:is([multiple],[size])) optgroup{font-weight:bolder}:where(select:is([multiple],[size])) optgroup option{padding-inline-start:20px}::file-selector-button{margin-inline-end:4px}::placeholder{opacity:1}@supports (not ((-webkit-appearance:-apple-pay-button))) or (contain-intrinsic-size:1px){::placeholder{color:currentColor}@supports (color:color-mix(in lab, red, red)){::placeholder{color:color-mix(in oklab, currentcolor 50%, transparent)}}}textarea{resize:vertical}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-date-and-time-value{min-height:1lh;text-align:inherit}::-webkit-datetime-edit{display:inline-flex}::-webkit-datetime-edit-fields-wrapper{padding:0}::-webkit-datetime-edit{padding-block:0}::-webkit-datetime-edit-year-field{padding-block:0}::-webkit-datetime-edit-month-field{padding-block:0}::-webkit-datetime-edit-day-field{padding-block:0}::-webkit-datetime-edit-hour-field{padding-block:0}::-webkit-datetime-edit-minute-field{padding-block:0}::-webkit-datetime-edit-second-field{padding-block:0}::-webkit-datetime-edit-millisecond-field{padding-block:0}::-webkit-datetime-edit-meridiem-field{padding-block:0}::-webkit-calendar-picker-indicator{line-height:1}:-moz-ui-invalid{box-shadow:none}button,input:where([type=button],[type=reset],[type=submit]){appearance:button}::file-selector-button{appearance:button}::-webkit-inner-spin-button{height:auto}::-webkit-outer-spin-button{height:auto}[hidden]:where(:not([hidden=until-found])){display:none!important}}@layer components;@layer utilities{.sr-only{clip-path:inset(50%);white-space:nowrap;border-width:0;width:1px;height:1px;margin:-1px;padding:0;position:absolute;overflow:hidden}.absolute{position:absolute}.static{position:static}.container{width:100%}@media (width>=40rem){.container{max-width:40rem}}@media (width>=48rem){.container{max-width:48rem}}@media (width>=64rem){.container{max-width:64rem}}@media (width>=80rem){.container{max-width:80rem}}@media (width>=96rem){.container{max-width:96rem}}.mx-auto{margin-inline:auto}.mt-1{margin-top:calc(var(--spacing) * 1)}.mt-2{margin-top:calc(var(--spacing) * 2)}.mt-3{margin-top:calc(var(--spacing) * 3)}.mt-4{margin-top:calc(var(--spacing) * 4)}.mt-6{margin-top:calc(var(--spacing) * 6)}.mb-6{margin-bottom:calc(var(--spacing) * 6)}.block{display:block}.flex{display:flex}.grid{display:grid}.inline-flex{display:inline-flex}.h-2{height:calc(var(--spacing) * 2)}.h-8{height:calc(var(--spacing) * 8)}.w-2{width:calc(var(--spacing) * 2)}.w-8{width:calc(var(--spacing) * 8)}.w-full{width:100%}.max-w-3xl{max-width:var(--container-3xl)}.cursor-pointer{cursor:pointer}.list-inside{list-style-position:inside}.list-decimal{list-style-type:decimal}.list-disc{list-style-type:disc}.grid-cols-1{grid-template-columns:repeat(1,minmax(0,1fr))}.flex-col{flex-direction:column}.items-center{align-items:center}.justify-between{justify-content:space-between}.justify-center{justify-content:center}.gap-2{gap:calc(var(--spacing) * 2)}.gap-3{gap:calc(var(--spacing) * 3)}:where(.space-y-1>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 1) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 1) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-3>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 3) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 3) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-4>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 4) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 4) * calc(1 - var(--tw-space-y-reverse)))}:where(.space-y-6>:not(:last-child)){--tw-space-y-reverse:0;margin-block-start:calc(calc(var(--spacing) * 6) * var(--tw-space-y-reverse));margin-block-end:calc(calc(var(--spacing) * 6) * calc(1 - var(--tw-space-y-reverse)))}.rounded{border-radius:.25rem}.rounded-full{border-radius:3.40282e38px}.rounded-lg{border-radius:var(--radius-lg)}.rounded-md{border-radius:var(--radius-md)}.rounded-xl{border-radius:var(--radius-xl)}.border{border-style:var(--tw-border-style);border-width:1px}.border-amber-300{border-color:var(--color-amber-300)}.border-red-300{border-color:var(--color-red-300)}.border-sky-600{border-color:var(--color-sky-600)}.border-slate-200{border-color:var(--color-slate-200)}.border-slate-300{border-color:var(--color-slate-300)}.bg-amber-50{background-color:var(--color-amber-50)}.bg-amber-100{background-color:var(--color-amber-100)}.bg-amber-500{background-color:var(--color-amber-500)}.bg-emerald-500{background-color:var(--color-emerald-500)}.bg-green-50{background-color:var(--color-green-50)}.bg-red-50{background-color:var(--color-red-50)}.bg-red-500{background-color:var(--color-red-500)}.bg-sky-600{background-color:var(--color-sky-600)}.bg-slate-50{background-color:var(--color-slate-50)}.bg-slate-300{background-color:var(--color-slate-300)}.bg-white{background-color:var(--color-white)}.bg-white\/80{background-color:#fffc}@supports (color:color-mix(in lab, red, red)){.bg-white\/80{background-color:color-mix(in oklab, var(--color-white) 80%, transparent)}}.p-2{padding:calc(var(--spacing) * 2)}.p-3{padding:calc(var(--spacing) * 3)}.p-4{padding:calc(var(--spacing) * 4)}.p-5{padding:calc(var(--spacing) * 5)}.px-1{padding-inline:calc(var(--spacing) * 1)}.px-3{padding-inline:calc(var(--spacing) * 3)}.px-4{padding-inline:calc(var(--spacing) * 4)}.px-5{padding-inline:calc(var(--spacing) * 5)}.py-1{padding-block:calc(var(--spacing) * 1)}.py-2{padding-block:calc(var(--spacing) * 2)}.py-3{padding-block:calc(var(--spacing) * 3)}.py-6{padding-block:calc(var(--spacing) * 6)}.text-center{text-align:center}.text-left{text-align:left}.font-mono{font-family:var(--font-mono)}.font-sans{font-family:var(--font-sans)}.text-2xl{font-size:var(--text-2xl);line-height:var(--tw-leading,var(--text-2xl--line-height))}.text-base{font-size:var(--text-base);line-height:var(--tw-leading,var(--text-base--line-height))}.text-lg{font-size:var(--text-lg);line-height:var(--tw-leading,var(--text-lg--line-height))}.text-sm{font-size:var(--text-sm);line-height:var(--tw-leading,var(--text-sm--line-height))}.text-xs{font-size:var(--text-xs);line-height:var(--tw-leading,var(--text-xs--line-height))}.font-medium{--tw-font-weight:var(--font-weight-medium);font-weight:var(--font-weight-medium)}.font-semibold{--tw-font-weight:var(--font-weight-semibold);font-weight:var(--font-weight-semibold)}.tracking-tight{--tw-tracking:var(--tracking-tight);letter-spacing:var(--tracking-tight)}.break-all{word-break:break-all}.text-amber-700{color:var(--color-amber-700)}.text-amber-800{color:var(--color-amber-800)}.text-amber-900{color:var(--color-amber-900)}.text-green-800{color:var(--color-green-800)}.text-red-800{color:var(--color-red-800)}.text-red-900{color:var(--color-red-900)}.text-sky-600{color:var(--color-sky-600)}.text-sky-700{color:var(--color-sky-700)}.text-slate-400{color:var(--color-slate-400)}.text-slate-500{color:var(--color-slate-500)}.text-slate-600{color:var(--color-slate-600)}.text-slate-700{color:var(--color-slate-700)}.text-slate-900{color:var(--color-slate-900)}.text-white{color:var(--color-white)}.italic{font-style:italic}.underline{text-decoration-line:underline}.shadow-sm{--tw-shadow:0 1px 3px 0 var(--tw-shadow-color,#0000001a), 0 1px 2px -1px var(--tw-shadow-color,#0000001a);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}@media (hover:hover){.hover\:bg-sky-50:hover{background-color:var(--color-sky-50)}.hover\:bg-sky-700:hover{background-color:var(--color-sky-700)}.hover\:bg-slate-50:hover{background-color:var(--color-slate-50)}}.focus\:border-sky-500:focus{border-color:var(--color-sky-500)}.focus\:ring-2:focus{--tw-ring-shadow:var(--tw-ring-inset,) 0 0 0 calc(2px + var(--tw-ring-offset-width)) var(--tw-ring-color,currentcolor);box-shadow:var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow)}.focus\:ring-sky-500:focus{--tw-ring-color:var(--color-sky-500)}.focus\:ring-offset-2:focus{--tw-ring-offset-width:2px;--tw-ring-offset-shadow:var(--tw-ring-inset,) 0 0 0 var(--tw-ring-offset-width) var(--tw-ring-offset-color)}.focus\:outline-none:focus{--tw-outline-style:none;outline-style:none}.disabled\:cursor-not-allowed:disabled{cursor:not-allowed}.disabled\:bg-slate-300:disabled{background-color:var(--color-slate-300)}.disabled\:opacity-50:disabled{opacity:.5}@media (width>=40rem){.sm\:grid-cols-2{grid-template-columns:repeat(2,minmax(0,1fr))}.sm\:grid-cols-\[10rem_1fr\]{grid-template-columns:10rem 1fr}.sm\:py-10{padding-block:calc(var(--spacing) * 10)}}}@property --tw-space-y-reverse{syntax:"*";inherits:false;initial-value:0}@property --tw-border-style{syntax:"*";inherits:false;initial-value:solid}@property --tw-font-weight{syntax:"*";inherits:false}@property --tw-tracking{syntax:"*";inherits:false}@property --tw-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-shadow-color{syntax:"*";inherits:false}@property --tw-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-inset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-shadow-color{syntax:"*";inherits:false}@property --tw-inset-shadow-alpha{syntax:"<percentage>";inherits:false;initial-value:100%}@property --tw-ring-color{syntax:"*";inherits:false}@property --tw-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-inset-ring-color{syntax:"*";inherits:false}@property --tw-inset-ring-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}@property --tw-ring-inset{syntax:"*";inherits:false}@property --tw-ring-offset-width{syntax:"<length>";inherits:false;initial-value:0}@property --tw-ring-offset-color{syntax:"*";inherits:false;initial-value:#fff}@property --tw-ring-offset-shadow{syntax:"*";inherits:false;initial-value:0 0 #0000}