@monster-orange/den 0.2.8 → 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/CHANGELOG.md +1547 -0
- package/README.md +4 -2
- package/bin/den +2 -1
- package/docs/distributions.md +507 -4
- package/make/den.mk +19 -4
- package/make/journeys.mk +2 -1
- package/make/local_dev.mk +6 -13
- package/make/secrets-1password.mk +18 -0
- package/make/test.mk +1 -1
- package/package.json +9 -2
- package/schemas/den.plugin.v1.json +156 -0
- package/schemas/den.theme.v1.json +269 -0
- package/scripts/auth-configure.ts +16 -8
- package/scripts/heal-config.ts +0 -5
- package/scripts/setup-1password.ts +41 -19
- package/scripts/setup-secrets.ts +67 -101
- package/src/pillars/api/global-setup.ts +1 -1
- package/src/pillars/journeys/sandbox.ts +23 -18
- package/src/pillars/journeys/tools/browser-session.ts +2 -2
- package/src/scripts/setup-default-workspace.ts +2 -2
- package/src/shared/auth/headless-login.ts +1 -1
- package/src/shared/auth/sudo-vault.ts +93 -31
- package/src/shared/auth/token-providers/index.ts +7 -3
- package/src/shared/brand.ts +7 -0
- package/src/shared/config-error.ts +33 -3
- package/src/shared/constants.ts +3 -24
- package/src/shared/den-config.ts +141 -5
- package/src/shared/edge/config.ts +147 -21
- package/src/shared/edge/exec.ts +31 -0
- package/src/shared/edge/included.ts +169 -0
- package/src/shared/edge/render.ts +69 -14
- package/src/shared/edge/runtime.ts +128 -121
- package/src/shared/edge/status.ts +68 -8
- package/src/shared/edge/tls.ts +328 -0
- package/src/shared/edge/types.ts +25 -9
- package/src/shared/environment-manager/default-workspace.ts +16 -1
- package/src/shared/environment-manager/health.ts +73 -38
- package/src/shared/environment-manager/scope.ts +168 -0
- package/src/shared/environment-manager/service-runner.ts +789 -43
- package/src/shared/environment-manager/up-targets.ts +132 -0
- package/src/shared/health/rollup.ts +48 -0
- package/src/shared/open-url.ts +21 -2
- package/src/shared/org-sources.ts +78 -59
- package/src/shared/org.ts +53 -5
- package/src/shared/plugins/manifest.ts +406 -0
- package/src/shared/plugins/store.ts +358 -0
- package/src/shared/process/legacy-adopt.ts +285 -0
- package/src/shared/process/process-group.ts +202 -0
- package/src/shared/process/spawn-streaming.ts +7 -0
- package/src/shared/secrets/doppler.ts +1 -1
- package/src/shared/secrets/index.ts +279 -50
- package/src/shared/secrets/none.ts +56 -0
- package/src/shared/secrets/strategies.ts +202 -0
- package/src/shared/secrets/types.ts +1 -1
- package/src/shared/smoke/git-remote.ts +40 -0
- package/src/shared/stacks/manifest-commands.ts +1 -1
- package/src/shared/stacks/namespace.ts +178 -0
- package/src/shared/stacks/owners.ts +303 -0
- package/src/shared/stacks/render.ts +8 -3
- package/src/shared/stacks/runtime.ts +100 -16
- package/src/shared/stacks/status.ts +63 -12
- package/src/shared/strategies/registry.ts +89 -0
- package/src/shared/theme/contract.ts +538 -0
- package/src/shared/types/index.ts +12 -3
- package/src/ui/cli/create.ts +24 -7
- package/src/ui/cli/doctor-command.ts +131 -0
- package/src/ui/cli/edge-command.ts +56 -10
- package/src/ui/cli/flag-parser.ts +7 -2
- package/src/ui/cli/health-display.ts +25 -0
- package/src/ui/cli/index.ts +548 -60
- package/src/ui/cli/notice.ts +20 -0
- package/src/ui/cli/plugin-command.ts +849 -0
- package/src/ui/cli/stack-command.ts +63 -2
- package/src/ui/cli/test-runner.ts +11 -9
- package/src/ui/cli/theme-command.ts +136 -0
- package/src/ui/cli/web-command.ts +47 -23
- package/src/ui/mcp/api.ts +28 -2
- package/src/ui/mcp/scope.ts +22 -1
- package/src/ui/mcp/tools.ts +50 -7
- package/src/ui/server/appearance-prefs.ts +95 -0
- package/src/ui/server/daemon-reaper.ts +29 -1
- package/src/ui/server/engine-stamp.ts +83 -0
- package/src/ui/server/github-service.ts +23 -44
- package/src/ui/server/graph-page.ts +20 -0
- package/src/ui/server/graph-prefs.ts +44 -1
- package/src/ui/server/http-errors.ts +293 -0
- package/src/ui/server/http-helpers.ts +22 -3
- package/src/ui/server/index.ts +532 -51
- package/src/ui/server/public/appearance.js +531 -0
- package/src/ui/server/public/edge-page.js +248 -0
- package/src/ui/server/public/edge.html +251 -0
- package/src/ui/server/public/environments.html +65 -83
- package/src/ui/server/public/error-page.js +247 -0
- package/src/ui/server/public/error.html +260 -0
- package/src/ui/server/public/graph-model.js +332 -14
- package/src/ui/server/public/graph.css +141 -47
- package/src/ui/server/public/graph.js +223 -44
- package/src/ui/server/public/header.js +396 -600
- package/src/ui/server/public/health-fit.js +248 -0
- package/src/ui/server/public/health-rollup.js +128 -0
- package/src/ui/server/public/i18n/es.js +287 -0
- package/src/ui/server/public/i18n/ja.js +276 -0
- package/src/ui/server/public/index.html +139 -438
- package/src/ui/server/public/local-dev.html +3468 -4044
- package/src/ui/server/public/logs.html +71 -208
- package/src/ui/server/public/panel.js +14 -2
- package/src/ui/server/public/release.html +175 -389
- package/src/ui/server/public/services.html +596 -770
- package/src/ui/server/public/settings-page.js +759 -0
- package/src/ui/server/public/settings-registry.js +521 -0
- package/src/ui/server/public/settings.html +590 -0
- package/src/ui/server/public/shell.js +3869 -0
- package/src/ui/server/public/topbar.js +1413 -0
- package/src/ui/server/public/version-label.js +12 -0
- package/src/ui/server/public/workspace-picker.js +74 -34
- package/src/ui/server/readonly-routes.ts +12 -1
- package/src/ui/server/services-list.ts +94 -17
- package/src/ui/server/settings-distribution.ts +74 -0
- package/src/ui/server/shell-prefs.ts +192 -0
- package/src/ui/server/theme-routes.ts +292 -0
- package/src/ui/server/theme-runtime.ts +407 -0
- package/src/ui/server/ui-assets.ts +16 -1
- package/src/ui/server/ui-language.ts +50 -0
- package/src/ui/server/workspace-routes.ts +45 -3
- package/themes/den/den-theme.json +72 -0
- package/themes/den/favicon.ico +0 -0
- package/themes/den/mark.svg +6 -0
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The routers in the distribution's included dynamic-config files (`edge
|
|
3
|
+
* include`, e.g. `edge/dynamic/*.yml`): Traefik serves them beside the
|
|
4
|
+
* routes Den generates, so `edge routes`, /api/edge and the Edge page list
|
|
5
|
+
* them too, each marked by its source file (PL-SPEC §10, a consumer's review).
|
|
6
|
+
*
|
|
7
|
+
* Read-only: the files are parsed, never written, and only `http.routers`
|
|
8
|
+
* is read. The same files `edge up` copies in (top-level YAML, not the
|
|
9
|
+
* generated edge.yml, not a stack's own route file).
|
|
10
|
+
*
|
|
11
|
+
* Since 0.3.0-dev.32 (a consumer's finding) also the stacks' own route
|
|
12
|
+
* files: Den writes `stack-<project>.yml` into the edge's dynamic directory
|
|
13
|
+
* when a stack comes up, and those routers are neither Den's host routes
|
|
14
|
+
* nor included files, so nothing listed them.
|
|
15
|
+
*/
|
|
16
|
+
import * as fs from "fs";
|
|
17
|
+
import * as path from "path";
|
|
18
|
+
import { parse } from "yaml";
|
|
19
|
+
import { includeFiles, isOwnedElsewhere, isStackRoutes } from "./runtime.js";
|
|
20
|
+
import type { EdgeConfig } from "./types.js";
|
|
21
|
+
|
|
22
|
+
export interface IncludedRouter {
|
|
23
|
+
/** The router's key under `http.routers`. */
|
|
24
|
+
name: string;
|
|
25
|
+
/** The included file it comes from (its name, as Traefik reads it). */
|
|
26
|
+
file: string;
|
|
27
|
+
rule: string;
|
|
28
|
+
service: string;
|
|
29
|
+
entryPoints: string[];
|
|
30
|
+
/** The hosts its rule names (`Host(...)`), for links. */
|
|
31
|
+
hosts: string[];
|
|
32
|
+
tls: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* Served over https: the router has TLS or is on the websecure
|
|
35
|
+
* entrypoint (0.3.0-dev.33). Its URLs are https://, else http://.
|
|
36
|
+
*/
|
|
37
|
+
secure: boolean;
|
|
38
|
+
/**
|
|
39
|
+
* Where it sends traffic: its service's loadBalancer server URL(s) when
|
|
40
|
+
* the same file defines that service, else the service's name
|
|
41
|
+
* (0.3.0-dev.33).
|
|
42
|
+
*/
|
|
43
|
+
upstream: string;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** A router's URLs: one per host, with its scheme; none without a Host(). */
|
|
47
|
+
export function routerUrls(r: Pick<IncludedRouter, "hosts" | "secure">) {
|
|
48
|
+
const scheme = r.secure ? "https://" : "http://";
|
|
49
|
+
return r.hosts.map((h) => scheme + h);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface IncludedRouters {
|
|
53
|
+
routers: IncludedRouter[];
|
|
54
|
+
/** Files that could not be read or parsed: listed, never fatal. */
|
|
55
|
+
errors: { file: string; error: string }[];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** The hosts a Traefik rule names: every quoted argument of Host(...). */
|
|
59
|
+
export function ruleHosts(rule: string): string[] {
|
|
60
|
+
const out: string[] = [];
|
|
61
|
+
for (const m of rule.matchAll(/\bHost\(([^)]*)\)/g))
|
|
62
|
+
for (const q of m[1].matchAll(/[`"']([^`"']+)[`"']/g))
|
|
63
|
+
if (!out.includes(q[1])) out.push(q[1]);
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const str = (v: unknown): string => (typeof v === "string" ? v : "");
|
|
68
|
+
|
|
69
|
+
/** The routers in one dynamic-config document's `http.routers`. */
|
|
70
|
+
export function routersOf(doc: unknown, file: string): IncludedRouter[] {
|
|
71
|
+
const http = (
|
|
72
|
+
doc as { http?: { routers?: unknown; services?: unknown } } | null
|
|
73
|
+
)?.http;
|
|
74
|
+
const routers = http?.routers;
|
|
75
|
+
if (!routers || typeof routers !== "object" || Array.isArray(routers))
|
|
76
|
+
return [];
|
|
77
|
+
const services = obj(http?.services);
|
|
78
|
+
return Object.entries(routers as Record<string, unknown>).map(([name, r]) => {
|
|
79
|
+
const o = obj(r);
|
|
80
|
+
const rule = str(o.rule);
|
|
81
|
+
const service = str(o.service);
|
|
82
|
+
const entryPoints = Array.isArray(o.entryPoints)
|
|
83
|
+
? o.entryPoints.filter((e): e is string => typeof e === "string")
|
|
84
|
+
: [];
|
|
85
|
+
const tls = o.tls !== undefined && o.tls !== null && o.tls !== false;
|
|
86
|
+
return {
|
|
87
|
+
name,
|
|
88
|
+
file,
|
|
89
|
+
rule,
|
|
90
|
+
service,
|
|
91
|
+
entryPoints,
|
|
92
|
+
hosts: ruleHosts(rule),
|
|
93
|
+
tls,
|
|
94
|
+
secure: tls || entryPoints.includes("websecure"),
|
|
95
|
+
upstream: serverUrls(services[service]).join(", ") || service,
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const obj = (v: unknown): Record<string, unknown> =>
|
|
101
|
+
v && typeof v === "object" && !Array.isArray(v)
|
|
102
|
+
? (v as Record<string, unknown>)
|
|
103
|
+
: {};
|
|
104
|
+
|
|
105
|
+
/** A Traefik service's loadBalancer server URLs (none: not a load balancer). */
|
|
106
|
+
function serverUrls(svc: unknown): string[] {
|
|
107
|
+
const servers = obj(obj(svc).loadBalancer).servers;
|
|
108
|
+
return Array.isArray(servers)
|
|
109
|
+
? servers.map((x) => str(obj(x).url)).filter(Boolean)
|
|
110
|
+
: [];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Every router in the edge's included files, in file then key order. */
|
|
114
|
+
export function includedRouters(cfg: EdgeConfig): IncludedRouters {
|
|
115
|
+
const out: IncludedRouters = { routers: [], errors: [] };
|
|
116
|
+
if (!cfg.include) return out;
|
|
117
|
+
let files: string[];
|
|
118
|
+
try {
|
|
119
|
+
files = includeFiles(cfg.include).filter((f) => !isOwnedElsewhere(f));
|
|
120
|
+
} catch (e) {
|
|
121
|
+
out.errors.push({
|
|
122
|
+
file: path.basename(cfg.include),
|
|
123
|
+
error: (e as Error).message,
|
|
124
|
+
});
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
for (const file of files) {
|
|
128
|
+
try {
|
|
129
|
+
const doc: unknown = parse(
|
|
130
|
+
fs.readFileSync(path.join(cfg.include, file), "utf-8"),
|
|
131
|
+
);
|
|
132
|
+
out.routers.push(...routersOf(doc, file));
|
|
133
|
+
} catch (e) {
|
|
134
|
+
out.errors.push({ file, error: (e as Error).message.split("\n")[0] });
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
return out;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Every router in the stacks' route files (`stack-*.yml` in the edge's
|
|
142
|
+
* dynamic directory, which `stack up` writes and `stack down` removes), in
|
|
143
|
+
* file then key order. No directory yet: none.
|
|
144
|
+
*/
|
|
145
|
+
export function stackRouters(cfg: EdgeConfig): IncludedRouters {
|
|
146
|
+
const out: IncludedRouters = { routers: [], errors: [] };
|
|
147
|
+
const dyn = path.join(cfg.stateDir, "dynamic");
|
|
148
|
+
let files: string[];
|
|
149
|
+
try {
|
|
150
|
+
files = fs
|
|
151
|
+
.readdirSync(dyn, { withFileTypes: true })
|
|
152
|
+
.filter((e) => e.isFile() && isStackRoutes(e.name))
|
|
153
|
+
.map((e) => e.name)
|
|
154
|
+
.sort();
|
|
155
|
+
} catch {
|
|
156
|
+
return out;
|
|
157
|
+
}
|
|
158
|
+
for (const file of files) {
|
|
159
|
+
try {
|
|
160
|
+
const doc: unknown = parse(
|
|
161
|
+
fs.readFileSync(path.join(dyn, file), "utf-8"),
|
|
162
|
+
);
|
|
163
|
+
out.routers.push(...routersOf(doc, file));
|
|
164
|
+
} catch (e) {
|
|
165
|
+
out.errors.push({ file, error: (e as Error).message.split("\n")[0] });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
@@ -38,12 +38,34 @@ function hostRule(name: string, cfg: EdgeConfig): string {
|
|
|
38
38
|
.join(" || ");
|
|
39
39
|
}
|
|
40
40
|
|
|
41
|
-
/**
|
|
41
|
+
/** True when the edge serves https: every edge.tls strategy but `none`. */
|
|
42
|
+
export function edgeHttps(cfg: EdgeConfig): boolean {
|
|
43
|
+
return cfg.tls.strategy !== "none";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The entrypoint routes listen on: websecure, or web for `tls: none`. */
|
|
47
|
+
export function routeEntryPoint(cfg: EdgeConfig): "web" | "websecure" {
|
|
48
|
+
return edgeHttps(cfg) ? "websecure" : "web";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** A router's TLS block: `{ tls: {} }`, or nothing for `tls: none`. */
|
|
52
|
+
export function routerTls(cfg: EdgeConfig): { tls?: Record<string, never> } {
|
|
53
|
+
return edgeHttps(cfg) ? { tls: {} } : {};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Public URL for a route, including the port when it isn't the default
|
|
58
|
+
* (443, or 80 for an http-only edge).
|
|
59
|
+
*/
|
|
42
60
|
export function routeUrl(
|
|
43
61
|
name: string,
|
|
44
62
|
cfg: EdgeConfig,
|
|
45
63
|
domain = cfg.domain,
|
|
46
64
|
): string {
|
|
65
|
+
if (!edgeHttps(cfg)) {
|
|
66
|
+
const h = cfg.ports?.http ?? 80;
|
|
67
|
+
return `http://${routeHost(name, domain)}${h === 80 ? "" : `:${h}`}`;
|
|
68
|
+
}
|
|
47
69
|
const p = cfg.ports?.https ?? 443;
|
|
48
70
|
return `https://${routeHost(name, domain)}${p === 443 ? "" : `:${p}`}`;
|
|
49
71
|
}
|
|
@@ -64,12 +86,20 @@ export function certNames(cfg: EdgeConfig): string[] {
|
|
|
64
86
|
]);
|
|
65
87
|
}
|
|
66
88
|
|
|
67
|
-
/**
|
|
89
|
+
/**
|
|
90
|
+
* Dynamic config: TLS cert, HTTP→HTTPS redirect, dashboard, routes. With
|
|
91
|
+
* `tls: none` every router is on the http entrypoint, with no TLS and no
|
|
92
|
+
* redirect.
|
|
93
|
+
*/
|
|
68
94
|
export function renderEdgeDynamic(cfg: EdgeConfig): string {
|
|
69
95
|
const httpsPort = cfg.ports?.https ?? 443;
|
|
70
96
|
const redirectPort = httpsPort !== 443 ? { port: String(httpsPort) } : {};
|
|
97
|
+
const https = edgeHttps(cfg);
|
|
98
|
+
const redirect = https && cfg.httpsRedirect;
|
|
99
|
+
// Fresh objects per router: a shared one would render as a YAML anchor.
|
|
100
|
+
const entryPoints = () => [routeEntryPoint(cfg)];
|
|
71
101
|
const routers: Record<string, unknown> = {
|
|
72
|
-
...(
|
|
102
|
+
...(redirect
|
|
73
103
|
? {
|
|
74
104
|
"to-https": {
|
|
75
105
|
entryPoints: ["web"],
|
|
@@ -81,10 +111,10 @@ export function renderEdgeDynamic(cfg: EdgeConfig): string {
|
|
|
81
111
|
}
|
|
82
112
|
: {}),
|
|
83
113
|
dashboard: {
|
|
84
|
-
entryPoints:
|
|
114
|
+
entryPoints: entryPoints(),
|
|
85
115
|
rule: hostRule("edge", cfg),
|
|
86
116
|
service: "api@internal",
|
|
87
|
-
|
|
117
|
+
...routerTls(cfg),
|
|
88
118
|
},
|
|
89
119
|
};
|
|
90
120
|
const services: Record<string, unknown> = {};
|
|
@@ -97,10 +127,10 @@ export function renderEdgeDynamic(cfg: EdgeConfig): string {
|
|
|
97
127
|
);
|
|
98
128
|
const key = `route-${slug(name)}`;
|
|
99
129
|
routers[key] = {
|
|
100
|
-
entryPoints:
|
|
130
|
+
entryPoints: entryPoints(),
|
|
101
131
|
rule: hostRule(name, cfg),
|
|
102
132
|
service: key,
|
|
103
|
-
|
|
133
|
+
...routerTls(cfg),
|
|
104
134
|
};
|
|
105
135
|
services[key] = { loadBalancer: { servers: [{ url }] } };
|
|
106
136
|
}
|
|
@@ -113,7 +143,7 @@ export function renderEdgeDynamic(cfg: EdgeConfig): string {
|
|
|
113
143
|
yaml.stringify({
|
|
114
144
|
http: {
|
|
115
145
|
routers,
|
|
116
|
-
...(
|
|
146
|
+
...(redirect
|
|
117
147
|
? {
|
|
118
148
|
middlewares: {
|
|
119
149
|
"to-https": {
|
|
@@ -128,7 +158,7 @@ export function renderEdgeDynamic(cfg: EdgeConfig): string {
|
|
|
128
158
|
: {}),
|
|
129
159
|
...(Object.keys(services).length ? { services } : {}),
|
|
130
160
|
},
|
|
131
|
-
...(cfg.
|
|
161
|
+
...(cfg.tls.strategy === "mkcert"
|
|
132
162
|
? {
|
|
133
163
|
tls: {
|
|
134
164
|
stores: { default: { defaultCertificate: cert } },
|
|
@@ -145,8 +175,25 @@ export function dockerProxyName(org: string): string {
|
|
|
145
175
|
return `${edgeNames(org).container}-docker`;
|
|
146
176
|
}
|
|
147
177
|
|
|
148
|
-
/**
|
|
149
|
-
|
|
178
|
+
/**
|
|
179
|
+
* Compose file for the edge; publishes host ports only when configured.
|
|
180
|
+
* With `tls: none` there is no https entrypoint, port or cert volume.
|
|
181
|
+
*/
|
|
182
|
+
/** The labels that say which state dir and org own the edge container. */
|
|
183
|
+
export const EDGE_ROOT_LABEL = "den.edge.root";
|
|
184
|
+
export const EDGE_ORG_LABEL = "den.edge.org";
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The edge's compose.yml. Its container carries `den.edge.root` (the state
|
|
188
|
+
* dir that owns it, `ownerRoot`: edgeUp passes its realpath) and
|
|
189
|
+
* `den.edge.org` (0.3.0-dev.40): edgeUp and `edge down` touch an existing
|
|
190
|
+
* edge only when its root is theirs.
|
|
191
|
+
*/
|
|
192
|
+
export function renderEdgeCompose(
|
|
193
|
+
cfg: EdgeConfig,
|
|
194
|
+
ownerRoot: string = cfg.stateDir,
|
|
195
|
+
): string {
|
|
196
|
+
const https = edgeHttps(cfg);
|
|
150
197
|
const n = edgeNames(cfg.org);
|
|
151
198
|
const proxy = dockerProxyName(cfg.org);
|
|
152
199
|
const labels = cfg.dockerLabels;
|
|
@@ -159,10 +206,11 @@ export function renderEdgeCompose(cfg: EdgeConfig): string {
|
|
|
159
206
|
image: cfg.image,
|
|
160
207
|
container_name: n.container,
|
|
161
208
|
restart: "unless-stopped",
|
|
209
|
+
labels: { [EDGE_ROOT_LABEL]: ownerRoot, [EDGE_ORG_LABEL]: cfg.org },
|
|
162
210
|
...(labels ? { depends_on: ["docker-proxy"] } : {}),
|
|
163
211
|
command: [
|
|
164
212
|
"--entrypoints.web.address=:80",
|
|
165
|
-
"--entrypoints.websecure.address=:443",
|
|
213
|
+
...(https ? ["--entrypoints.websecure.address=:443"] : []),
|
|
166
214
|
"--providers.file.directory=/etc/edge/dynamic",
|
|
167
215
|
"--providers.file.watch=true",
|
|
168
216
|
...(labels
|
|
@@ -179,12 +227,19 @@ export function renderEdgeCompose(cfg: EdgeConfig): string {
|
|
|
179
227
|
...cfg.args,
|
|
180
228
|
],
|
|
181
229
|
...(cfg.ports
|
|
182
|
-
? {
|
|
230
|
+
? {
|
|
231
|
+
ports: [
|
|
232
|
+
`${cfg.ports.http}:80`,
|
|
233
|
+
...(https ? [`${cfg.ports.https}:443`] : []),
|
|
234
|
+
],
|
|
235
|
+
}
|
|
183
236
|
: {}),
|
|
184
237
|
extra_hosts: ["host.docker.internal:host-gateway"],
|
|
185
238
|
volumes: [
|
|
186
239
|
"./dynamic:/etc/edge/dynamic:ro",
|
|
187
|
-
...(cfg.
|
|
240
|
+
...(cfg.tls.strategy === "mkcert"
|
|
241
|
+
? ["./certs:/etc/edge/certs:ro"]
|
|
242
|
+
: []),
|
|
188
243
|
...Object.entries(cfg.mounts)
|
|
189
244
|
.sort(([a], [b]) => a.localeCompare(b))
|
|
190
245
|
.map(([host, target]) => `${host}:${target}:ro`),
|
|
@@ -1,47 +1,30 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Side effects for the edge: docker,
|
|
3
|
-
* routing decision lives in render.ts.
|
|
2
|
+
* Side effects for the edge: docker, files, and the TLS strategy's (tls.ts).
|
|
3
|
+
* Thin on purpose — every routing decision lives in render.ts.
|
|
4
4
|
*/
|
|
5
|
-
import { spawnSync } from "child_process";
|
|
6
5
|
import * as fs from "fs";
|
|
7
6
|
import * as net from "net";
|
|
8
7
|
import * as path from "path";
|
|
8
|
+
import { must, sh } from "./exec.js";
|
|
9
9
|
import {
|
|
10
|
-
|
|
10
|
+
EDGE_ROOT_LABEL,
|
|
11
11
|
edgeNames,
|
|
12
12
|
renderEdgeCompose,
|
|
13
13
|
renderEdgeDynamic,
|
|
14
14
|
} from "./render.js";
|
|
15
|
+
import {
|
|
16
|
+
assertCaUsable,
|
|
17
|
+
caDir,
|
|
18
|
+
caTrusted,
|
|
19
|
+
edgeTrust,
|
|
20
|
+
edgeUntrust,
|
|
21
|
+
sharedCa,
|
|
22
|
+
tlsStrategyFor,
|
|
23
|
+
warnImplicitTlsOnce,
|
|
24
|
+
} from "./tls.js";
|
|
15
25
|
import type { EdgeConfig } from "./types.js";
|
|
16
26
|
|
|
17
|
-
|
|
18
|
-
cmd: string,
|
|
19
|
-
args: string[],
|
|
20
|
-
opts: { env?: NodeJS.ProcessEnv; inherit?: boolean } = {},
|
|
21
|
-
) {
|
|
22
|
-
const r = spawnSync(cmd, args, {
|
|
23
|
-
encoding: "utf-8",
|
|
24
|
-
env: { ...process.env, ...opts.env },
|
|
25
|
-
stdio: opts.inherit ? "inherit" : "pipe",
|
|
26
|
-
});
|
|
27
|
-
if (r.error) throw new Error(`${cmd} is not installed (${r.error.message})`);
|
|
28
|
-
return {
|
|
29
|
-
code: r.status ?? 1,
|
|
30
|
-
out: (r.stdout ?? "").trim(),
|
|
31
|
-
err: (r.stderr ?? "").trim(),
|
|
32
|
-
};
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
function must(
|
|
36
|
-
cmd: string,
|
|
37
|
-
args: string[],
|
|
38
|
-
opts: { env?: NodeJS.ProcessEnv } = {},
|
|
39
|
-
): string {
|
|
40
|
-
const r = sh(cmd, args, opts);
|
|
41
|
-
if (r.code !== 0)
|
|
42
|
-
throw new Error(`${cmd} ${args.join(" ")} failed:\n${r.err || r.out}`);
|
|
43
|
-
return r.out;
|
|
44
|
-
}
|
|
27
|
+
export { assertCaUsable, caDir, caTrusted, sharedCa };
|
|
45
28
|
|
|
46
29
|
function write(file: string, content: string): void {
|
|
47
30
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
@@ -68,102 +51,25 @@ export function portInUse(port: number): Promise<boolean> {
|
|
|
68
51
|
});
|
|
69
52
|
}
|
|
70
53
|
|
|
71
|
-
// ── Certificates (
|
|
54
|
+
// ── Certificates (the edge.tls strategy, see tls.ts) ────────────────────────
|
|
72
55
|
|
|
73
|
-
|
|
74
|
-
return cfg.caroot ?? path.join(cfg.stateDir, "ca");
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/** True when the edge uses an existing CA (`caroot`) rather than its own. */
|
|
78
|
-
export function sharedCa(cfg: EdgeConfig): boolean {
|
|
79
|
-
return cfg.caroot !== null;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* A shared CA must already exist with its key: mkcert silently creates a new
|
|
84
|
-
* CA in an empty CAROOT, and devices that trust the shared one would not
|
|
85
|
-
* trust that. The org's own CA may be created on first use.
|
|
86
|
-
*/
|
|
87
|
-
export function assertCaUsable(cfg: EdgeConfig): void {
|
|
88
|
-
if (!sharedCa(cfg)) return;
|
|
89
|
-
const dir = caDir(cfg);
|
|
90
|
-
if (!fs.existsSync(dir))
|
|
91
|
-
throw new Error(
|
|
92
|
-
`edge.caroot ${dir} does not exist. The edge never creates a CA there; ` +
|
|
93
|
-
`point caroot at the existing CA (\`mkcert -CAROOT\`).`,
|
|
94
|
-
);
|
|
95
|
-
for (const f of ["rootCA.pem", "rootCA-key.pem"])
|
|
96
|
-
if (!fs.existsSync(path.join(dir, f)))
|
|
97
|
-
throw new Error(
|
|
98
|
-
`edge.caroot ${dir} has no ${f}. Refusing to run: mkcert would mint a ` +
|
|
99
|
-
`new CA that other devices don't trust.`,
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function mkcertEnv(cfg: EdgeConfig): NodeJS.ProcessEnv {
|
|
104
|
-
return { CAROOT: caDir(cfg) };
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
/** Issue (or re-issue when names changed) the edge cert from the org's own CA. */
|
|
56
|
+
/** Make sure the edge's cert exists, as its TLS strategy does it. */
|
|
108
57
|
export function ensureCerts(cfg: EdgeConfig): {
|
|
109
58
|
issued: boolean;
|
|
110
59
|
names: string[];
|
|
111
60
|
} {
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
assertCaUsable(cfg);
|
|
115
|
-
const dir = path.join(cfg.stateDir, "certs");
|
|
116
|
-
const stamp = path.join(dir, "names.txt");
|
|
117
|
-
const want = names.join("\n");
|
|
118
|
-
if (
|
|
119
|
-
fs.existsSync(path.join(dir, "edge.pem")) &&
|
|
120
|
-
fs.existsSync(stamp) &&
|
|
121
|
-
fs.readFileSync(stamp, "utf-8") === want
|
|
122
|
-
)
|
|
123
|
-
return { issued: false, names };
|
|
124
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
125
|
-
if (!sharedCa(cfg))
|
|
126
|
-
fs.mkdirSync(caDir(cfg), { recursive: true, mode: 0o700 });
|
|
127
|
-
must(
|
|
128
|
-
"mkcert",
|
|
129
|
-
[
|
|
130
|
-
"-cert-file",
|
|
131
|
-
path.join(dir, "edge.pem"),
|
|
132
|
-
"-key-file",
|
|
133
|
-
path.join(dir, "edge-key.pem"),
|
|
134
|
-
...names,
|
|
135
|
-
],
|
|
136
|
-
{ env: mkcertEnv(cfg) },
|
|
137
|
-
);
|
|
138
|
-
write(stamp, want);
|
|
139
|
-
return { issued: true, names };
|
|
61
|
+
const r = tlsStrategyFor(cfg).capabilities.ensure(cfg);
|
|
62
|
+
return { issued: r.issued, names: r.names };
|
|
140
63
|
}
|
|
141
64
|
|
|
142
|
-
/**
|
|
65
|
+
/** `edge trust`: install the CA (mkcert only; prompts for a password). */
|
|
143
66
|
export function trustCa(cfg: EdgeConfig): number {
|
|
144
|
-
|
|
145
|
-
if (!sharedCa(cfg))
|
|
146
|
-
fs.mkdirSync(caDir(cfg), { recursive: true, mode: 0o700 });
|
|
147
|
-
return sh("mkcert", ["-install"], { env: mkcertEnv(cfg), inherit: true })
|
|
148
|
-
.code;
|
|
67
|
+
return edgeTrust(cfg);
|
|
149
68
|
}
|
|
150
69
|
|
|
70
|
+
/** `edge untrust` (mkcert only; never a shared CA). */
|
|
151
71
|
export function untrustCa(cfg: EdgeConfig): number {
|
|
152
|
-
|
|
153
|
-
throw new Error(
|
|
154
|
-
`edge.caroot is a shared CA (${caDir(cfg)}); other tools and devices ` +
|
|
155
|
-
`rely on it being trusted, so the edge never uninstalls it.`,
|
|
156
|
-
);
|
|
157
|
-
return sh("mkcert", ["-uninstall"], { env: mkcertEnv(cfg), inherit: true })
|
|
158
|
-
.code;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/** Whether the org CA is in the macOS system keychain (null: can't tell). */
|
|
162
|
-
export function caTrusted(cfg: EdgeConfig): boolean | null {
|
|
163
|
-
const pem = path.join(caDir(cfg), "rootCA.pem");
|
|
164
|
-
if (!fs.existsSync(pem)) return false;
|
|
165
|
-
if (process.platform !== "darwin") return null;
|
|
166
|
-
return sh("security", ["verify-cert", "-c", pem]).code === 0;
|
|
72
|
+
return edgeUntrust(cfg);
|
|
167
73
|
}
|
|
168
74
|
|
|
169
75
|
// ── Included dynamic config ─────────────────────────────────────────────────
|
|
@@ -188,9 +94,13 @@ export function includeFiles(dir: string | null): string[] {
|
|
|
188
94
|
*/
|
|
189
95
|
const INCLUDED_LEDGER = "included.json";
|
|
190
96
|
|
|
97
|
+
/** A stack's own route file (`stack-<project>.yml`, stacks/runtime.ts). */
|
|
98
|
+
export const isStackRoutes = (f: string): boolean =>
|
|
99
|
+
/^stack-.*\.ya?ml$/.test(f);
|
|
100
|
+
|
|
191
101
|
/** Files other writers own in the dynamic directory: never copied over or pruned. */
|
|
192
|
-
const isOwnedElsewhere = (f: string): boolean =>
|
|
193
|
-
f === GENERATED ||
|
|
102
|
+
export const isOwnedElsewhere = (f: string): boolean =>
|
|
103
|
+
f === GENERATED || isStackRoutes(f);
|
|
194
104
|
|
|
195
105
|
function readLedger(stateDir: string): string[] {
|
|
196
106
|
try {
|
|
@@ -228,7 +138,101 @@ export function syncIncluded(cfg: EdgeConfig): string[] {
|
|
|
228
138
|
|
|
229
139
|
// ── Edge ─────────────────────────────────────────────────────────────────────
|
|
230
140
|
|
|
231
|
-
|
|
141
|
+
/** Where the dynamic config mounts in the edge container (render.ts). */
|
|
142
|
+
const DYNAMIC_TARGET = "/etc/edge/dynamic";
|
|
143
|
+
|
|
144
|
+
/** An existing container of the edge's name: who owns it, as it says. */
|
|
145
|
+
export interface ExistingEdge {
|
|
146
|
+
/** Its `den.edge.root` label, or null for an unlabelled (legacy) edge. */
|
|
147
|
+
root: string | null;
|
|
148
|
+
/** The dir its dynamic-config mount comes from, when it has one. */
|
|
149
|
+
mountsDir: string | null;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/** The container named `name`, any state; null when there is none. */
|
|
153
|
+
export function existingEdge(name: string): ExistingEdge | null {
|
|
154
|
+
const r = sh("docker", [
|
|
155
|
+
"inspect",
|
|
156
|
+
"--type",
|
|
157
|
+
"container",
|
|
158
|
+
"-f",
|
|
159
|
+
`{{index .Config.Labels "${EDGE_ROOT_LABEL}"}}|{{range .Mounts}}{{.Source}}>{{.Destination}};{{end}}`,
|
|
160
|
+
name,
|
|
161
|
+
]);
|
|
162
|
+
// A container always prints the separator; nothing at all is no answer.
|
|
163
|
+
if (r.code !== 0 || !r.out.includes("|")) return null;
|
|
164
|
+
const [label, mounts] = r.out.split("|");
|
|
165
|
+
const root = label && label !== "<no value>" ? label : null;
|
|
166
|
+
let mountsDir: string | null = null;
|
|
167
|
+
for (const m of (mounts ?? "").split(";")) {
|
|
168
|
+
const [src, dst] = m.split(">");
|
|
169
|
+
if (src && dst === DYNAMIC_TARGET) mountsDir = path.dirname(src);
|
|
170
|
+
}
|
|
171
|
+
return { root, mountsDir };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function realOrSelf(p: string): string {
|
|
175
|
+
try {
|
|
176
|
+
return fs.realpathSync(p);
|
|
177
|
+
} catch {
|
|
178
|
+
return path.resolve(p);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Whether this invocation may create, recreate or stop the edge
|
|
184
|
+
* (0.3.0-dev.40, P1: a scratch root with its own DEN_EDGE_DIR but the real
|
|
185
|
+
* edge.org recreated the user's real edge with the scratch certs and
|
|
186
|
+
* routes; the container and project are named by org). An existing edge is
|
|
187
|
+
* ours only when its `den.edge.root` label is this state dir; an
|
|
188
|
+
* unlabelled (legacy) one is adopted only from the default state dir, never
|
|
189
|
+
* under a DEN_EDGE_DIR that overrides it. `takeOver` (`edge up
|
|
190
|
+
* --take-over`) is the only override. Nothing is touched; only paths are
|
|
191
|
+
* said.
|
|
192
|
+
*/
|
|
193
|
+
export function assertEdgeOwnedHere(
|
|
194
|
+
cfg: EdgeConfig,
|
|
195
|
+
opts: {
|
|
196
|
+
takeOver?: boolean;
|
|
197
|
+
warn?: (line: string) => void;
|
|
198
|
+
/** What a refusal says it won't do: "replacing" (up), "stopping" (down). */
|
|
199
|
+
verb?: "replacing" | "stopping";
|
|
200
|
+
} = {},
|
|
201
|
+
): void {
|
|
202
|
+
const n = edgeNames(cfg.org);
|
|
203
|
+
const found = existingEdge(n.container);
|
|
204
|
+
if (!found) return;
|
|
205
|
+
const here = realOrSelf(cfg.stateDir);
|
|
206
|
+
const ours = found.root
|
|
207
|
+
? realOrSelf(found.root) === here
|
|
208
|
+
: !cfg.defaultStateDir; // legacy: only from the default state dir
|
|
209
|
+
if (ours) return;
|
|
210
|
+
const other = found.root ?? found.mountsDir ?? "another state dir";
|
|
211
|
+
const warn = opts.warn ?? ((l: string) => console.warn(l));
|
|
212
|
+
// A scratch root on the real org: the name is the org's, so only another
|
|
213
|
+
// org isolates it.
|
|
214
|
+
if (cfg.defaultStateDir)
|
|
215
|
+
warn(
|
|
216
|
+
`Warning: DEN_EDGE_DIR (${cfg.stateDir}) is not this config's edge dir, ` +
|
|
217
|
+
`and edge.org "${cfg.org}" names an edge that exists already. ` +
|
|
218
|
+
`Give this root a distinct edge.org.`,
|
|
219
|
+
);
|
|
220
|
+
if (opts.takeOver) {
|
|
221
|
+
warn(`Taking over edge ${n.container} from ${other} (--take-over).`);
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
throw new Error(
|
|
225
|
+
`edge ${n.container} belongs to ${other}; not ${opts.verb ?? "replacing"} it`,
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
export async function edgeUp(
|
|
230
|
+
cfg: EdgeConfig,
|
|
231
|
+
opts: { takeOver?: boolean } = {},
|
|
232
|
+
) {
|
|
233
|
+
// Before anything is written or started: an edge of this name that
|
|
234
|
+
// another state dir owns is never replaced.
|
|
235
|
+
assertEdgeOwnedHere(cfg, opts);
|
|
232
236
|
const n = edgeNames(cfg.org);
|
|
233
237
|
if (cfg.ports && containerState(n.container) !== "running") {
|
|
234
238
|
for (const p of [cfg.ports.http, cfg.ports.https])
|
|
@@ -240,6 +244,7 @@ export async function edgeUp(cfg: EdgeConfig) {
|
|
|
240
244
|
);
|
|
241
245
|
}
|
|
242
246
|
assertCaUsable(cfg);
|
|
247
|
+
warnImplicitTlsOnce(cfg);
|
|
243
248
|
ensureNetwork(n.network);
|
|
244
249
|
for (const net of [
|
|
245
250
|
...(cfg.dockerLabels ? [cfg.dockerLabels.network] : []),
|
|
@@ -256,12 +261,14 @@ export async function edgeUp(cfg: EdgeConfig) {
|
|
|
256
261
|
const included = syncIncluded(cfg);
|
|
257
262
|
write(path.join(cfg.stateDir, "dynamic", GENERATED), renderEdgeDynamic(cfg));
|
|
258
263
|
const compose = path.join(cfg.stateDir, "compose.yml");
|
|
259
|
-
write(compose, renderEdgeCompose(cfg));
|
|
264
|
+
write(compose, renderEdgeCompose(cfg, realOrSelf(cfg.stateDir)));
|
|
260
265
|
must("docker", ["compose", "-f", compose, "up", "-d", "--remove-orphans"]);
|
|
261
266
|
return { certs, included };
|
|
262
267
|
}
|
|
263
268
|
|
|
264
269
|
export function edgeDown(cfg: EdgeConfig): void {
|
|
270
|
+
// The project is named by org too: another dir's edge is not ours to stop.
|
|
271
|
+
assertEdgeOwnedHere(cfg, { verb: "stopping" });
|
|
265
272
|
const compose = path.join(cfg.stateDir, "compose.yml");
|
|
266
273
|
if (fs.existsSync(compose))
|
|
267
274
|
must("docker", ["compose", "-f", compose, "down"]);
|