headscale leaves the platform. 45 files move to plugins/offscale/ and the
platform stops knowing it exists.
api/router.ts the thin auth-gated proxy, now at /api/offscale
sidecar/ 18 files, the whole headscale contract and its admin keys
db/ schema + queries, offscale_servers
web/ 26 files as panels and a layout — no screen, per the rule
removed from the platform: the hono mount, the `headscale` capability, the
App.tsx route pair, the screen and its barrel, the AppRegistry spread, the
officerdev re-exports, the dock tile, the page-title rule, and both database
barrels. tsgo is clean and nothing references it.
the imports tell the story of what the plugin↔host API actually is. the sidecar
takes @@/sidecar/protocol, @@/sidecar/connect, @@/data-path and
@@/officer-url.mjs; the queries take officerdb/db and officerdb/crypto; the
schema takes officerdb/auth/schema for the one reference a plugin may make; the
web half takes useClient, copyToClipboard, WorkspaceView and TerminalView from
the officerdev barrel. all of it resolves because a plugin lives inside the repo
— no publishing, no version negotiation.
AND IT FOUND A REAL BUG IN THE INSTALLER. createSidecarProxy learns its port
from a one-shot `<name>:server` event and subscribes when the plugin's router is
first imported — at mount. install started the sidecar BEFORE mounting, so the
announcement fired into a void: process online, routes mounted, every request
answering `503 sidecar not available` until something forced a reconnect. it
would have hit every plugin with an http sidecar. `example` never caught it
because it has no listener to announce.
install and enable now mount before starting; disable still unmounts before
stopping. neither direction leaves a mounted route in front of a sidecar that
cannot be reached.
verified live: /api/offscale/_officer/servers answers {"servers":[]}, /offscale
and /offscale/nodes serve, the old /api/headscale is 404, the offscale
capability is registered from the manifest, and officer-offscale is online.
757 pass, same 10 pre-existing failures.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
117 lines
5.6 KiB
TypeScript
117 lines
5.6 KiB
TypeScript
import type { Handler } from 'hono';
|
|
import { getUserById, updateUser, OWNER_USER_ID } from 'officerdb';
|
|
import argon2 from 'argon2';
|
|
import * as errors from '@@/custom-errors';
|
|
import { validatePassword } from '../auth/validate-password';
|
|
|
|
// The owner resetting somebody else's platform password.
|
|
//
|
|
// Named for what it is, and NOT to be confused with `api/auth/reset-password.ts`, which is the
|
|
// token-from-an-email flow the person runs themselves. This one is the owner acting on another account,
|
|
// and the only thing the two share is the column they write.
|
|
//
|
|
// ── Why the platform generates it rather than the owner typing one ──
|
|
//
|
|
// Because the failure this exists to fix is "I created the account and forgot to copy the password down".
|
|
// An owner-typed reset can lose it exactly the same way on the second attempt. A generated one is shown
|
|
// once, in a dialog built to be copied, and is never recoverable afterwards — which is the honest state,
|
|
// since only the argon2 hash is stored.
|
|
//
|
|
// ── The Linux account is deliberately untouched ──
|
|
//
|
|
// A member's Linux account has no password and never had one: `ensureOsUser` runs `useradd` with no `-p`,
|
|
// so it is created password-locked. Nothing needs one — their terminal runs through `setpriv`, which does
|
|
// not authenticate; their SSH access is the key the owner pasted; `su - <member>` as root does not ask.
|
|
// And machine-setup sets `PasswordAuthentication no`, so a Linux password could not be used to log in even
|
|
// if it existed. Setting one here would be creating a new way in, not repairing a lost one.
|
|
|
|
/**
|
|
* Characters the generated password is built from.
|
|
*
|
|
* Split by class because `validatePassword` requires ALL FOUR — twelve characters, a lowercase, an
|
|
* uppercase, a digit and a special. A uniform draw over the union satisfies that only with high
|
|
* probability, and "the reset button fails once a month" is a worse bug than it looks, because it happens
|
|
* to somebody who is already locked out.
|
|
*
|
|
* The specials are a subset of the class `validatePassword` accepts, chosen to survive being copied,
|
|
* pasted, quoted in a shell and read aloud: no quotes, no backslash, no backtick.
|
|
*/
|
|
const CLASSES = ['abcdefghijkmnopqrstuvwxyz', 'ABCDEFGHJKLMNPQRSTUVWXYZ', '23456789', '!@#$%^&*()-_=+'] as const;
|
|
|
|
const PASSWORD_LENGTH = 20;
|
|
|
|
/** Uniform over `alphabet` by rejection sampling — `% n` on a byte biases the first characters. */
|
|
function pick(alphabet: string): string {
|
|
const max = 256 - (256 % alphabet.length);
|
|
const byte = new Uint8Array(1);
|
|
for (;;) {
|
|
crypto.getRandomValues(byte);
|
|
if (byte[0]! < max) return alphabet[byte[0]! % alphabet.length]!;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A password that satisfies `validatePassword` by construction, not by luck.
|
|
*
|
|
* One character from each class first, the remainder from the union, then shuffled — otherwise the first
|
|
* four positions would always be lower, upper, digit, special, which is a pattern worth not having.
|
|
*
|
|
* Fisher-Yates with the same rejection sampling, for the same reason.
|
|
*
|
|
* `l`, `I`, `1`, `O` and `0` are absent from the alphabets above. This gets read off a screen and typed
|
|
* somewhere else often enough that the ambiguous pairs are worth losing.
|
|
*/
|
|
export function generatePassword(length = PASSWORD_LENGTH): string {
|
|
const union = CLASSES.join('');
|
|
const chars = CLASSES.map((cls) => pick(cls));
|
|
while (chars.length < length) chars.push(pick(union));
|
|
|
|
for (let i = chars.length - 1; i > 0; i--) {
|
|
const bound = i + 1;
|
|
const max = 256 - (256 % bound);
|
|
const byte = new Uint8Array(1);
|
|
let j: number;
|
|
do {
|
|
crypto.getRandomValues(byte);
|
|
} while (byte[0]! >= max);
|
|
j = byte[0]! % bound;
|
|
[chars[i], chars[j]] = [chars[j]!, chars[i]!];
|
|
}
|
|
|
|
const password = chars.join('');
|
|
// Generated then CHECKED against the same validator every other path uses. If the rules ever gain a
|
|
// requirement the alphabets above do not cover, this throws here — at the one call site, loudly — rather
|
|
// than minting passwords the login form would go on to reject.
|
|
validatePassword(password);
|
|
return password;
|
|
}
|
|
|
|
export const resetUserPasswordHandler: Handler = async function (ctx) {
|
|
const id = Number(ctx.req.param('id'));
|
|
if (!Number.isInteger(id) || id < 1) throw errors.BAD_REQUEST('Invalid user id');
|
|
|
|
// The owner has `PUT /auth/change-password`, which asks for the current one. Resetting themselves from
|
|
// here would sign them out of the session they are doing it from, for no gain.
|
|
if (id === OWNER_USER_ID) {
|
|
throw errors.BAD_REQUEST('Change your own password from Settings, which asks for the current one.');
|
|
}
|
|
|
|
const existing = await getUserById(id);
|
|
if (!existing) throw errors.NOT_FOUND('User not found');
|
|
|
|
const password = generatePassword();
|
|
|
|
// `passwordChangedAt: new Date()` is what signs them out everywhere. `userMiddleware` refuses any token
|
|
// whose `iat` predates it, so every session they have — browser, phone, an agent holding a token —
|
|
// stops at the next request. That is the point: a reset happens because the old password was lost or
|
|
// leaked, and leaving live sessions behind would defeat both reasons.
|
|
//
|
|
// It also overwrites the null that `create-user.ts` leaves to mean "the owner chose this, not them".
|
|
// Nothing reads that; the only consumer of this column is the token check above.
|
|
await updateUser(id, { password: await argon2.hash(password), passwordChangedAt: new Date() });
|
|
|
|
// Returned exactly once. Only the hash is stored, so there is no second chance to fetch it and the UI
|
|
// says so — which is the whole reason it is generated rather than typed.
|
|
return ctx.json({ email: existing.email, password });
|
|
};
|