Give master keys a stable uuid
- master_keys.uuid (UUID v4, unique index): new keys get one on create, existing keys once on start. It survives rotate (new secret) and relabel, so it identifies the key itself rather than its link. - /ui/masterkeys* and /api/v1/masterkeys* return uuid, and :id in those paths accepts the numeric id or the uuid. - /sub/v1 register, peek and devices return key_uuid, so a client can recognise the same key behind a reissued link. It is not in the sen:// link (that would need a new format) and not in config, so rev and existing clients are unaffected - checked against a 0.3.1 ui.db. - Panel: the uuid sits in the key's row like a device ID (full value in the tooltip, click copies) and in the key settings with a copy button; on mobile it is shown under the key's name. - README: uuid in the master key API.
This commit is contained in:
@@ -50,12 +50,12 @@ let apiId = 1;
|
||||
|
||||
// ── мастер-ключи sen:// ──
|
||||
interface Dev { id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null; rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number }
|
||||
interface MK { id: number; label: string; device_limit: number; created_at: number; secret: Buffer; devs: Dev[]; servers: number[]; deleting?: number[] }
|
||||
interface MK { id: number; uuid: string; label: string; device_limit: number; created_at: number; secret: Buffer; devs: Dev[]; servers: number[]; deleting?: number[] }
|
||||
let mkId = 1, devId = 1;
|
||||
const dev = (name: string, platform: string, online: boolean, version = "0.6.5"): Dev => ({ id: devId++, device_id: crypto.randomUUID(), device_name: name, platform, version, created_at: now() - 86400, last_seen: now() - rnd(900), rekey_requested: false, online, lastHandshake: now() - rnd(300), rx: rnd(9e8), tx: rnd(3e8) });
|
||||
const masters: MK[] = [{ id: mkId++, label: "Семья", device_limit: 3, created_at: now() - 86400 * 2, secret: crypto.randomBytes(16), devs: [dev("MacBook Ивана", "macOS", true), dev("Windows-ПК", "Windows", false, "0.6.2")], servers: [0, 1] }];
|
||||
const masters: MK[] = [{ id: mkId++, uuid: crypto.randomUUID(), label: "Семья", device_limit: 3, created_at: now() - 86400 * 2, secret: crypto.randomBytes(16), devs: [dev("MacBook Ивана", "macOS", true), dev("Windows-ПК", "Windows", false, "0.6.2")], servers: [0, 1] }];
|
||||
const signPub = crypto.randomBytes(32);
|
||||
const mkOut = (m: MK) => ({ id: m.id, label: m.label, device_limit: m.device_limit, devices: m.devs.length, servers: m.servers, created_at: m.created_at, ...(m.deleting ? { deleting: m.deleting } : {}) });
|
||||
const mkOut = (m: MK) => ({ id: m.id, uuid: m.uuid, label: m.label, device_limit: m.device_limit, devices: m.devs.length, servers: m.servers, created_at: m.created_at, ...(m.deleting ? { deleting: m.deleting } : {}) });
|
||||
// Мок: устройство «догнано» на всех серверах ключа, кроме Tokyo (id 2 — ещё не подключалась).
|
||||
const devOut = (m: MK, d: Dev) => ({ ...d, servers_total: m.servers.length, servers_ok: m.servers.filter(s => s !== 2).length });
|
||||
const find = (id: string) => masters.find(m => m.id === Number(id) && !m.deleting);
|
||||
@@ -123,7 +123,7 @@ app.get("/ui/masterkeys", (_q, r) => r.json({ enabled: true, tls: false, keys: m
|
||||
app.post("/ui/masterkeys", (q, r) => {
|
||||
const limit = q.body.device_limit ?? 3;
|
||||
if (!q.body.label || !Number.isInteger(limit) || limit < 1 || limit > 100) return r.status(400).json({ error: "Неверные параметры" });
|
||||
const m: MK = { id: mkId++, label: q.body.label, device_limit: limit, created_at: now(), secret: crypto.randomBytes(16), devs: [], servers: q.body.servers ?? [0] };
|
||||
const m: MK = { id: mkId++, uuid: crypto.randomUUID(), label: q.body.label, device_limit: limit, created_at: now(), secret: crypto.randomBytes(16), devs: [], servers: q.body.servers ?? [0] };
|
||||
masters.push(m); r.status(201).json(mkOut(m));
|
||||
});
|
||||
app.patch("/ui/masterkeys/:id", (q, r) => {
|
||||
|
||||
@@ -12,7 +12,7 @@ import './masterkeys.css';
|
||||
// плюс лимит устройств; устройства регистрируются сами (приватный ключ остаётся
|
||||
// на них) и здесь только отображаются и отзываются. Формат — docs/sen-link.md.
|
||||
// deleting — ключ удалён и уже не работает, но эти серверы ещё не подтвердили, что сняли его пиров.
|
||||
interface MasterKey { id: number; label: string; device_limit: number; devices: number; servers: number[]; created_at: number; deleting?: number[] }
|
||||
interface MasterKey { id: number; uuid: string; label: string; device_limit: number; devices: number; servers: number[]; created_at: number; deleting?: number[] }
|
||||
interface Device {
|
||||
id: number; device_id: string; device_name: string; platform: string; version: string; created_at: number; last_seen: number | null;
|
||||
rekey_requested: boolean; online: boolean; lastHandshake: number; rx: number; tx: number;
|
||||
@@ -138,6 +138,19 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
{/* Слева — настройки самого ключа */}
|
||||
<section className="mk-box">
|
||||
<h4 className="mk-box-title">Настройки ключа</h4>
|
||||
<div className="mk-setting">
|
||||
<div className="mk-setting-text">
|
||||
<span className="mk-setting-name">UUID</span>
|
||||
<code className="mk-uuid" title={k.uuid}>{k.uuid}</code>
|
||||
<span className="mk-setting-hint">Постоянный: перевыпуск ссылки его не меняет</span>
|
||||
</div>
|
||||
<div className="mk-setting-ctl">
|
||||
<button className="btn btn--tonal" onClick={() =>
|
||||
copyText(k.uuid).then(() => showMsg('UUID скопирован'), () => showMsg('Не удалось скопировать'))}>
|
||||
<IcoCopy /> Скопировать
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mk-setting">
|
||||
<div className="mk-setting-text">
|
||||
<label className="mk-setting-name" htmlFor={`mk-limit-${k.id}`}>Лимит устройств</label>
|
||||
@@ -221,6 +234,17 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
</div>
|
||||
);
|
||||
|
||||
// UUID ключа в строке списка — как ID устройств под ним (та же ширина и обрезка),
|
||||
// полностью — в подсказке, по клику копируется.
|
||||
const uuidCell = (k: MasterKey) => (
|
||||
<span className="tip-wrap mk-uuid-wrap" data-tip={`UUID ${k.uuid} · нажми, чтобы скопировать`}>
|
||||
<button className="mk-uuid-btn" aria-label={`Скопировать UUID ${k.uuid}`}
|
||||
onClick={() => copyText(k.uuid).then(() => showMsg('UUID скопирован'), () => showMsg('Не удалось скопировать'))}>
|
||||
<code className="mk-device-id">{k.uuid}</code>
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
|
||||
const caret = (k: MasterKey) => (
|
||||
<button className={`mk-caret${folded.has(k.id) ? ' mk-caret--folded' : ''}`}
|
||||
aria-label={folded.has(k.id) ? 'Развернуть' : 'Свернуть'} aria-expanded={!folded.has(k.id)}
|
||||
@@ -285,7 +309,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
if (k.deleting) return (
|
||||
<tr key={k.id} className="mk-tr-master mk-last">
|
||||
<td><span className="mk-name"><IcoKey />{k.label}</span></td>
|
||||
<td colSpan={8}>{deletingChip(k)}</td>
|
||||
<td className="td-mono">{uuidCell(k)}</td>
|
||||
<td colSpan={7}>{deletingChip(k)}</td>
|
||||
</tr>
|
||||
);
|
||||
const list = devs[k.id] ?? [];
|
||||
@@ -295,7 +320,8 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
<Fragment key={k.id}>
|
||||
<tr className="mk-tr-master">
|
||||
<td><span className="mk-name">{caret(k)}<IcoKey />{k.label}</span></td>
|
||||
<td colSpan={6}></td>
|
||||
<td className="td-mono">{uuidCell(k)}</td>
|
||||
<td colSpan={5}></td>
|
||||
<td>{meter(k, list.length)}</td>
|
||||
<td className="td-actions mk-td-actions">{actions(k)}</td>
|
||||
</tr>
|
||||
@@ -339,7 +365,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
<li className="mk-root" key={k.id}>
|
||||
<div className="mk-master">
|
||||
<IcoKey />
|
||||
<span className="mk-master-label">{k.label}</span>
|
||||
<span className="mk-master-label">{k.label}<code className="mk-master-uuid">{k.uuid}</code></span>
|
||||
{deletingChip(k)}
|
||||
</div>
|
||||
</li>
|
||||
@@ -352,7 +378,7 @@ export default function MasterKeysPage({ token, showMsg }: PageProps) {
|
||||
<div className="mk-master">
|
||||
{caret(k)}
|
||||
<IcoKey />
|
||||
<span className="mk-master-label">{k.label}</span>
|
||||
<span className="mk-master-label">{k.label}<code className="mk-master-uuid">{k.uuid}</code></span>
|
||||
{meter(k, list.length)}
|
||||
{actions(k)}
|
||||
</div>
|
||||
|
||||
@@ -26,6 +26,19 @@ LICENSE file in the root directory of this source tree.
|
||||
.mk-tr-master td { background: var(--surface-container); font-weight: 500; padding-top: 6px; padding-bottom: 6px; }
|
||||
.mk-tr-master:hover td { background: var(--surface-container); }
|
||||
.mk-name { display: inline-flex; align-items: center; gap: 8px; color: var(--primary); }
|
||||
/* UUID ключа в строке: выглядит как ID устройства, по клику копирует */
|
||||
.mk-uuid-btn {
|
||||
background: none; border: 0; padding: 0; margin: 0;
|
||||
cursor: pointer; font: inherit; font-weight: 400; color: inherit; line-height: inherit;
|
||||
}
|
||||
.mk-uuid-btn:hover .mk-device-id { color: var(--primary); }
|
||||
.mk-uuid-btn:focus-visible { outline: 2px solid var(--primary); outline-offset: 2px; border-radius: 4px; }
|
||||
.mk-master-uuid {
|
||||
display: block;
|
||||
font-family: var(--mono); font-size: 12px; font-weight: 400;
|
||||
color: var(--neutral-text);
|
||||
overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.mk-name .mk-caret { margin-left: -8px; }
|
||||
.mk-tr-master .mk-name { color: var(--on-surface); }
|
||||
.mk-tr-master .mk-name svg { color: var(--primary); }
|
||||
@@ -111,6 +124,10 @@ LICENSE file in the root directory of this source tree.
|
||||
.mk-setting-name { font-size: 14px; font-weight: 500; color: var(--on-surface); }
|
||||
.mk-setting-hint { font-size: 12px; color: var(--neutral-text); }
|
||||
.mk-setting-ctl { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
|
||||
.mk-uuid {
|
||||
font-family: var(--mono); font-size: 12px; color: var(--on-surface);
|
||||
overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 100%;
|
||||
}
|
||||
.mk-setting-ctl .btn { height: 36px; padding: 0 16px; font-size: 13px; }
|
||||
.mk-limit-input { height: 36px; width: 80px !important; text-align: center; }
|
||||
|
||||
|
||||
+30
-13
@@ -51,7 +51,7 @@ export interface Servers {
|
||||
}
|
||||
|
||||
interface MasterRow {
|
||||
id: number; label: string; secret: Buffer; device_limit: number;
|
||||
id: number; uuid: string; label: string; secret: Buffer; device_limit: number;
|
||||
server_id: number; created_at: number; revoked_at: number | null;
|
||||
}
|
||||
interface DeviceRow {
|
||||
@@ -195,6 +195,16 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
// таблицы колонки может не быть.
|
||||
if (!(uidb.prepare("PRAGMA table_info(devices)").all() as { name: string }[]).some(c => c.name === "version"))
|
||||
uidb.exec("ALTER TABLE devices ADD COLUMN version TEXT NOT NULL DEFAULT ''");
|
||||
// uuid мастер-ключа — постоянный идентификатор: не меняется ни при перевыпуске ссылки,
|
||||
// ни при правке метки. Существующим ключам выдаётся здесь один раз.
|
||||
if (!(uidb.prepare("PRAGMA table_info(master_keys)").all() as { name: string }[]).some(c => c.name === "uuid"))
|
||||
uidb.exec("ALTER TABLE master_keys ADD COLUMN uuid TEXT");
|
||||
{
|
||||
const setUuid = uidb.prepare<[string, number]>("UPDATE master_keys SET uuid = ? WHERE id = ?");
|
||||
for (const { id } of uidb.prepare("SELECT id FROM master_keys WHERE uuid IS NULL").all() as { id: number }[])
|
||||
setUuid.run(crypto.randomUUID(), id);
|
||||
}
|
||||
uidb.exec("CREATE UNIQUE INDEX IF NOT EXISTS master_keys_uuid ON master_keys (uuid)");
|
||||
uidb.pragma("foreign_keys = ON");
|
||||
|
||||
// Перенос со времени одного сервера: ключ отдавал master_keys.server_id, а пир устройства
|
||||
@@ -222,7 +232,8 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
masterById: uidb.prepare<[number]>("SELECT * FROM master_keys WHERE id = ? AND revoked_at IS NULL"),
|
||||
masterRow: uidb.prepare<[number]>("SELECT m.*, (SELECT COUNT(*) FROM devices d WHERE d.master_id = m.id) AS devices FROM master_keys m WHERE id = ?"),
|
||||
masterBySecret: uidb.prepare<[Buffer]>("SELECT * FROM master_keys WHERE secret = ? AND revoked_at IS NULL"),
|
||||
masterInsert: uidb.prepare<[string, Buffer, number, number, number]>("INSERT INTO master_keys (label, secret, device_limit, server_id, created_at) VALUES (?, ?, ?, ?, ?)"),
|
||||
masterInsert: uidb.prepare<[string, string, Buffer, number, number, number]>("INSERT INTO master_keys (uuid, label, secret, device_limit, server_id, created_at) VALUES (?, ?, ?, ?, ?, ?)"),
|
||||
masterIdByUuid: uidb.prepare<[string]>("SELECT id FROM master_keys WHERE uuid = ?"),
|
||||
masterUpdate: uidb.prepare<[string, number, number]>("UPDATE master_keys SET label = ?, device_limit = ? WHERE id = ?"),
|
||||
masterSecret: uidb.prepare<[Buffer, number]>("UPDATE master_keys SET secret = ? WHERE id = ?"),
|
||||
masterRevoke: uidb.prepare<[number, number]>("UPDATE master_keys SET revoked_at = ? WHERE id = ?"),
|
||||
@@ -577,7 +588,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
// devices / device_limit: сколько мест занято с этим устройством — клиент показывает это сразу,
|
||||
// без отдельного запроса списка.
|
||||
send(req, res, 201, {
|
||||
device: dev.id, config: cfg,
|
||||
device: dev.id, config: cfg, key_uuid: master.uuid,
|
||||
devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit,
|
||||
});
|
||||
}));
|
||||
@@ -594,7 +605,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
send(req, res, secretB ? 404 : 400, { error: secretB ? "not_found" : "bad_request" });
|
||||
return;
|
||||
}
|
||||
send(req, res, 200, { devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit });
|
||||
send(req, res, 200, { key_uuid: master.uuid, devices: (q.deviceCount.get(master.id) as { n: number }).n, device_limit: master.device_limit });
|
||||
}));
|
||||
|
||||
sub.get("/sub/v1/config", deviceAuth, wrap(async (req, res) => {
|
||||
@@ -614,6 +625,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
if (!master) { send(req, res, 404, { error: "not_found" }); return; }
|
||||
send(req, res, 200, {
|
||||
name: master.label,
|
||||
key_uuid: master.uuid,
|
||||
device_limit: master.device_limit,
|
||||
devices: (q.devicesOf.all(master.id) as DeviceRow[]).map(d => ({
|
||||
id: d.id, name: d.device_name, platform: d.platform, version: d.version,
|
||||
@@ -674,6 +686,11 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
});
|
||||
};
|
||||
const idOf = (v: string) => { const n = Number(v); return Number.isSafeInteger(n) ? n : -1; }; // -1 — не найдётся
|
||||
// Мастер-ключ в пути — числовой id или uuid.
|
||||
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
const masterIdOf = (v: string) => UUID_RE.test(v)
|
||||
? (q.masterIdByUuid.get(v.toLowerCase()) as { id: number } | undefined)?.id ?? -1
|
||||
: idOf(v);
|
||||
const LABEL = /^[^\r\n<>]{1,40}$/;
|
||||
const limitOk = (n: unknown): n is number => Number.isInteger(n) && (n as number) >= 1 && (n as number) <= 100;
|
||||
|
||||
@@ -707,7 +724,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
// deleting — ключ удалён, но эти серверы ещё не подтвердили, что сняли его пиров.
|
||||
function masterOut(m: MasterRow & { devices: number }) {
|
||||
return {
|
||||
id: m.id, label: m.label, device_limit: m.device_limit,
|
||||
id: m.id, uuid: m.uuid, label: m.label, device_limit: m.device_limit,
|
||||
devices: m.devices, servers: serverIdsOf(m.id), created_at: m.created_at,
|
||||
...(m.revoked_at !== null ? { deleting: serverIdsOf(m.id) } : {}),
|
||||
};
|
||||
@@ -728,7 +745,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
catch (e) { throw wanted === undefined && scope === null ? new ApiError(400, "Сначала добавьте сервер") : e; }
|
||||
let id = 0;
|
||||
uidb.transaction(() => {
|
||||
id = Number(q.masterInsert.run(label as string, crypto.randomBytes(16), limit as number, ids[0], now()).lastInsertRowid);
|
||||
id = Number(q.masterInsert.run(crypto.randomUUID(), label as string, crypto.randomBytes(16), limit as number, ids[0], now()).lastInsertRowid);
|
||||
for (const sid of ids) q.serverAdd.run(id, sid);
|
||||
})();
|
||||
return masterOutById(id);
|
||||
@@ -849,12 +866,12 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
res.json({ enabled: signKey !== null, tls: tlsPin !== null, keys: listMasters(null) });
|
||||
});
|
||||
masterKeys.post("/", aw((req, res) => { res.status(201).json(createMaster(req.body, null)); }));
|
||||
masterKeys.patch("/:id", aw(async (req, res) => { res.json(await updateMaster(masterFor(idOf(req.params.id), null), req.body, null)); }));
|
||||
masterKeys.post("/:id/rotate", aw((req, res) => { const m = masterFor(idOf(req.params.id), null); rotateMaster(m); res.json({ id: m.id }); }));
|
||||
masterKeys.post("/:id/rekey", aw((req, res) => { const m = masterFor(idOf(req.params.id), null); rekeyMaster(m); res.json({ id: m.id }); }));
|
||||
masterKeys.delete("/:id", aw(async (req, res) => { res.json(await deleteMaster(masterFor(idOf(req.params.id), null))); }));
|
||||
masterKeys.get("/:id/link", aw((req, res) => { res.json(linkOf(masterFor(idOf(req.params.id), null), req)); }));
|
||||
masterKeys.get("/:id/devices", aw(async (req, res) => { res.json({ devices: await masterDevices(masterFor(idOf(req.params.id), null)) }); }));
|
||||
masterKeys.patch("/:id", aw(async (req, res) => { res.json(await updateMaster(masterFor(masterIdOf(req.params.id), null), req.body, null)); }));
|
||||
masterKeys.post("/:id/rotate", aw((req, res) => { const m = masterFor(masterIdOf(req.params.id), null); rotateMaster(m); res.json({ id: m.id }); }));
|
||||
masterKeys.post("/:id/rekey", aw((req, res) => { const m = masterFor(masterIdOf(req.params.id), null); rekeyMaster(m); res.json({ id: m.id }); }));
|
||||
masterKeys.delete("/:id", aw(async (req, res) => { res.json(await deleteMaster(masterFor(masterIdOf(req.params.id), null))); }));
|
||||
masterKeys.get("/:id/link", aw((req, res) => { res.json(linkOf(masterFor(masterIdOf(req.params.id), null), req)); }));
|
||||
masterKeys.get("/:id/devices", aw(async (req, res) => { res.json({ devices: await masterDevices(masterFor(masterIdOf(req.params.id), null)) }); }));
|
||||
|
||||
const devices = Router();
|
||||
devices.delete("/:id", aw(async (req, res) => { res.json(await deleteDevice(deviceFor(idOf(req.params.id)))); }));
|
||||
@@ -866,7 +883,7 @@ export function createSub(deps: { uidb: Database.Database; servers: Servers; bas
|
||||
// область проверялась через их мастер-ключ.
|
||||
function apiRouter(scopeOf: (req: Request) => number[]) {
|
||||
const r = Router();
|
||||
const master = (req: Request) => masterFor(idOf(req.params.id), scopeOf(req));
|
||||
const master = (req: Request) => masterFor(masterIdOf(req.params.id), scopeOf(req));
|
||||
const device = (req: Request) => deviceFor(idOf(req.params.device), master(req));
|
||||
|
||||
r.get("/", aw((req, res) => { res.json({ keys: listMasters(scopeOf(req)) }); }));
|
||||
|
||||
Reference in New Issue
Block a user