signalk-ssl 0.5.0 → 0.7.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 +22 -14
- package/dist/plugin/api.d.ts +28 -2
- package/dist/plugin/api.js +38 -20
- package/dist/plugin/index.d.ts +9 -0
- package/dist/plugin/index.js +72 -55
- package/dist/plugin/schema.d.ts +19 -1
- package/dist/plugin/schema.js +23 -0
- package/dist/plugin/service.d.ts +8 -0
- package/dist/plugin/service.js +30 -0
- package/dist/plugin/storage.d.ts +0 -9
- package/dist/plugin/storage.js +1 -17
- package/package.json +2 -2
- package/public/assets/index-CJ5rxBca.css +2 -0
- package/public/assets/{index-CVVhdLBc.js → index-DDHPjf9Z.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 `/
|
|
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
|
|
@@ -76,22 +84,22 @@ or `webapp` mode first.
|
|
|
76
84
|
|
|
77
85
|
## Routes
|
|
78
86
|
|
|
79
|
-
- `GET /
|
|
87
|
+
- `GET /signalk-ssl/` — webapp static files (served by signalk-server at the module name, admin auth required)
|
|
80
88
|
- `GET /plugins/signalk-ssl/status` — JSON status (admin auth required)
|
|
81
89
|
- `POST /plugins/signalk-ssl/renew` — issue / renew leaf (admin auth required)
|
|
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,5 +1,5 @@
|
|
|
1
|
-
import type { IRouter } from 'express';
|
|
2
|
-
import type { SslService } from './service.js';
|
|
1
|
+
import type { IRouter, RequestHandler } from 'express';
|
|
2
|
+
import type { SslService, ServiceStatus } from './service.js';
|
|
3
3
|
import type { SignalkSslConfig } from './schema.js';
|
|
4
4
|
import type { PassphraseSource } from './passphrase-source.js';
|
|
5
5
|
import type { CertStore } from './storage.js';
|
|
@@ -13,8 +13,34 @@ export interface AdminApiDeps extends ApiDeps {
|
|
|
13
13
|
/** Returns the raw hostname signalk-server uses for mDNS advertisement,
|
|
14
14
|
* or '' if unavailable. See ExtendedServerAPI in src/plugin/index.ts. */
|
|
15
15
|
readonly getRawHostname: () => string;
|
|
16
|
+
/** Called with each fresh status the admin routes compute, so the plugin can
|
|
17
|
+
* keep its synchronous statusMessage() cache current (the webapp polls
|
|
18
|
+
* /status, and /renew recomputes it). Optional. */
|
|
19
|
+
readonly onStatus?: (status: ServiceStatus) => void;
|
|
16
20
|
}
|
|
17
21
|
/** Mounted by the server at /plugins/signalk-ssl/* — admin auth applied. */
|
|
18
22
|
export declare const registerAdminRoutes: (router: IRouter, deps: AdminApiDeps) => void;
|
|
19
23
|
/** Mounted by the server at /signalk/v1/api/* via signalKApiRoutes — read-only public. */
|
|
20
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
|
@@ -12,6 +12,7 @@ const asyncRoute = (fn) => (req, res, next) => {
|
|
|
12
12
|
export const registerAdminRoutes = (router, deps) => {
|
|
13
13
|
router.get('/status', asyncRoute(async (_req, res) => {
|
|
14
14
|
const s = await deps.service.status();
|
|
15
|
+
deps.onStatus?.(s);
|
|
15
16
|
sendJson(res, 200, s);
|
|
16
17
|
}));
|
|
17
18
|
router.get('/api/local-ips', (_req, res) => {
|
|
@@ -22,6 +23,9 @@ export const registerAdminRoutes = (router, deps) => {
|
|
|
22
23
|
});
|
|
23
24
|
router.post('/renew', asyncRoute(async (_req, res) => {
|
|
24
25
|
const out = await deps.service.issueIfNeeded();
|
|
26
|
+
if (deps.onStatus) {
|
|
27
|
+
deps.onStatus(await deps.service.status());
|
|
28
|
+
}
|
|
25
29
|
sendJson(res, 200, out);
|
|
26
30
|
}));
|
|
27
31
|
router.post('/unlock', asyncRoute(async (req, res) => {
|
|
@@ -62,27 +66,41 @@ export const registerAdminRoutes = (router, deps) => {
|
|
|
62
66
|
sendJson(res, status, out);
|
|
63
67
|
}));
|
|
64
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
|
+
});
|
|
65
90
|
/** Mounted by the server at /signalk/v1/api/* via signalKApiRoutes — read-only public. */
|
|
66
91
|
export const buildPublicRoutes = (router, deps) => {
|
|
67
|
-
router.get('/ssl/ca.crt',
|
|
68
|
-
|
|
69
|
-
if (ca === null) {
|
|
70
|
-
sendJson(res, 404, { error: 'no CA configured' });
|
|
71
|
-
return;
|
|
72
|
-
}
|
|
73
|
-
res.type('application/x-x509-ca-cert').send(ca.certificatePem);
|
|
74
|
-
}));
|
|
75
|
-
router.get('/ssl/ca.mobileconfig', asyncRoute(async (_req, res) => {
|
|
76
|
-
const ca = await deps.store.readCaState();
|
|
77
|
-
if (ca === null) {
|
|
78
|
-
sendJson(res, 404, { error: 'no CA configured' });
|
|
79
|
-
return;
|
|
80
|
-
}
|
|
81
|
-
const xml = buildMobileconfig(ca.certificatePem, {
|
|
82
|
-
caName: deps.config.commonName,
|
|
83
|
-
organization: deps.config.organization
|
|
84
|
-
});
|
|
85
|
-
res.type('application/x-apple-aspen-config').send(xml);
|
|
86
|
-
}));
|
|
92
|
+
router.get('/ssl/ca.crt', sendCaCrt(deps));
|
|
93
|
+
router.get('/ssl/ca.mobileconfig', sendCaMobileconfig(deps));
|
|
87
94
|
return router;
|
|
88
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.d.ts
CHANGED
|
@@ -1,4 +1,13 @@
|
|
|
1
1
|
import 'reflect-metadata';
|
|
2
2
|
import type { PluginConstructor } from '@signalk/server-api';
|
|
3
|
+
import { type ServiceStatus } from './service.js';
|
|
4
|
+
/**
|
|
5
|
+
* One-line status for the admin plugin list. statusMessage() must be
|
|
6
|
+
* synchronous, so this formats a cached {@link ServiceStatus} snapshot rather
|
|
7
|
+
* than reading the cert state from disk. Describes issuance state — not config —
|
|
8
|
+
* so an operator can tell "issued and healthy" from "no cert yet" or "error" at
|
|
9
|
+
* a glance. `null` snapshot means startup hasn't produced one yet.
|
|
10
|
+
*/
|
|
11
|
+
export declare const formatStatusMessage: (status: ServiceStatus | null) => string;
|
|
3
12
|
declare const pluginConstructor: PluginConstructor;
|
|
4
13
|
export default pluginConstructor;
|
package/dist/plugin/index.js
CHANGED
|
@@ -5,95 +5,112 @@
|
|
|
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';
|
|
11
|
-
import {
|
|
10
|
+
import { buildPublicRoutes, registerAdminRoutes, registerPublicCaRoutes } from './api.js';
|
|
11
|
+
import { buildConfigSchema, DEFAULT_CONFIG } from './schema.js';
|
|
12
12
|
const PLUGIN_ID = 'signalk-ssl';
|
|
13
13
|
const PLUGIN_NAME = 'SignalK SSL';
|
|
14
14
|
const PLUGIN_DESCRIPTION = 'Generate a local CA and issue trusted HTTPS certificates for your SignalK server.';
|
|
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
|
};
|
|
18
21
|
const resolveConfigPath = (app, fallback) => {
|
|
19
22
|
return app.config?.configPath ?? fallback;
|
|
20
23
|
};
|
|
21
|
-
const sansAreEmpty = (config) => config.sans.dnsNames.length === 0 && config.sans.ipAddresses.length === 0;
|
|
22
24
|
/**
|
|
23
|
-
* One-
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
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.
|
|
25
|
+
* One-line status for the admin plugin list. statusMessage() must be
|
|
26
|
+
* synchronous, so this formats a cached {@link ServiceStatus} snapshot rather
|
|
27
|
+
* than reading the cert state from disk. Describes issuance state — not config —
|
|
28
|
+
* so an operator can tell "issued and healthy" from "no cert yet" or "error" at
|
|
29
|
+
* a glance. `null` snapshot means startup hasn't produced one yet.
|
|
32
30
|
*/
|
|
33
|
-
const
|
|
34
|
-
if (
|
|
35
|
-
return
|
|
31
|
+
export const formatStatusMessage = (status) => {
|
|
32
|
+
if (status === null) {
|
|
33
|
+
return 'starting';
|
|
36
34
|
}
|
|
37
|
-
|
|
38
|
-
|
|
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;
|
|
35
|
+
if (status.permissionWarning !== null) {
|
|
36
|
+
return `error: ${status.permissionWarning}`;
|
|
42
37
|
}
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
38
|
+
if (!status.hasCa) {
|
|
39
|
+
return 'no CA yet — enable and save config';
|
|
40
|
+
}
|
|
41
|
+
if (!status.hasLeaf) {
|
|
42
|
+
return 'CA ready, no certificate issued yet';
|
|
43
|
+
}
|
|
44
|
+
const name = status.leafSansDns[0] ?? status.leafSansIp[0] ?? 'certificate';
|
|
45
|
+
const days = status.leafDaysRemaining;
|
|
46
|
+
const expiry = days === null ? '' : ` · ${days.toString()}d left`;
|
|
47
|
+
const restart = status.restartRequired ? ' · restart to apply' : '';
|
|
48
|
+
return `${name}${expiry}${restart}`;
|
|
51
49
|
};
|
|
52
50
|
const pluginConstructor = (app) => {
|
|
53
51
|
const extended = app;
|
|
52
|
+
// The raw hostname signalk-server uses for mDNS (EXTERNALHOST → proxy_host →
|
|
53
|
+
// settings.hostname → os.hostname()), '' when unavailable.
|
|
54
|
+
const rawHostname = () => extended.config?.getExternalHostname?.() ?? '';
|
|
54
55
|
let scheduler = null;
|
|
55
56
|
let service = null;
|
|
56
57
|
let store = null;
|
|
57
58
|
let passphrase = null;
|
|
58
59
|
let config = DEFAULT_CONFIG;
|
|
60
|
+
// Cached snapshot for the synchronous statusMessage(); refreshed after each
|
|
61
|
+
// issue attempt. Approximate between refreshes (the webapp /status is live).
|
|
62
|
+
let lastStatus = null;
|
|
63
|
+
const refreshStatus = async (svc) => {
|
|
64
|
+
try {
|
|
65
|
+
lastStatus = await svc.status();
|
|
66
|
+
}
|
|
67
|
+
catch (e) {
|
|
68
|
+
app.error(`${PLUGIN_ID} status refresh failed: ${String(e)}`);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
59
71
|
const plugin = {
|
|
60
72
|
id: PLUGIN_ID,
|
|
61
73
|
name: PLUGIN_NAME,
|
|
62
74
|
description: PLUGIN_DESCRIPTION,
|
|
63
|
-
|
|
64
|
-
|
|
75
|
+
// Re-evaluated by signalk-server on every config-screen load, so the
|
|
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
|
+
}),
|
|
82
|
+
start(rawConfig, _restart) {
|
|
65
83
|
config = resolveConfig(rawConfig);
|
|
66
84
|
const dataDir = app.getDataDirPath();
|
|
67
85
|
const configPath = resolveConfigPath(extended, dataDir);
|
|
68
86
|
store = new CertStore(dataDir);
|
|
69
87
|
passphrase = new PassphraseSource(config.passphraseMode, store);
|
|
70
88
|
service = new SslService({ store, passphrase, config, configPath });
|
|
89
|
+
// Mount the public CA download on the raw Express app (outside the
|
|
90
|
+
// auth-guarded /signalk/v1/api/*), so a phone scanning the QR can fetch
|
|
91
|
+
// the CA without a SignalK login even when allow_readonly is off. Once per
|
|
92
|
+
// process — Express keeps handlers across plugin restarts. The handlers
|
|
93
|
+
// read deps via an accessor so they pick up the current config/store after
|
|
94
|
+
// a restart instead of capturing the first start's objects.
|
|
95
|
+
if (!publicCaMounted && typeof extended.get === 'function') {
|
|
96
|
+
registerPublicCaRoutes({ get: extended.get.bind(extended) }, () => {
|
|
97
|
+
// start() always sets these before the routes can be hit; the closure
|
|
98
|
+
// reads them live so a restart's fresh objects are picked up.
|
|
99
|
+
if (service === null || store === null || passphrase === null) {
|
|
100
|
+
throw new Error(`${PLUGIN_ID} public CA route hit before start completed`);
|
|
101
|
+
}
|
|
102
|
+
return { service, store, passphrase, config };
|
|
103
|
+
});
|
|
104
|
+
publicCaMounted = true;
|
|
105
|
+
}
|
|
71
106
|
const svcLocal = service;
|
|
72
107
|
const storeLocal = store;
|
|
73
|
-
const cfgLocal = config;
|
|
74
108
|
const logSchedulerError = (err) => {
|
|
75
109
|
app.error(`${PLUGIN_ID} scheduled renewal failed: ${String(err)}`);
|
|
76
110
|
};
|
|
77
111
|
void storeLocal
|
|
78
112
|
.init()
|
|
79
113
|
.then(async () => {
|
|
80
|
-
// First-run SAN seed must run before the issue flow: if it triggers a
|
|
81
|
-
// restart, start() runs again with the seeded config and the issue
|
|
82
|
-
// happens then. Bail out here so we don't issue against empty SANs and
|
|
83
|
-
// immediately get restarted out from under it.
|
|
84
|
-
try {
|
|
85
|
-
const rawHostname = extended.config?.getExternalHostname?.() ?? '';
|
|
86
|
-
const restarted = await maybeSeedHostname(storeLocal, cfgLocal, rawHostname, restart, (msg) => {
|
|
87
|
-
app.debug(msg);
|
|
88
|
-
});
|
|
89
|
-
if (restarted) {
|
|
90
|
-
return;
|
|
91
|
-
}
|
|
92
|
-
}
|
|
93
|
-
catch (e) {
|
|
94
|
-
// A seed failure must never block cert issuance — log and continue.
|
|
95
|
-
app.error(`${PLUGIN_ID} hostname seed failed: ${String(e)}`);
|
|
96
|
-
}
|
|
97
114
|
try {
|
|
98
115
|
const warning = await svcLocal.checkWritePermissions();
|
|
99
116
|
if (warning !== null) {
|
|
@@ -110,6 +127,7 @@ const pluginConstructor = (app) => {
|
|
|
110
127
|
catch (e) {
|
|
111
128
|
app.error(`${PLUGIN_ID} initial issue failed: ${String(e)}`);
|
|
112
129
|
}
|
|
130
|
+
await refreshStatus(svcLocal);
|
|
113
131
|
// Start the scheduler only after init + initial issue have completed,
|
|
114
132
|
// so a short test interval can't race against an uninitialised store.
|
|
115
133
|
scheduler = startRenewalScheduler(svcLocal, logSchedulerError);
|
|
@@ -139,7 +157,10 @@ const pluginConstructor = (app) => {
|
|
|
139
157
|
store,
|
|
140
158
|
passphrase,
|
|
141
159
|
config,
|
|
142
|
-
getRawHostname:
|
|
160
|
+
getRawHostname: rawHostname,
|
|
161
|
+
onStatus: (s) => {
|
|
162
|
+
lastStatus = s;
|
|
163
|
+
}
|
|
143
164
|
});
|
|
144
165
|
},
|
|
145
166
|
signalKApiRoutes(router) {
|
|
@@ -149,11 +170,7 @@ const pluginConstructor = (app) => {
|
|
|
149
170
|
return buildPublicRoutes(router, { service, store, passphrase, config });
|
|
150
171
|
},
|
|
151
172
|
statusMessage() {
|
|
152
|
-
|
|
153
|
-
return 'starting';
|
|
154
|
-
}
|
|
155
|
-
// statusMessage must be synchronous; just describe the cached config.
|
|
156
|
-
return `mode=${config.mode}, sans=${(config.sans.dnsNames.length + config.sans.ipAddresses.length).toString()}`;
|
|
173
|
+
return formatStatusMessage(lastStatus);
|
|
157
174
|
}
|
|
158
175
|
};
|
|
159
176
|
return plugin;
|
package/dist/plugin/schema.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { type Static } from '@sinclair/typebox';
|
|
1
|
+
import { type Static, type TSchema } from '@sinclair/typebox';
|
|
2
2
|
export declare const SansSchema: import("@sinclair/typebox").TObject<{
|
|
3
3
|
dnsNames: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>;
|
|
4
4
|
ipAddresses: import("@sinclair/typebox").TArray<import("@sinclair/typebox").TString>;
|
|
@@ -25,5 +25,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
|
+
}
|
|
34
|
+
/**
|
|
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.
|
|
39
|
+
*
|
|
40
|
+
* `schema()` is re-invoked by signalk-server on every config-screen load, so
|
|
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.
|
|
44
|
+
*/
|
|
45
|
+
export declare const buildConfigSchema: (defaults: SchemaDefaults) => TSchema;
|
|
28
46
|
export type SignalkSslConfig = Static<typeof ConfigSchema>;
|
|
29
47
|
export declare const DEFAULT_CONFIG: SignalkSslConfig;
|
package/dist/plugin/schema.js
CHANGED
|
@@ -71,6 +71,29 @@ export const ConfigSchema = Type.Object({
|
|
|
71
71
|
title: 'SignalK SSL',
|
|
72
72
|
description: 'Manage a local Certificate Authority and HTTPS leaf certificates for this server.'
|
|
73
73
|
});
|
|
74
|
+
/**
|
|
75
|
+
* Return the config schema with 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
|
+
*
|
|
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/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
|
+
*/
|
|
85
|
+
export const buildConfigSchema = (defaults) => {
|
|
86
|
+
if (defaults.dnsName === null && defaults.ipAddresses.length === 0) {
|
|
87
|
+
return ConfigSchema;
|
|
88
|
+
}
|
|
89
|
+
// ConfigSchema is a plain JSON object at runtime; clone so we never mutate the
|
|
90
|
+
// shared static schema, then swap in the discovered SAN defaults.
|
|
91
|
+
const clone = structuredClone(ConfigSchema);
|
|
92
|
+
clone.properties.sans.properties.dnsNames.default =
|
|
93
|
+
defaults.dnsName === null ? [] : [defaults.dnsName];
|
|
94
|
+
clone.properties.sans.properties.ipAddresses.default = [...defaults.ipAddresses];
|
|
95
|
+
return clone;
|
|
96
|
+
};
|
|
74
97
|
export const DEFAULT_CONFIG = {
|
|
75
98
|
mode: 'generate',
|
|
76
99
|
commonName: 'SignalK Local CA',
|
package/dist/plugin/service.d.ts
CHANGED
|
@@ -82,6 +82,14 @@ export declare class SslService {
|
|
|
82
82
|
acknowledgeRestart(): void;
|
|
83
83
|
}
|
|
84
84
|
export declare const discoverLocalIps: () => string[];
|
|
85
|
+
/**
|
|
86
|
+
* Discovered LAN IPv4 addresses suitable for seeding into the cert's
|
|
87
|
+
* `ipAddresses` SAN default — same source as {@link discoverLocalIps} but
|
|
88
|
+
* filtered to RFC-1918 private ranges. Returned as a non-forcing suggestion;
|
|
89
|
+
* the user can trim any (e.g. a VPN-overlay address) in the config form before
|
|
90
|
+
* saving.
|
|
91
|
+
*/
|
|
92
|
+
export declare const discoverPrivateLanIps: () => string[];
|
|
85
93
|
/**
|
|
86
94
|
* Derive a DNS-name suggestion to add as a SAN, given the raw hostname
|
|
87
95
|
* signalk-server uses for its mDNS advertisement
|
package/dist/plugin/service.js
CHANGED
|
@@ -234,6 +234,36 @@ export const discoverLocalIps = () => {
|
|
|
234
234
|
}
|
|
235
235
|
return [...out];
|
|
236
236
|
};
|
|
237
|
+
/**
|
|
238
|
+
* True for RFC-1918 private IPv4 ranges (10/8, 172.16/12, 192.168/16) — the
|
|
239
|
+
* address space a boat LAN actually uses. Public IPs are excluded so we never
|
|
240
|
+
* bake a routable address into a long-lived cert.
|
|
241
|
+
*/
|
|
242
|
+
const isPrivateIpv4 = (ip) => {
|
|
243
|
+
const octets = ip.split('.').map(Number);
|
|
244
|
+
if (octets.length !== 4 || octets.some((o) => Number.isNaN(o) || o < 0 || o > 255)) {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
const [a, b] = octets;
|
|
248
|
+
if (a === 10) {
|
|
249
|
+
return true;
|
|
250
|
+
}
|
|
251
|
+
if (a === 172 && b >= 16 && b <= 31) {
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
if (a === 192 && b === 168) {
|
|
255
|
+
return true;
|
|
256
|
+
}
|
|
257
|
+
return false;
|
|
258
|
+
};
|
|
259
|
+
/**
|
|
260
|
+
* Discovered LAN IPv4 addresses suitable for seeding into the cert's
|
|
261
|
+
* `ipAddresses` SAN default — same source as {@link discoverLocalIps} but
|
|
262
|
+
* filtered to RFC-1918 private ranges. Returned as a non-forcing suggestion;
|
|
263
|
+
* the user can trim any (e.g. a VPN-overlay address) in the config form before
|
|
264
|
+
* saving.
|
|
265
|
+
*/
|
|
266
|
+
export const discoverPrivateLanIps = () => discoverLocalIps().filter(isPrivateIpv4);
|
|
237
267
|
/**
|
|
238
268
|
* Derive a DNS-name suggestion to add as a SAN, given the raw hostname
|
|
239
269
|
* signalk-server uses for its mDNS advertisement
|
package/dist/plugin/storage.d.ts
CHANGED
|
@@ -34,13 +34,4 @@ export declare class CertStore {
|
|
|
34
34
|
writeSettings(settings: SettingsOnDisk): Promise<void>;
|
|
35
35
|
readConvenienceEnvelope(): Promise<ConveniencePassphraseEnvelope | null>;
|
|
36
36
|
writeConvenienceEnvelope(env: ConveniencePassphraseEnvelope): Promise<void>;
|
|
37
|
-
/**
|
|
38
|
-
* Whether the plugin has already attempted its one-shot SAN seed (auto-adding
|
|
39
|
-
* the discovered mDNS hostname to an empty dnsNames list on first run). The
|
|
40
|
-
* marker makes the seed a one-time action: once it exists, a user who deletes
|
|
41
|
-
* the seeded name will not see it re-added on the next restart. Presence is
|
|
42
|
-
* the whole signal; the stored body is informational only.
|
|
43
|
-
*/
|
|
44
|
-
hasSeededHostname(): Promise<boolean>;
|
|
45
|
-
markHostnameSeeded(hostname: string): Promise<void>;
|
|
46
37
|
}
|
package/dist/plugin/storage.js
CHANGED
|
@@ -9,8 +9,7 @@ const FILES = {
|
|
|
9
9
|
state: 'state.json',
|
|
10
10
|
leafState: 'leaf-state.json',
|
|
11
11
|
settings: 'settings.json',
|
|
12
|
-
convenience: 'passphrase.kdf.json'
|
|
13
|
-
hostnameSeeded: 'hostname-seeded.json'
|
|
12
|
+
convenience: 'passphrase.kdf.json'
|
|
14
13
|
};
|
|
15
14
|
const PEM_KEY_MODE = 0o600;
|
|
16
15
|
const PEM_CERT_MODE = 0o644;
|
|
@@ -131,19 +130,4 @@ export class CertStore {
|
|
|
131
130
|
await this.init();
|
|
132
131
|
await atomicWrite(this.path('convenience'), JSON.stringify(env, null, 2), JSON_MODE);
|
|
133
132
|
}
|
|
134
|
-
/**
|
|
135
|
-
* Whether the plugin has already attempted its one-shot SAN seed (auto-adding
|
|
136
|
-
* the discovered mDNS hostname to an empty dnsNames list on first run). The
|
|
137
|
-
* marker makes the seed a one-time action: once it exists, a user who deletes
|
|
138
|
-
* the seeded name will not see it re-added on the next restart. Presence is
|
|
139
|
-
* the whole signal; the stored body is informational only.
|
|
140
|
-
*/
|
|
141
|
-
async hasSeededHostname() {
|
|
142
|
-
return (await readIfExists(this.path('hostnameSeeded'))) !== null;
|
|
143
|
-
}
|
|
144
|
-
async markHostnameSeeded(hostname) {
|
|
145
|
-
await this.init();
|
|
146
|
-
const body = JSON.stringify({ hostname, seededAt: new Date().toISOString() }, null, 2);
|
|
147
|
-
await atomicWrite(this.path('hostnameSeeded'), body, JSON_MODE);
|
|
148
|
-
}
|
|
149
133
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-ssl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "SSL/TLS certificate management plugin for SignalK Node Server — generate a local CA, issue server certs, distribute the root via QR to phones/tablets",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/plugin/index.js",
|
|
@@ -58,7 +58,7 @@
|
|
|
58
58
|
"webapp": {
|
|
59
59
|
"name": "SignalK SSL",
|
|
60
60
|
"description": "SSL/TLS certificate management for SignalK",
|
|
61
|
-
"location": "/
|
|
61
|
+
"location": "/signalk-ssl/"
|
|
62
62
|
}
|
|
63
63
|
},
|
|
64
64
|
"peerDependencies": {
|
|
@@ -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-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}.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-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-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}
|