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 +21 -13
- package/dist/plugin/api.d.ts +23 -1
- package/dist/plugin/api.js +34 -20
- package/dist/plugin/index.js +46 -6
- package/dist/plugin/schema.d.ts +14 -8
- package/dist/plugin/schema.js +13 -11
- package/dist/plugin/service.d.ts +31 -0
- package/dist/plugin/service.js +46 -2
- package/package.json +1 -1
- package/public/assets/index-A1lwNlrC.css +2 -0
- package/public/assets/{index-CVVhdLBc.js → index-DFpKCpIt.js} +1 -1
- package/public/index.html +2 -2
- package/public/assets/index-xrow3Xg7.css +0 -2
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
|
-
|
|
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 →
|
|
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.
|
|
29
|
-
2.
|
|
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
|
|
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
|
|
86
|
-
- `GET /signalk
|
|
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
|
|
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
|
|
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
|
|
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
|
|
package/dist/plugin/api.d.ts
CHANGED
|
@@ -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;
|
package/dist/plugin/api.js
CHANGED
|
@@ -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',
|
|
72
|
-
|
|
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
|
+
};
|
package/dist/plugin/index.js
CHANGED
|
@@ -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
|
|
74
|
-
// pre-filled in the form *before* the plugin is enabled.
|
|
75
|
-
schema: () => buildConfigSchema(
|
|
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({
|
|
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) => {
|
package/dist/plugin/schema.d.ts
CHANGED
|
@@ -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
|
|
30
|
-
* hostname, so the server-rendered config form shows
|
|
31
|
-
* before the plugin is enabled. Falls back to the
|
|
32
|
-
*
|
|
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
|
|
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: (
|
|
45
|
+
export declare const buildConfigSchema: (defaults: SchemaDefaults) => TSchema;
|
|
40
46
|
export type SignalkSslConfig = Static<typeof ConfigSchema>;
|
|
41
47
|
export declare const DEFAULT_CONFIG: SignalkSslConfig;
|
package/dist/plugin/schema.js
CHANGED
|
@@ -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
|
|
76
|
-
* hostname, so the server-rendered config form shows
|
|
77
|
-
* before the plugin is enabled. Falls back to the
|
|
78
|
-
*
|
|
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
|
|
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 = (
|
|
86
|
-
if (
|
|
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
|
|
90
|
+
// shared static schema, then swap in the discovered SAN defaults.
|
|
91
91
|
const clone = structuredClone(ConfigSchema);
|
|
92
|
-
clone.properties.sans.properties.dnsNames.default =
|
|
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 = {
|
package/dist/plugin/service.d.ts
CHANGED
|
@@ -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
|
package/dist/plugin/service.js
CHANGED
|
@@ -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.
|
|
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}
|