signalk-ssl 0.2.2 → 0.3.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 +24 -0
- package/dist/plugin/api.js +22 -0
- package/dist/plugin/container-env.d.ts +32 -0
- package/dist/plugin/container-env.js +62 -0
- package/dist/plugin/index.js +9 -0
- package/dist/plugin/service.d.ts +33 -0
- package/dist/plugin/service.js +56 -2
- package/package.json +2 -1
- package/public/app-icon.svg +22 -0
- package/public/assets/index-Cj1dz98K.js +9 -0
- package/public/assets/index-xrow3Xg7.css +2 -0
- package/public/index.html +2 -2
- package/public/assets/index-Bx2jIpfl.js +0 -9
- package/public/assets/index-CVCKF5kt.css +0 -2
package/README.md
CHANGED
|
@@ -55,6 +55,20 @@ The CA private key is always encrypted at rest with PBES2 / PBKDF2-SHA256 / AES-
|
|
|
55
55
|
| `env` | Reads `SIGNALK_SSL_PASSPHRASE` from the environment at startup. | Boxes where you set the env var via systemd / Compose. |
|
|
56
56
|
| `webapp` | Prompts in the webapp on each restart. | High-security setups where the passphrase lives in your head. |
|
|
57
57
|
|
|
58
|
+
### Changing the passphrase
|
|
59
|
+
|
|
60
|
+
The **Change passphrase** panel on the status dashboard re-encrypts the CA
|
|
61
|
+
private key under a new passphrase. The CA certificate itself is untouched, so
|
|
62
|
+
every device that already trusts your CA keeps working and no restart is
|
|
63
|
+
needed. Enter the current passphrase plus the new one; a wrong current
|
|
64
|
+
passphrase is rejected without changing anything on disk.
|
|
65
|
+
|
|
66
|
+
In `env` mode you must **also** update `SIGNALK_SSL_PASSPHRASE` to the new value
|
|
67
|
+
(systemd unit / Compose file), or the next restart won't be able to decrypt the
|
|
68
|
+
CA. In `convenience` mode the passphrase is machine-derived and not typeable, so
|
|
69
|
+
this flow doesn't apply — to re-key a convenience-mode install, switch to `env`
|
|
70
|
+
or `webapp` mode first.
|
|
71
|
+
|
|
58
72
|
## Mode of operation
|
|
59
73
|
|
|
60
74
|
- **Generate** (default) — fresh CA on first run.
|
|
@@ -67,6 +81,7 @@ The CA private key is always encrypted at rest with PBES2 / PBKDF2-SHA256 / AES-
|
|
|
67
81
|
- `POST /plugins/signalk-ssl/renew` — issue / renew leaf (admin auth required)
|
|
68
82
|
- `POST /plugins/signalk-ssl/unlock` — supply passphrase (webapp mode, admin auth required)
|
|
69
83
|
- `POST /plugins/signalk-ssl/lock` — drop in-memory passphrase
|
|
84
|
+
- `POST /plugins/signalk-ssl/rotate` — re-encrypt the CA key under a new passphrase (admin auth required)
|
|
70
85
|
- `GET /signalk/v1/api/ssl/ca.crt` — **public** download of CA cert (PEM)
|
|
71
86
|
- `GET /signalk/v1/api/ssl/ca.mobileconfig` — **public** download of Apple profile
|
|
72
87
|
|
|
@@ -97,6 +112,15 @@ The renewal scheduler runs daily and re-issues whenever the configured SANs no l
|
|
|
97
112
|
|
|
98
113
|
SignalK refuses to start with a TLS key that isn't `0600`. The plugin writes `0600` and re-chmods on every install. If you see this error, check that no other process has rewritten the file.
|
|
99
114
|
|
|
115
|
+
### "Cannot write the CA / certificate files" (rootless Podman UID shift)
|
|
116
|
+
|
|
117
|
+
The plugin probes write access to its data dir and the cert path at startup. Inside **rootless Podman**, a bind-mounted host directory can look present but reject child creation when the directory is owned by a UID that doesn't match the container's effective UID — the classic UID-shift symptom. When this happens the plugin logs a warning and shows a red banner on the status dashboard instead of silently failing on the first cert write.
|
|
118
|
+
|
|
119
|
+
Fixes:
|
|
120
|
+
|
|
121
|
+
- Run the container with `--userns=keep-id` (Podman) so in-container writes land as the host owner. On hosts where the SignalK user isn't UID 1000, use the explicit form `--userns=keep-id:uid=<in-image-uid>,gid=<in-image-gid>`.
|
|
122
|
+
- Or `chown` the mounted directory to the UID the container process runs as.
|
|
123
|
+
|
|
100
124
|
### "I rotated the CA and now every phone is broken"
|
|
101
125
|
|
|
102
126
|
That's the cost of rotating a CA. Every device needs to re-install the new root. The webapp's "Regenerate CA" button shows this consequence before it lets you proceed.
|
package/dist/plugin/api.js
CHANGED
|
@@ -36,6 +36,28 @@ export const registerAdminRoutes = (router, deps) => {
|
|
|
36
36
|
deps.service.acknowledgeRestart();
|
|
37
37
|
sendJson(res, 200, { kind: 'locked' });
|
|
38
38
|
});
|
|
39
|
+
router.post('/rotate', asyncRoute(async (req, res) => {
|
|
40
|
+
const body = (req.body ?? {});
|
|
41
|
+
if (typeof body.oldPassphrase !== 'string' || body.oldPassphrase.length === 0) {
|
|
42
|
+
sendJson(res, 400, { error: 'oldPassphrase required' });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (typeof body.newPassphrase !== 'string' || body.newPassphrase.length === 0) {
|
|
46
|
+
sendJson(res, 400, { error: 'newPassphrase required' });
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
const out = await deps.service.rotatePassphrase(body.oldPassphrase, body.newPassphrase);
|
|
50
|
+
// Map the outcome to a status: a wrong old passphrase is a client error,
|
|
51
|
+
// a missing CA is a 409 (nothing to rotate yet), an internal failure 500.
|
|
52
|
+
const status = out.kind === 'rotated'
|
|
53
|
+
? 200
|
|
54
|
+
: out.kind === 'wrong-passphrase'
|
|
55
|
+
? 403
|
|
56
|
+
: out.kind === 'no-ca'
|
|
57
|
+
? 409
|
|
58
|
+
: 500;
|
|
59
|
+
sendJson(res, status, out);
|
|
60
|
+
}));
|
|
39
61
|
};
|
|
40
62
|
/** Mounted by the server at /signalk/v1/api/* via signalKApiRoutes — read-only public. */
|
|
41
63
|
export const buildPublicRoutes = (router, deps) => {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Best-effort detection that we're running inside a container. None of these
|
|
3
|
+
* signals is authoritative on its own, but any one of them is a strong hint:
|
|
4
|
+
* - `/.dockerenv` — Docker writes this into every container.
|
|
5
|
+
* - `/run/.containerenv` — Podman's equivalent.
|
|
6
|
+
* - `container` env var — set by systemd-nspawn and some Podman setups.
|
|
7
|
+
*/
|
|
8
|
+
export declare const detectContainer: (env?: NodeJS.ProcessEnv) => boolean;
|
|
9
|
+
export interface PermissionProbe {
|
|
10
|
+
readonly dir: string;
|
|
11
|
+
readonly writable: boolean;
|
|
12
|
+
readonly error: string | null;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Verify the process can actually create + remove a file under `dir`. A bare
|
|
16
|
+
* `access(dir, W_OK)` is not enough on bind-mounted volumes where the dir
|
|
17
|
+
* looks writable but the effective UID can't create children (the classic
|
|
18
|
+
* rootless-Podman UID-shift symptom). So we write a real probe file.
|
|
19
|
+
*/
|
|
20
|
+
export declare const probeWritable: (dir: string) => Promise<PermissionProbe>;
|
|
21
|
+
export interface PermissionCheckInput {
|
|
22
|
+
readonly dataDir: string;
|
|
23
|
+
readonly configPath: string;
|
|
24
|
+
readonly containerized?: boolean;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Run write probes against the two directories the plugin must write to and,
|
|
28
|
+
* if either fails while containerized, return an operator-facing warning that
|
|
29
|
+
* names the rootless-Podman UID-shift failure mode explicitly. Returns null
|
|
30
|
+
* when everything is writable.
|
|
31
|
+
*/
|
|
32
|
+
export declare const checkPermissions: (input: PermissionCheckInput) => Promise<string | null>;
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { access, constants, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import { join } from 'node:path';
|
|
4
|
+
/**
|
|
5
|
+
* Best-effort detection that we're running inside a container. None of these
|
|
6
|
+
* signals is authoritative on its own, but any one of them is a strong hint:
|
|
7
|
+
* - `/.dockerenv` — Docker writes this into every container.
|
|
8
|
+
* - `/run/.containerenv` — Podman's equivalent.
|
|
9
|
+
* - `container` env var — set by systemd-nspawn and some Podman setups.
|
|
10
|
+
*/
|
|
11
|
+
export const detectContainer = (env = process.env) => {
|
|
12
|
+
return (existsSync('/.dockerenv') ||
|
|
13
|
+
existsSync('/run/.containerenv') ||
|
|
14
|
+
(env.container !== undefined && env.container !== ''));
|
|
15
|
+
};
|
|
16
|
+
/**
|
|
17
|
+
* Verify the process can actually create + remove a file under `dir`. A bare
|
|
18
|
+
* `access(dir, W_OK)` is not enough on bind-mounted volumes where the dir
|
|
19
|
+
* looks writable but the effective UID can't create children (the classic
|
|
20
|
+
* rootless-Podman UID-shift symptom). So we write a real probe file.
|
|
21
|
+
*/
|
|
22
|
+
export const probeWritable = async (dir) => {
|
|
23
|
+
const probe = join(dir, `.signalk-ssl-write-probe.${process.pid.toString()}`);
|
|
24
|
+
try {
|
|
25
|
+
await mkdir(dir, { recursive: true });
|
|
26
|
+
await access(dir, constants.W_OK);
|
|
27
|
+
// The access() check passes on some UID-shifted mounts that still reject
|
|
28
|
+
// child creation, so follow it with an actual create + remove.
|
|
29
|
+
await rm(probe, { force: true });
|
|
30
|
+
await writeFile(probe, '');
|
|
31
|
+
await rm(probe, { force: true });
|
|
32
|
+
return { dir, writable: true, error: null };
|
|
33
|
+
}
|
|
34
|
+
catch (e) {
|
|
35
|
+
return { dir, writable: false, error: e instanceof Error ? e.message : String(e) };
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Run write probes against the two directories the plugin must write to and,
|
|
40
|
+
* if either fails while containerized, return an operator-facing warning that
|
|
41
|
+
* names the rootless-Podman UID-shift failure mode explicitly. Returns null
|
|
42
|
+
* when everything is writable.
|
|
43
|
+
*/
|
|
44
|
+
export const checkPermissions = async (input) => {
|
|
45
|
+
const containerized = input.containerized ?? detectContainer();
|
|
46
|
+
const probes = await Promise.all([probeWritable(input.dataDir), probeWritable(input.configPath)]);
|
|
47
|
+
const failed = probes.filter((p) => !p.writable);
|
|
48
|
+
if (failed.length === 0) {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
const dirs = failed.map((p) => p.dir).join(', ');
|
|
52
|
+
if (containerized) {
|
|
53
|
+
return (`Cannot write to ${dirs}. This is the classic rootless-Podman UID-shift ` +
|
|
54
|
+
`symptom: the bind-mounted host directory is owned by a UID that doesn't ` +
|
|
55
|
+
`match the container's effective UID. Run the container with ` +
|
|
56
|
+
`--userns=keep-id (Podman) so in-container writes land as the host owner, ` +
|
|
57
|
+
`or chown the mounted directory to the container UID. Until this is fixed ` +
|
|
58
|
+
`the plugin can't persist the CA or install certificates.`);
|
|
59
|
+
}
|
|
60
|
+
return (`Cannot write to ${dirs}. Check directory ownership and permissions — the ` +
|
|
61
|
+
`plugin needs to write the CA state and install ssl-*.pem there.`);
|
|
62
|
+
};
|
package/dist/plugin/index.js
CHANGED
|
@@ -42,6 +42,15 @@ const pluginConstructor = (app) => {
|
|
|
42
42
|
app.error(`${PLUGIN_ID} scheduled renewal failed: ${String(err)}`);
|
|
43
43
|
};
|
|
44
44
|
void store.init().then(async () => {
|
|
45
|
+
try {
|
|
46
|
+
const warning = await svcLocal.checkWritePermissions();
|
|
47
|
+
if (warning !== null) {
|
|
48
|
+
app.error(`${PLUGIN_ID} permission warning: ${warning}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
catch (e) {
|
|
52
|
+
app.error(`${PLUGIN_ID} permission probe failed: ${String(e)}`);
|
|
53
|
+
}
|
|
45
54
|
try {
|
|
46
55
|
const outcome = await svcLocal.issueIfNeeded();
|
|
47
56
|
app.debug(`${PLUGIN_ID} initial issueIfNeeded: ${JSON.stringify(outcome)}`);
|
package/dist/plugin/service.d.ts
CHANGED
|
@@ -17,6 +17,16 @@ export type IssueOutcome = {
|
|
|
17
17
|
readonly kind: 'error';
|
|
18
18
|
readonly message: string;
|
|
19
19
|
};
|
|
20
|
+
export type RotateOutcome = {
|
|
21
|
+
readonly kind: 'rotated';
|
|
22
|
+
} | {
|
|
23
|
+
readonly kind: 'no-ca';
|
|
24
|
+
} | {
|
|
25
|
+
readonly kind: 'wrong-passphrase';
|
|
26
|
+
} | {
|
|
27
|
+
readonly kind: 'error';
|
|
28
|
+
readonly message: string;
|
|
29
|
+
};
|
|
20
30
|
export interface ServiceStatus {
|
|
21
31
|
readonly hasCa: boolean;
|
|
22
32
|
readonly caFingerprint: string | null;
|
|
@@ -27,6 +37,7 @@ export interface ServiceStatus {
|
|
|
27
37
|
readonly leafSansDns: readonly string[];
|
|
28
38
|
readonly leafSansIp: readonly string[];
|
|
29
39
|
readonly restartRequired: boolean;
|
|
40
|
+
readonly permissionWarning: string | null;
|
|
30
41
|
}
|
|
31
42
|
export interface SslServiceDeps {
|
|
32
43
|
readonly store: CertStore;
|
|
@@ -37,10 +48,32 @@ export interface SslServiceDeps {
|
|
|
37
48
|
export declare class SslService {
|
|
38
49
|
private readonly deps;
|
|
39
50
|
private restartRequired;
|
|
51
|
+
private permissionWarning;
|
|
40
52
|
constructor(deps: SslServiceDeps);
|
|
53
|
+
/**
|
|
54
|
+
* Probe write access to the data dir and the cert install path. On a
|
|
55
|
+
* rootless-Podman UID-shift the bind-mounted host dir looks present but
|
|
56
|
+
* rejects child creation; this surfaces that as an operator-facing warning
|
|
57
|
+
* (logged at start, exposed via /status) rather than failing silently on
|
|
58
|
+
* the first cert write. Idempotent — safe to call on every start.
|
|
59
|
+
*/
|
|
60
|
+
checkWritePermissions(): Promise<string | null>;
|
|
41
61
|
targets(): InstallTargets;
|
|
42
62
|
private resolveSans;
|
|
43
63
|
issueIfNeeded(): Promise<IssueOutcome>;
|
|
64
|
+
/**
|
|
65
|
+
* Re-encrypt the CA private key under a new passphrase. The CA key is the
|
|
66
|
+
* only passphrase-protected artifact (leaf keys are written plaintext at
|
|
67
|
+
* 0o600 to the TLS path), so rotation is a single re-wrap: decrypt with the
|
|
68
|
+
* old passphrase, re-encrypt with the new one, write back atomically.
|
|
69
|
+
*
|
|
70
|
+
* `oldPassphrase` must match whatever the CA key is currently wrapped with
|
|
71
|
+
* — for `env`/`webapp` modes that's the operator's typed value; for
|
|
72
|
+
* `convenience` mode it's the machine-derived digest, which the caller
|
|
73
|
+
* can't type, so this route is only meaningful for env/webapp installs.
|
|
74
|
+
* We verify by attempting decryption and never touch disk on a mismatch.
|
|
75
|
+
*/
|
|
76
|
+
rotatePassphrase(oldPassphrase: string, newPassphrase: string): Promise<RotateOutcome>;
|
|
44
77
|
private bootstrapCa;
|
|
45
78
|
private importCa;
|
|
46
79
|
private signAndStoreLeaf;
|
package/dist/plugin/service.js
CHANGED
|
@@ -4,13 +4,29 @@ import { computeSpkiFingerprint, decryptPrivateKeyPkcs8, encryptPrivateKeyPkcs8,
|
|
|
4
4
|
import { needsRenewal } from './needs-renewal.js';
|
|
5
5
|
import { parseSans, sanitizeHostname } from './sans.js';
|
|
6
6
|
import { defaultTargets, installCerts } from './cert-installer.js';
|
|
7
|
+
import { checkPermissions } from './container-env.js';
|
|
7
8
|
const MS_PER_DAY = 24 * 60 * 60 * 1000;
|
|
8
9
|
export class SslService {
|
|
9
10
|
deps;
|
|
10
11
|
restartRequired = false;
|
|
12
|
+
permissionWarning = null;
|
|
11
13
|
constructor(deps) {
|
|
12
14
|
this.deps = deps;
|
|
13
15
|
}
|
|
16
|
+
/**
|
|
17
|
+
* Probe write access to the data dir and the cert install path. On a
|
|
18
|
+
* rootless-Podman UID-shift the bind-mounted host dir looks present but
|
|
19
|
+
* rejects child creation; this surfaces that as an operator-facing warning
|
|
20
|
+
* (logged at start, exposed via /status) rather than failing silently on
|
|
21
|
+
* the first cert write. Idempotent — safe to call on every start.
|
|
22
|
+
*/
|
|
23
|
+
async checkWritePermissions() {
|
|
24
|
+
this.permissionWarning = await checkPermissions({
|
|
25
|
+
dataDir: this.deps.store.dataDir,
|
|
26
|
+
configPath: this.deps.configPath
|
|
27
|
+
});
|
|
28
|
+
return this.permissionWarning;
|
|
29
|
+
}
|
|
14
30
|
targets() {
|
|
15
31
|
return defaultTargets(this.deps.configPath);
|
|
16
32
|
}
|
|
@@ -49,6 +65,42 @@ export class SslService {
|
|
|
49
65
|
this.restartRequired = true;
|
|
50
66
|
return { kind: 'issued', reason: 'first-run' };
|
|
51
67
|
}
|
|
68
|
+
/**
|
|
69
|
+
* Re-encrypt the CA private key under a new passphrase. The CA key is the
|
|
70
|
+
* only passphrase-protected artifact (leaf keys are written plaintext at
|
|
71
|
+
* 0o600 to the TLS path), so rotation is a single re-wrap: decrypt with the
|
|
72
|
+
* old passphrase, re-encrypt with the new one, write back atomically.
|
|
73
|
+
*
|
|
74
|
+
* `oldPassphrase` must match whatever the CA key is currently wrapped with
|
|
75
|
+
* — for `env`/`webapp` modes that's the operator's typed value; for
|
|
76
|
+
* `convenience` mode it's the machine-derived digest, which the caller
|
|
77
|
+
* can't type, so this route is only meaningful for env/webapp installs.
|
|
78
|
+
* We verify by attempting decryption and never touch disk on a mismatch.
|
|
79
|
+
*/
|
|
80
|
+
async rotatePassphrase(oldPassphrase, newPassphrase) {
|
|
81
|
+
const caState = await this.deps.store.readCaState();
|
|
82
|
+
if (caState === null) {
|
|
83
|
+
return { kind: 'no-ca' };
|
|
84
|
+
}
|
|
85
|
+
let caPrivateKey;
|
|
86
|
+
try {
|
|
87
|
+
caPrivateKey = await decryptPrivateKeyPkcs8(caState.encryptedKeyPem, oldPassphrase);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return { kind: 'wrong-passphrase' };
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const reEncrypted = await encryptPrivateKeyPkcs8(caPrivateKey, newPassphrase);
|
|
94
|
+
await this.deps.store.writeCaState({ ...caState, encryptedKeyPem: reEncrypted });
|
|
95
|
+
}
|
|
96
|
+
catch (e) {
|
|
97
|
+
return { kind: 'error', message: e instanceof Error ? e.message : String(e) };
|
|
98
|
+
}
|
|
99
|
+
// The in-memory passphrase (webapp mode) must follow the re-wrap, or the
|
|
100
|
+
// next resolve() would still hand back the old value and fail to decrypt.
|
|
101
|
+
this.deps.passphrase.unlockWith(newPassphrase);
|
|
102
|
+
return { kind: 'rotated' };
|
|
103
|
+
}
|
|
52
104
|
async bootstrapCa(passphrase) {
|
|
53
105
|
if (this.deps.config.mode === 'import') {
|
|
54
106
|
return this.importCa(passphrase);
|
|
@@ -129,7 +181,8 @@ export class SslService {
|
|
|
129
181
|
leafDaysRemaining: null,
|
|
130
182
|
leafSansDns: [],
|
|
131
183
|
leafSansIp: [],
|
|
132
|
-
restartRequired: this.restartRequired
|
|
184
|
+
restartRequired: this.restartRequired,
|
|
185
|
+
permissionWarning: this.permissionWarning
|
|
133
186
|
};
|
|
134
187
|
}
|
|
135
188
|
const decision = needsRenewal(leafState.certificatePem, this.resolveSans(), this.deps.config.renewalThresholdDays);
|
|
@@ -142,7 +195,8 @@ export class SslService {
|
|
|
142
195
|
leafDaysRemaining: Math.round(decision.daysUntilExpiry),
|
|
143
196
|
leafSansDns: this.deps.config.sans.dnsNames.map((d) => sanitizeHostname(d).toLowerCase()),
|
|
144
197
|
leafSansIp: this.deps.config.sans.ipAddresses,
|
|
145
|
-
restartRequired: this.restartRequired
|
|
198
|
+
restartRequired: this.restartRequired,
|
|
199
|
+
permissionWarning: this.permissionWarning
|
|
146
200
|
};
|
|
147
201
|
}
|
|
148
202
|
acknowledgeRestart() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "signalk-ssl",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.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",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"signalk-plugin-enabled-by-default": false,
|
|
47
47
|
"signalk": {
|
|
48
48
|
"displayName": "SignalK SSL",
|
|
49
|
+
"appIcon": "./app-icon.svg",
|
|
49
50
|
"appstore": {
|
|
50
51
|
"displayName": "SignalK SSL",
|
|
51
52
|
"categories": [
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" role="img" aria-label="signalk-ssl">
|
|
2
|
+
<title>signalk-ssl</title>
|
|
3
|
+
<!-- Background: rounded square in SignalK blue -->
|
|
4
|
+
<rect x="0" y="0" width="256" height="256" rx="44" ry="44" fill="#003399"/>
|
|
5
|
+
|
|
6
|
+
<!-- Concentric broadcasting arcs above the lock: the "signal" in SignalK -->
|
|
7
|
+
<g fill="none" stroke="#FFCC00" stroke-linecap="round">
|
|
8
|
+
<path d="M 88 80 A 40 40 0 0 1 168 80" stroke-width="6"/>
|
|
9
|
+
<path d="M 74 78 A 54 54 0 0 1 182 78" stroke-width="6" opacity="0.7"/>
|
|
10
|
+
<path d="M 60 76 A 68 68 0 0 1 196 76" stroke-width="6" opacity="0.45"/>
|
|
11
|
+
</g>
|
|
12
|
+
|
|
13
|
+
<!-- Padlock shackle: rounded inverted-U, drawn after the arcs so it sits on top -->
|
|
14
|
+
<path d="M 96 130 V 102 a 32 32 0 0 1 64 0 V 130" fill="none" stroke="#FFFFFF" stroke-width="14" stroke-linecap="round"/>
|
|
15
|
+
|
|
16
|
+
<!-- Padlock body: rounded rectangle -->
|
|
17
|
+
<rect x="64" y="126" width="128" height="100" rx="14" ry="14" fill="#FFFFFF"/>
|
|
18
|
+
|
|
19
|
+
<!-- Keyhole: circle + tapered stem in SignalK blue, evokes a classic lock -->
|
|
20
|
+
<circle cx="128" cy="166" r="14" fill="#003399"/>
|
|
21
|
+
<path d="M 122 174 L 118 206 H 138 L 134 174 Z" fill="#003399"/>
|
|
22
|
+
</svg>
|