laneyard 0.2.0 → 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 +122 -15
- package/dist/src/cli/detect.js +60 -16
- package/dist/src/cli/prompt.js +36 -0
- package/dist/src/cli/setup.js +282 -0
- package/dist/src/cli/style.js +31 -0
- package/dist/src/cli/user.js +127 -0
- package/dist/src/config/accounts.js +163 -0
- package/dist/src/config/load.js +61 -1
- package/dist/src/config/schema.js +26 -1
- package/dist/src/config/store.js +10 -0
- package/dist/src/config/yaml.js +14 -0
- package/dist/src/heuristics/blocking-actions.js +20 -0
- package/dist/src/heuristics/platforms.js +77 -0
- package/dist/src/heuristics/readiness.js +159 -16
- package/dist/src/main.js +48 -7
- package/dist/src/server/app.js +65 -13
- package/dist/src/server/auth.js +85 -17
- package/dist/src/server/permissions.js +73 -0
- package/dist/src/server/routes/projects.js +42 -0
- package/dist/src/server/routes/readiness.js +16 -3
- package/dist/src/server/routes/users.js +64 -0
- package/dist/src/sidecar/bridge.js +17 -1
- package/dist/web/assets/{Editor-D5Q4uj4n.js → Editor-DNFBA4gv.js} +1 -1
- package/dist/web/assets/index-De6sxx6G.css +1 -0
- package/dist/web/assets/index-TWRQ1cJg.js +62 -0
- package/dist/web/index.html +2 -2
- package/package.json +1 -1
- package/dist/web/assets/index-D9_EL8LA.js +0 -62
- package/dist/web/assets/index-ZUqTX6d1.css +0 -1
|
@@ -20,6 +20,9 @@ const META = {
|
|
|
20
20
|
appStoreConnect: { id: "app-store-connect", title: "App Store Connect authentication" },
|
|
21
21
|
match: { id: "match", title: "match usable without intervention" },
|
|
22
22
|
blockingActions: { id: "blocking-actions", title: "no action known to stop and ask" },
|
|
23
|
+
androidKeystore: { id: "android-keystore", title: "keystore reachable without a prompt" },
|
|
24
|
+
playStore: { id: "play-store", title: "Play Store service account" },
|
|
25
|
+
platforms: { id: "platforms", title: "what this project builds for" },
|
|
23
26
|
};
|
|
24
27
|
/**
|
|
25
28
|
* Does the remote answer without asking anyone for credentials?
|
|
@@ -161,33 +164,173 @@ export function checkBlockingActions(uses) {
|
|
|
161
164
|
[...new Set(findings.map((f) => f.finding.fix))].join(" ")),
|
|
162
165
|
};
|
|
163
166
|
}
|
|
167
|
+
/** `build_android_app` is the newer name for the same action. */
|
|
168
|
+
const GRADLE_ACTIONS = new Set(["gradle", "build_android_app"]);
|
|
169
|
+
/**
|
|
170
|
+
* The arguments that say a lane hands gradle a keystore of its own.
|
|
171
|
+
*
|
|
172
|
+
* Read literally, like everything else here. `gradle(storePassword: ENV["PW"])`
|
|
173
|
+
* arrives with no `storePassword` at all — the sidecar drops what it cannot
|
|
174
|
+
* read — and this check says so rather than inventing a verdict.
|
|
175
|
+
*/
|
|
176
|
+
const KEYSTORE_ARGS = ["storeFile", "storePassword"];
|
|
177
|
+
/** `ANDROID_KEYSTORE_PASSWORD`, `KEYSTORE_PASSWORD`, `STORE_PASSWORD`. */
|
|
178
|
+
const KEYSTORE_PASSWORD = /(^|_)(KEYSTORE|STORE)_PASSWORD$/;
|
|
179
|
+
const STORE_KEYSTORE_PASSWORD = "Store the keystore passphrase as `ANDROID_KEYSTORE_PASSWORD` from the secrets tab, " +
|
|
180
|
+
"and read it in the lane with `storePassword: ENV[\"ANDROID_KEYSTORE_PASSWORD\"]`.";
|
|
181
|
+
export function checkAndroidKeystore(uses, secretKeys) {
|
|
182
|
+
if (!uses.ok) {
|
|
183
|
+
return { ...META.androidKeystore, ...undetermined(`could not read the lanes: ${uses.reason}`) };
|
|
184
|
+
}
|
|
185
|
+
const calls = uses.value.flatMap((lane) => lane.actions.filter((a) => GRADLE_ACTIONS.has(a.name)).map((action) => ({ lane: lane.lane, action })));
|
|
186
|
+
if (calls.length === 0) {
|
|
187
|
+
return { ...META.androidKeystore, ...ok("no lane builds with gradle.") };
|
|
188
|
+
}
|
|
189
|
+
const signing = calls.filter((c) => KEYSTORE_ARGS.some((arg) => arg in c.action.args));
|
|
190
|
+
if (signing.length === 0) {
|
|
191
|
+
// Deliberately a statement about what was read, not about the build: a
|
|
192
|
+
// keystore configured in `build.gradle` or through the environment is
|
|
193
|
+
// invisible from here, and claiming it is absent would be a guess.
|
|
194
|
+
return {
|
|
195
|
+
...META.androidKeystore,
|
|
196
|
+
...ok("no lane passes `storeFile` or `storePassword` to gradle: nothing here needs unlocking."),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
const withoutPassphrase = signing.filter((c) => !("storePassword" in c.action.args));
|
|
200
|
+
if (withoutPassphrase.length === 0) {
|
|
201
|
+
return {
|
|
202
|
+
...META.androidKeystore,
|
|
203
|
+
...ok(`${nameList(signing.map((c) => c.lane))} passes \`storePassword\` in the call itself: ` +
|
|
204
|
+
"gradle has what it needs."),
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
if (secretKeys.some((key) => KEYSTORE_PASSWORD.test(key))) {
|
|
208
|
+
return { ...META.androidKeystore, ...ok("a keystore passphrase is in the vault.") };
|
|
209
|
+
}
|
|
210
|
+
return {
|
|
211
|
+
...META.androidKeystore,
|
|
212
|
+
...warn(`${nameList(withoutPassphrase.map((c) => c.lane))} hands gradle a keystore, but no keystore ` +
|
|
213
|
+
"passphrase is in the vault: gradle asks for it and waits.", STORE_KEYSTORE_PASSWORD, "secrets"),
|
|
214
|
+
};
|
|
215
|
+
}
|
|
216
|
+
/** The two names for the same action. `supply` is the older one. */
|
|
217
|
+
const PLAY_UPLOAD_ACTIONS = new Set(["upload_to_play_store", "supply"]);
|
|
218
|
+
/** `SUPPLY_JSON_KEY` and `SUPPLY_JSON_KEY_DATA`, the names fastlane itself reads. */
|
|
219
|
+
const PLAY_JSON_KEY = /^SUPPLY_JSON_KEY/;
|
|
220
|
+
/** The same credential, named in the call instead of in the vault. */
|
|
221
|
+
const PLAY_KEY_ARGS = ["json_key", "json_key_data"];
|
|
222
|
+
const STORE_PLAY_KEY = "Store the service account JSON as `SUPPLY_JSON_KEY_DATA` from the secrets tab. " +
|
|
223
|
+
"A service account does not expire on its own.";
|
|
224
|
+
export function checkPlayStore(uses, secretKeys) {
|
|
225
|
+
if (!uses.ok) {
|
|
226
|
+
return { ...META.playStore, ...undetermined(`could not read the lanes: ${uses.reason}`) };
|
|
227
|
+
}
|
|
228
|
+
const calls = uses.value.flatMap((lane) => lane.actions
|
|
229
|
+
.filter((a) => PLAY_UPLOAD_ACTIONS.has(a.name))
|
|
230
|
+
.map((action) => ({ lane: lane.lane, action })));
|
|
231
|
+
if (calls.length === 0) {
|
|
232
|
+
return { ...META.playStore, ...ok("no lane uploads to the Play Store.") };
|
|
233
|
+
}
|
|
234
|
+
if (secretKeys.some((key) => PLAY_JSON_KEY.test(key))) {
|
|
235
|
+
return { ...META.playStore, ...ok("a Play Store service account is in the vault.") };
|
|
236
|
+
}
|
|
237
|
+
const named = calls.filter((c) => PLAY_KEY_ARGS.some((arg) => arg in c.action.args));
|
|
238
|
+
if (named.length > 0) {
|
|
239
|
+
// A path in a Fastfile says nothing about whether that file exists on this
|
|
240
|
+
// machine. Neither a tick nor a warning would be honest.
|
|
241
|
+
return {
|
|
242
|
+
...META.playStore,
|
|
243
|
+
...undetermined(`${nameList(named.map((c) => c.lane))} passes \`json_key\` in the call: whether that file is ` +
|
|
244
|
+
"on this machine is not something a Fastfile says.", STORE_PLAY_KEY),
|
|
245
|
+
};
|
|
246
|
+
}
|
|
247
|
+
return {
|
|
248
|
+
...META.playStore,
|
|
249
|
+
...warn(`${nameList(calls.map((c) => c.lane))} uploads to the Play Store, but no service account is in ` +
|
|
250
|
+
"the vault: supply stops and asks which credentials to use.", STORE_PLAY_KEY, "secrets"),
|
|
251
|
+
};
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* The line a project with no platform gets instead of half a checklist.
|
|
255
|
+
*
|
|
256
|
+
* It is not a check — nothing was examined — and it is not a warning either.
|
|
257
|
+
* It says what is missing and where to write it down, and then the shared
|
|
258
|
+
* checks above it still stand on their own.
|
|
259
|
+
*/
|
|
260
|
+
function platformNote(platforms) {
|
|
261
|
+
return {
|
|
262
|
+
...META.platforms,
|
|
263
|
+
...undetermined(platforms.ok
|
|
264
|
+
? "no platform detected: no Xcode project and no Gradle build in the repository, " +
|
|
265
|
+
"and laneyard.yml names none. Only the checks above apply."
|
|
266
|
+
: `could not tell: ${platforms.reason}. Only the checks above apply.`, "Add `platforms: [ios]`, `[android]` or both to laneyard.yml in the repository, " +
|
|
267
|
+
"and the checks for that platform appear here."),
|
|
268
|
+
};
|
|
269
|
+
}
|
|
164
270
|
/**
|
|
165
271
|
* The checklist itself: a table, so that adding a check is adding a row.
|
|
166
272
|
*
|
|
167
273
|
* Exported because the order it declares is the order the interface shows, and
|
|
168
274
|
* a test that checks that should read the same list.
|
|
169
275
|
*/
|
|
170
|
-
export const
|
|
171
|
-
{
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
276
|
+
export const SECTIONS = [
|
|
277
|
+
{
|
|
278
|
+
platform: "all",
|
|
279
|
+
checks: [
|
|
280
|
+
{ ...META.repository, run: (i) => checkRepository(i.probeRepository) },
|
|
281
|
+
{ ...META.dependencies, run: (i) => checkDependencies(i.dependencies) },
|
|
282
|
+
{ ...META.blockingActions, run: (i) => checkBlockingActions(i.uses) },
|
|
283
|
+
],
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
platform: "ios",
|
|
287
|
+
checks: [
|
|
288
|
+
{ ...META.appStoreConnect, run: (i) => checkAppStoreConnect(i.secretKeys) },
|
|
289
|
+
{ ...META.match, run: (i) => checkMatch(i.uses, i.secretKeys) },
|
|
290
|
+
],
|
|
291
|
+
},
|
|
292
|
+
{
|
|
293
|
+
platform: "android",
|
|
294
|
+
checks: [
|
|
295
|
+
{ ...META.androidKeystore, run: (i) => checkAndroidKeystore(i.uses, i.secretKeys) },
|
|
296
|
+
{ ...META.playStore, run: (i) => checkPlayStore(i.uses, i.secretKeys) },
|
|
297
|
+
],
|
|
298
|
+
},
|
|
176
299
|
];
|
|
177
300
|
/**
|
|
178
|
-
* Runs
|
|
301
|
+
* Runs the sections that apply, and guarantees the list comes back whole.
|
|
179
302
|
*
|
|
180
303
|
* Each check already commits to not throwing. This wrapper exists because that
|
|
181
304
|
* promise is worth exactly as much as the next person to edit a check: one
|
|
182
305
|
* probe misbehaving must cost one line of the checklist, not the checklist.
|
|
183
306
|
*/
|
|
184
|
-
export async function
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
307
|
+
export async function runChecklist(input) {
|
|
308
|
+
// Read once and defensively: this is the one value used outside a check body,
|
|
309
|
+
// and a malformed one must not be the thing that empties the whole screen.
|
|
310
|
+
const platforms = input.platforms && input.platforms.ok && Array.isArray(input.platforms.value)
|
|
311
|
+
? input.platforms.value
|
|
312
|
+
: [];
|
|
313
|
+
const sections = await Promise.all(SECTIONS.filter((s) => s.platform === "all" || platforms.includes(s.platform)).map(async (section) => ({
|
|
314
|
+
platform: section.platform,
|
|
315
|
+
checks: await Promise.all(section.checks.map(async ({ id, title, run }) => {
|
|
316
|
+
try {
|
|
317
|
+
return await run(input);
|
|
318
|
+
}
|
|
319
|
+
catch (cause) {
|
|
320
|
+
return {
|
|
321
|
+
id,
|
|
322
|
+
title,
|
|
323
|
+
state: "unknown",
|
|
324
|
+
detail: `the check itself failed: ${reasonOf(cause)}`,
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
})),
|
|
328
|
+
})));
|
|
329
|
+
// The shared section is always first and always present, so the note about a
|
|
330
|
+
// missing platform sits under the checks that still applied.
|
|
331
|
+
const shared = sections[0];
|
|
332
|
+
if (platforms.length === 0 && shared) {
|
|
333
|
+
shared.checks.push(platformNote(input.platforms ?? { ok: true, value: [] }));
|
|
334
|
+
}
|
|
335
|
+
return sections;
|
|
193
336
|
}
|
package/dist/src/main.js
CHANGED
|
@@ -3,9 +3,11 @@ import { realpathSync } from "node:fs";
|
|
|
3
3
|
import { mkdir } from "node:fs/promises";
|
|
4
4
|
import { homedir } from "node:os";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
+
import { bold, dim, field, heading } from "./cli/style.js";
|
|
6
7
|
import { fileURLToPath } from "node:url";
|
|
7
|
-
import {
|
|
8
|
+
import { runSetupCommand } from "./cli/setup.js";
|
|
8
9
|
import { runSecretCommand } from "./cli/secret.js";
|
|
10
|
+
import { runUserCommand } from "./cli/user.js";
|
|
9
11
|
import { ConfigStore } from "./config/store.js";
|
|
10
12
|
import { CacheStore } from "./db/cache.js";
|
|
11
13
|
import { openDatabase } from "./db/open.js";
|
|
@@ -16,7 +18,7 @@ import { Vault } from "./secrets/vault.js";
|
|
|
16
18
|
import { makeInvoke } from "./sidecar/bridge.js";
|
|
17
19
|
import { LaneReader } from "./sidecar/lanes.js";
|
|
18
20
|
import { UsesReader } from "./sidecar/uses.js";
|
|
19
|
-
export const version = "0.
|
|
21
|
+
export const version = "0.3.0";
|
|
20
22
|
/** Assembles the server from a data folder. */
|
|
21
23
|
export async function createServerFromConfig(root) {
|
|
22
24
|
const config = new ConfigStore(join(root, "config.yml"));
|
|
@@ -61,7 +63,24 @@ async function main() {
|
|
|
61
63
|
});
|
|
62
64
|
const server = config.server();
|
|
63
65
|
await app.listen({ port: server.port, host: server.bind });
|
|
64
|
-
|
|
66
|
+
const projects = config.projects();
|
|
67
|
+
process.stdout.write(heading(`laneyard ${version}`) +
|
|
68
|
+
field("listening", `http://localhost:${server.port}`) +
|
|
69
|
+
"\n" +
|
|
70
|
+
field("config", join(root, "config.yml")) +
|
|
71
|
+
"\n" +
|
|
72
|
+
field("projects", projects.length === 0
|
|
73
|
+
? dim("none yet")
|
|
74
|
+
: projects.map((p) => p.slug).join(", ")) +
|
|
75
|
+
"\n" +
|
|
76
|
+
(projects.length === 0
|
|
77
|
+
? // A server with nothing to build should say what to do next rather
|
|
78
|
+
// than sit there looking successful.
|
|
79
|
+
"\n" +
|
|
80
|
+
dim(" No project yet. From a folder that already uses fastlane:\n") +
|
|
81
|
+
` ${bold("laneyard setup")}\n`
|
|
82
|
+
: "") +
|
|
83
|
+
"\n");
|
|
65
84
|
}
|
|
66
85
|
/**
|
|
67
86
|
* True when this file is what the user launched, rather than something that
|
|
@@ -84,9 +103,12 @@ function invokedDirectly() {
|
|
|
84
103
|
}
|
|
85
104
|
const USAGE = `laneyard — a self-hosted web UI for fastlane
|
|
86
105
|
|
|
87
|
-
laneyard
|
|
106
|
+
laneyard setup set up the project in the current directory
|
|
107
|
+
--yes accepts every detected value without asking
|
|
88
108
|
laneyard secret set NAME [--project <slug>]
|
|
89
109
|
store a secret, its value read from standard input
|
|
110
|
+
laneyard user add NAME [--role admin|builder]
|
|
111
|
+
create an account, its password read from standard input
|
|
90
112
|
laneyard start the server
|
|
91
113
|
laneyard --version print the version
|
|
92
114
|
|
|
@@ -101,7 +123,7 @@ function explainStartupFailure(cause) {
|
|
|
101
123
|
if (message.includes("ENOENT") && message.includes("config.yml")) {
|
|
102
124
|
return ("No configuration yet.\n\n" +
|
|
103
125
|
" cd into a project that already uses fastlane, then run:\n" +
|
|
104
|
-
" laneyard
|
|
126
|
+
" laneyard setup\n\n" +
|
|
105
127
|
"That writes " + join(homeDir(), "config.yml") + " for you.");
|
|
106
128
|
}
|
|
107
129
|
return message;
|
|
@@ -116,13 +138,16 @@ if (invokedDirectly()) {
|
|
|
116
138
|
process.stdout.write(`${version}\n`);
|
|
117
139
|
process.exit(0);
|
|
118
140
|
}
|
|
119
|
-
if (command === "
|
|
141
|
+
if (command === "setup") {
|
|
120
142
|
const home = homeDir();
|
|
121
143
|
await mkdir(home, { recursive: true });
|
|
122
144
|
const slugIndex = rest.indexOf("--slug");
|
|
123
145
|
const slug = slugIndex === -1 ? undefined : rest[slugIndex + 1];
|
|
146
|
+
// `--yes` accepts every proposal, for scripts and for anyone who has done
|
|
147
|
+
// this before. Interactive is the default because the values are guesses.
|
|
148
|
+
const yes = rest.includes("--yes") || rest.includes("-y");
|
|
124
149
|
try {
|
|
125
|
-
process.exit(await
|
|
150
|
+
process.exit(await runSetupCommand(process.cwd(), join(home, "config.yml"), { slug, yes }));
|
|
126
151
|
}
|
|
127
152
|
catch (cause) {
|
|
128
153
|
// A taken slug or an unreadable file are ordinary situations. A stack
|
|
@@ -142,6 +167,22 @@ if (invokedDirectly()) {
|
|
|
142
167
|
err: (text) => process.stderr.write(text),
|
|
143
168
|
}));
|
|
144
169
|
}
|
|
170
|
+
if (command === "user") {
|
|
171
|
+
const home = homeDir();
|
|
172
|
+
await mkdir(home, { recursive: true });
|
|
173
|
+
process.exit(await runUserCommand(home, rest, {
|
|
174
|
+
stdin: process.stdin,
|
|
175
|
+
interactive: process.stdin.isTTY === true,
|
|
176
|
+
out: (text) => process.stdout.write(text),
|
|
177
|
+
err: (text) => process.stderr.write(text),
|
|
178
|
+
}));
|
|
179
|
+
}
|
|
180
|
+
// 0.1.0 shipped this as `add`. Anyone who learned that name deserves a
|
|
181
|
+
// sentence rather than "Unknown command".
|
|
182
|
+
if (command === "add") {
|
|
183
|
+
process.stderr.write("`laneyard add` is now `laneyard setup`.\n");
|
|
184
|
+
process.exit(1);
|
|
185
|
+
}
|
|
145
186
|
if (command !== undefined) {
|
|
146
187
|
process.stderr.write(`Unknown command: ${command}\n\n${USAGE}`);
|
|
147
188
|
process.exit(1);
|
package/dist/src/server/app.js
CHANGED
|
@@ -4,21 +4,27 @@ import Fastify from "fastify";
|
|
|
4
4
|
import { existsSync } from "node:fs";
|
|
5
5
|
import { dirname, join } from "node:path";
|
|
6
6
|
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { LEGACY_ADMIN_NAME } from "../config/load.js";
|
|
7
8
|
import { RunStore } from "../db/runs.js";
|
|
8
9
|
import { Workspace } from "../git/workspace.js";
|
|
9
10
|
import { LogStore } from "../logs/store.js";
|
|
10
11
|
import { executeRun } from "../runner/orchestrate.js";
|
|
11
12
|
import { RunQueue } from "../runner/queue.js";
|
|
12
|
-
import { LoginThrottle, SESSION_COOKIE, SessionStore
|
|
13
|
+
import { authenticate, LoginThrottle, SESSION_COOKIE, SessionStore } from "./auth.js";
|
|
14
|
+
import { requiresAdmin } from "./permissions.js";
|
|
13
15
|
import { registerFastfileRoutes } from "./routes/fastfile.js";
|
|
14
16
|
import { registerProjectRoutes } from "./routes/projects.js";
|
|
15
17
|
import { registerReadinessRoutes } from "./routes/readiness.js";
|
|
16
18
|
import { registerRunRoutes } from "./routes/runs.js";
|
|
17
19
|
import { registerSecretRoutes } from "./routes/secrets.js";
|
|
20
|
+
import { registerUserRoutes } from "./routes/users.js";
|
|
18
21
|
import { registerWebSocket } from "./ws.js";
|
|
19
22
|
export async function buildApp(deps) {
|
|
20
23
|
const app = Fastify({ logger: false });
|
|
21
24
|
await app.register(cookie);
|
|
25
|
+
// Declared up front so every request carries the field on the same shape,
|
|
26
|
+
// rather than each one growing a property the first time a hook writes it.
|
|
27
|
+
app.decorateRequest("identity", undefined);
|
|
22
28
|
const workspacePath = (slug) => join(deps.root, "workspaces", slug);
|
|
23
29
|
const ctx = {
|
|
24
30
|
...deps,
|
|
@@ -98,32 +104,77 @@ export async function buildApp(deps) {
|
|
|
98
104
|
app.addHook("onClose", async () => queue.close());
|
|
99
105
|
const throttle = new LoginThrottle();
|
|
100
106
|
app.post("/api/login", async (req, reply) => {
|
|
101
|
-
const { password } = req.body;
|
|
102
|
-
|
|
103
|
-
|
|
107
|
+
const { name, password } = (req.body ?? {});
|
|
108
|
+
// A body with no name is the 0.2 login form, which knew only a password.
|
|
109
|
+
// It authenticates as `admin`, which is precisely the account a lone
|
|
110
|
+
// `server.password_hash` is loaded as — so an upgraded install keeps
|
|
111
|
+
// working, and the loader still owes nobody an answer about which form
|
|
112
|
+
// the file used.
|
|
113
|
+
const account = name ?? LEGACY_ADMIN_NAME;
|
|
114
|
+
const waitMs = throttle.retryAfterMs(account);
|
|
104
115
|
if (waitMs > 0) {
|
|
105
116
|
return reply
|
|
106
117
|
.code(429)
|
|
107
118
|
.header("retry-after", Math.ceil(waitMs / 1000))
|
|
108
119
|
.send({ error: `Too many attempts. Try again in ${Math.ceil(waitMs / 1000)}s.` });
|
|
109
120
|
}
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
121
|
+
const users = deps.config.server()?.users ?? [];
|
|
122
|
+
const identity = password ? await authenticate(users, account, password) : null;
|
|
123
|
+
if (!identity) {
|
|
124
|
+
throttle.recordFailure(account);
|
|
125
|
+
// One message for a wrong password and for a name that does not exist:
|
|
126
|
+
// telling them apart is telling a stranger which accounts are worth
|
|
127
|
+
// attacking.
|
|
128
|
+
return reply.code(401).send({ error: "Incorrect name or password" });
|
|
113
129
|
}
|
|
114
|
-
throttle.recordSuccess();
|
|
115
|
-
const token = ctx.sessions.issue();
|
|
130
|
+
throttle.recordSuccess(account);
|
|
131
|
+
const token = ctx.sessions.issue(identity);
|
|
116
132
|
return reply
|
|
117
133
|
.setCookie(SESSION_COOKIE, token, { path: "/", httpOnly: true, sameSite: "lax" })
|
|
118
|
-
.send({ ok: true });
|
|
134
|
+
.send({ ok: true, name: identity.name, role: identity.role });
|
|
119
135
|
});
|
|
120
|
-
// Every /api route except /api/login requires a session
|
|
136
|
+
// Every /api route except /api/login requires a session, and the routes on
|
|
137
|
+
// the admin list require an admin. Both decided here, once: a permission
|
|
138
|
+
// expressed as an `if` inside a handler is one nobody finds during an audit.
|
|
121
139
|
app.addHook("onRequest", async (req, reply) => {
|
|
122
|
-
if (!req.url.startsWith("/api") || req.url === "/api/login")
|
|
140
|
+
if (!req.url.startsWith("/api") || req.url.split("?")[0] === "/api/login")
|
|
123
141
|
return;
|
|
124
|
-
|
|
142
|
+
const session = ctx.sessions.get(req.cookies[SESSION_COOKIE]);
|
|
143
|
+
// The session holds who someone was when they signed in; the configuration
|
|
144
|
+
// holds who they are. They part company whenever config.yml is edited —
|
|
145
|
+
// from `laneyard user add`, which is another process entirely, or by hand.
|
|
146
|
+
// So the account is looked up again on every request: an account that is
|
|
147
|
+
// gone has no session, and a demotion takes effect at once rather than at
|
|
148
|
+
// the next restart. It is one find over a handful of entries.
|
|
149
|
+
const account = session && ctx.config.server()?.users.find((u) => u.name === session.name);
|
|
150
|
+
if (!session || !account) {
|
|
125
151
|
return reply.code(401).send({ error: "Session required" });
|
|
126
152
|
}
|
|
153
|
+
const identity = { name: account.name, role: account.role };
|
|
154
|
+
req.identity = identity;
|
|
155
|
+
if (identity.role !== "admin" && requiresAdmin(req.method, req.url)) {
|
|
156
|
+
return reply.code(403).send({ error: "This action requires the admin role" });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
/**
|
|
160
|
+
* Signing out ends this session and no other.
|
|
161
|
+
*
|
|
162
|
+
* The same person may be signed in on a laptop and on a phone; pressing sign
|
|
163
|
+
* out on one of them must not be an act on the other. Only removing the
|
|
164
|
+
* account ends every session it has, and that is a different action with a
|
|
165
|
+
* different name.
|
|
166
|
+
*/
|
|
167
|
+
app.post("/api/logout", async (req, reply) => {
|
|
168
|
+
const token = req.cookies[SESSION_COOKIE];
|
|
169
|
+
if (token)
|
|
170
|
+
ctx.sessions.revoke(token);
|
|
171
|
+
return reply.clearCookie(SESSION_COOKIE, { path: "/" }).code(204).send();
|
|
172
|
+
});
|
|
173
|
+
app.get("/api/me", async (req) => {
|
|
174
|
+
// Non-null because the hook above rejected every request that has no
|
|
175
|
+
// identity before this handler could be reached.
|
|
176
|
+
const { name, role } = req.identity;
|
|
177
|
+
return { name, role };
|
|
127
178
|
});
|
|
128
179
|
ctx.sockets = await registerWebSocket(app, ctx);
|
|
129
180
|
await registerProjectRoutes(app, ctx);
|
|
@@ -131,6 +182,7 @@ export async function buildApp(deps) {
|
|
|
131
182
|
await registerSecretRoutes(app, ctx);
|
|
132
183
|
await registerReadinessRoutes(app, ctx);
|
|
133
184
|
await registerFastfileRoutes(app, ctx);
|
|
185
|
+
await registerUserRoutes(app, ctx);
|
|
134
186
|
// Resolved from the module's location, not from the data folder:
|
|
135
187
|
// `deps.root` is ~/.laneyard, the built SPA lives in the repository. Two
|
|
136
188
|
// relative positions, depending on whether we're running on the sources
|
package/dist/src/server/auth.js
CHANGED
|
@@ -37,45 +37,113 @@ export async function verifyPassword(password, stored) {
|
|
|
37
37
|
return false;
|
|
38
38
|
}
|
|
39
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* A hash no password matches, used to spend the same work on an unknown name.
|
|
42
|
+
*
|
|
43
|
+
* Built from random bytes rather than by hashing something: `hashPassword` is
|
|
44
|
+
* synchronous and would freeze the server for its duration. The shape is what
|
|
45
|
+
* matters — `verifyPassword` derives a 32-byte key with the same parameters and
|
|
46
|
+
* then fails the comparison, which is exactly what a wrong password costs.
|
|
47
|
+
*/
|
|
48
|
+
const DECOY_HASH = `scrypt$${randomBytes(16).toString("hex")}$${randomBytes(32).toString("hex")}`;
|
|
49
|
+
/**
|
|
50
|
+
* Turns a name and a password into who that is, or into nothing.
|
|
51
|
+
*
|
|
52
|
+
* An unknown name is verified against a decoy hash instead of returning early.
|
|
53
|
+
* Without that, a refusal for a name that does not exist comes back in
|
|
54
|
+
* microseconds while a wrong password takes the thirty milliseconds scrypt
|
|
55
|
+
* costs — and the login form becomes a way to enumerate accounts before trying
|
|
56
|
+
* a single password against them.
|
|
57
|
+
*/
|
|
58
|
+
export async function authenticate(users, name, password) {
|
|
59
|
+
const user = users.find((u) => u.name === name);
|
|
60
|
+
if (!user) {
|
|
61
|
+
await verifyPassword(password, DECOY_HASH);
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
if (!(await verifyPassword(password, user.password_hash)))
|
|
65
|
+
return null;
|
|
66
|
+
return { name: user.name, role: user.role };
|
|
67
|
+
}
|
|
40
68
|
/** In-memory sessions: they don't survive a restart, and that's just fine. */
|
|
41
69
|
export class SessionStore {
|
|
42
|
-
tokens = new
|
|
43
|
-
issue() {
|
|
70
|
+
tokens = new Map();
|
|
71
|
+
issue(identity) {
|
|
44
72
|
const token = randomBytes(32).toString("hex");
|
|
45
|
-
this.tokens.
|
|
73
|
+
this.tokens.set(token, identity);
|
|
46
74
|
return token;
|
|
47
75
|
}
|
|
76
|
+
/** Who the token belongs to, or undefined if it belongs to nobody. */
|
|
77
|
+
get(token) {
|
|
78
|
+
return token === undefined ? undefined : this.tokens.get(token);
|
|
79
|
+
}
|
|
48
80
|
valid(token) {
|
|
49
|
-
return token !== undefined
|
|
81
|
+
return this.get(token) !== undefined;
|
|
50
82
|
}
|
|
51
83
|
revoke(token) {
|
|
52
84
|
this.tokens.delete(token);
|
|
53
85
|
}
|
|
86
|
+
/**
|
|
87
|
+
* Drops every session belonging to a name.
|
|
88
|
+
*
|
|
89
|
+
* The identity is snapshotted when the session is issued, which is what makes
|
|
90
|
+
* a request cheap — but it also means removing an account or changing its role
|
|
91
|
+
* leaves the old answer live in whatever browsers already had it. Without this,
|
|
92
|
+
* "remove the account" and "revoke access" are two different things, while
|
|
93
|
+
* every interface that offers the first implies the second.
|
|
94
|
+
*/
|
|
95
|
+
revokeAllFor(name) {
|
|
96
|
+
for (const [token, identity] of this.tokens) {
|
|
97
|
+
if (identity.name === name)
|
|
98
|
+
this.tokens.delete(token);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
54
101
|
}
|
|
55
102
|
export const SESSION_COOKIE = "laneyard_session";
|
|
103
|
+
/** Past this many tracked names, the entries that no longer delay anyone go. */
|
|
104
|
+
const THROTTLE_PRUNE_ABOVE = 1_000;
|
|
56
105
|
/**
|
|
57
|
-
* Slows down repeated login attempts.
|
|
106
|
+
* Slows down repeated login attempts, one account at a time.
|
|
58
107
|
*
|
|
59
108
|
* Without this, a network neighbour could try passwords as fast as the
|
|
60
109
|
* server responds. The delay grows with failures and resets to zero on a
|
|
61
110
|
* success: the legitimate user who mistypes once doesn't feel it.
|
|
111
|
+
*
|
|
112
|
+
* Counted per name rather than globally: a single counter means anyone able to
|
|
113
|
+
* reach the login form can lock out every account on the server by hammering
|
|
114
|
+
* one name — a denial of service disguised as a security measure.
|
|
62
115
|
*/
|
|
63
116
|
export class LoginThrottle {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
return Math.max(0, this.until - now);
|
|
117
|
+
perName = new Map();
|
|
118
|
+
/** Milliseconds left to wait for that name, 0 if the way is clear. */
|
|
119
|
+
retryAfterMs(name, now = Date.now()) {
|
|
120
|
+
return Math.max(0, (this.perName.get(name)?.until ?? 0) - now);
|
|
69
121
|
}
|
|
70
|
-
recordFailure(now = Date.now()) {
|
|
71
|
-
this.failures
|
|
122
|
+
recordFailure(name, now = Date.now()) {
|
|
123
|
+
const state = this.perName.get(name) ?? { failures: 0, until: 0 };
|
|
124
|
+
state.failures += 1;
|
|
72
125
|
// 0, 0, 0, then 1s, 2s, 4s… capped at one minute.
|
|
73
|
-
if (
|
|
74
|
-
|
|
126
|
+
if (state.failures > 3) {
|
|
127
|
+
state.until = now + Math.min(60_000, 2 ** (state.failures - 4) * 1000);
|
|
75
128
|
}
|
|
129
|
+
this.perName.set(name, state);
|
|
130
|
+
// The key is whatever the caller sent, so an attacker chooses how many
|
|
131
|
+
// entries exist. Dropping those whose delay has run out bounds the map by
|
|
132
|
+
// how fast someone can be delayed, not by how many names they can invent.
|
|
133
|
+
if (this.perName.size > THROTTLE_PRUNE_ABOVE)
|
|
134
|
+
this.prune(now);
|
|
76
135
|
}
|
|
77
|
-
recordSuccess() {
|
|
78
|
-
this.
|
|
79
|
-
|
|
136
|
+
recordSuccess(name) {
|
|
137
|
+
this.perName.delete(name);
|
|
138
|
+
}
|
|
139
|
+
/** Number of names currently tracked. Exposed so the pruning can be tested. */
|
|
140
|
+
size() {
|
|
141
|
+
return this.perName.size;
|
|
142
|
+
}
|
|
143
|
+
prune(now) {
|
|
144
|
+
for (const [name, state] of this.perName) {
|
|
145
|
+
if (state.until <= now)
|
|
146
|
+
this.perName.delete(name);
|
|
147
|
+
}
|
|
80
148
|
}
|
|
81
149
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who can do what — the whole answer, in one file.
|
|
3
|
+
*
|
|
4
|
+
* A permission expressed as an `if` inside a handler is a permission nobody
|
|
5
|
+
* finds when it matters: during an audit, or after an incident. So there are no
|
|
6
|
+
* such `if`s. Every route that needs more than a session is named below, and the
|
|
7
|
+
* single hook in `app.ts` is the only thing that reads this.
|
|
8
|
+
*
|
|
9
|
+
* Two roles, and only two. `admin` may do everything; `builder` may start a
|
|
10
|
+
* build, watch it, cancel it and download what it produced — nothing that
|
|
11
|
+
* changes what a build does, and nothing that reveals a credential.
|
|
12
|
+
*/
|
|
13
|
+
/**
|
|
14
|
+
* What each route needs. Everything absent from this list needs only a session.
|
|
15
|
+
*
|
|
16
|
+
* Reading the Fastfile is deliberately not here: a builder who can start a lane
|
|
17
|
+
* benefits from seeing what it does, and it contains no credential — anything
|
|
18
|
+
* that does is in the vault, which is.
|
|
19
|
+
*/
|
|
20
|
+
export const REQUIRES_ADMIN = [
|
|
21
|
+
{ method: "*", path: "/api/secrets" },
|
|
22
|
+
{ method: "*", path: "/api/projects/:slug/secrets" },
|
|
23
|
+
{ method: "PUT", path: "/api/projects/:slug/fastfile" },
|
|
24
|
+
{ method: "POST", path: "/api/projects/:slug/commit" },
|
|
25
|
+
{ method: "POST", path: "/api/projects/:slug/push" },
|
|
26
|
+
{ method: "DELETE", path: "/api/projects/:slug" },
|
|
27
|
+
{ method: "*", path: "/api/users" },
|
|
28
|
+
];
|
|
29
|
+
/**
|
|
30
|
+
* `/api/secrets/APP_KEY?x=1` → `["api", "secrets", "APP_KEY"]`.
|
|
31
|
+
*
|
|
32
|
+
* Each segment is percent-decoded, because the router decodes before it matches
|
|
33
|
+
* and this must see the same path the router did. Comparing the raw text sent
|
|
34
|
+
* `GET /api/%73ecrets` straight past the admin list and into the vault: Fastify
|
|
35
|
+
* routed it to `/api/secrets`, and this function had been looking at `%73ecrets`.
|
|
36
|
+
*/
|
|
37
|
+
function segments(url) {
|
|
38
|
+
return (url.split("?")[0] ?? "")
|
|
39
|
+
.split("/")
|
|
40
|
+
.filter((s) => s !== "")
|
|
41
|
+
.map((s) => {
|
|
42
|
+
try {
|
|
43
|
+
return decodeURIComponent(s);
|
|
44
|
+
}
|
|
45
|
+
catch {
|
|
46
|
+
// Malformed escapes reach no route either; left as-is rather than
|
|
47
|
+
// swallowed, so a segment is never silently emptied into a match.
|
|
48
|
+
return s;
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Does this request need an admin?
|
|
54
|
+
*
|
|
55
|
+
* A pattern matches a request whose path *starts with* it, segment by segment,
|
|
56
|
+
* with `:name` standing for any one segment. The prefix is deliberate:
|
|
57
|
+
* `/api/secrets` means the vault, not one URL, and a table naming every key's
|
|
58
|
+
* route separately would be a table someone forgets to extend the next time a
|
|
59
|
+
* route is added under it. It only ever errs towards refusing, since every
|
|
60
|
+
* entry here is a restriction.
|
|
61
|
+
*/
|
|
62
|
+
export function requiresAdmin(method, url) {
|
|
63
|
+
const parts = segments(url);
|
|
64
|
+
const verb = method.toUpperCase();
|
|
65
|
+
return REQUIRES_ADMIN.some((pattern) => {
|
|
66
|
+
if (pattern.method !== "*" && pattern.method.toUpperCase() !== verb)
|
|
67
|
+
return false;
|
|
68
|
+
const expected = segments(pattern.path);
|
|
69
|
+
if (parts.length < expected.length)
|
|
70
|
+
return false;
|
|
71
|
+
return expected.every((seg, i) => seg.startsWith(":") || seg === parts[i]);
|
|
72
|
+
});
|
|
73
|
+
}
|