245 lines
7.5 KiB
TypeScript
245 lines
7.5 KiB
TypeScript
import { Router, Request, Response } from 'express';
|
|
import { query, queryOne } from '../config/database';
|
|
import { requireAuth, requireAdmin } from '../middleware/auth';
|
|
import { encrypt } from '../utils/crypto';
|
|
import { rateLimitRedis } from '../middleware/rateLimitRedis';
|
|
import { validateRouterOsUsername } from '../utils/routerOsUsername';
|
|
|
|
const router = Router();
|
|
router.use(requireAuth);
|
|
|
|
const presetMutationLimiter = rateLimitRedis({
|
|
windowSec: 60,
|
|
max: 30,
|
|
keyPrefix: 'credential-preset',
|
|
});
|
|
|
|
export interface CredentialPresetRow {
|
|
id: number;
|
|
name: string;
|
|
api_username: string;
|
|
api_password_encrypted: string;
|
|
api_port: number | null;
|
|
ssh_username: string | null;
|
|
ssh_password_encrypted: string | null;
|
|
ssh_port: number | null;
|
|
notes: string | null;
|
|
/** When false, only admins may use this preset when adding/updating devices. */
|
|
allow_operator_use: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
// Public (listable) shape — never returns the encrypted secrets themselves,
|
|
// just booleans telling the UI which slots are populated.
|
|
interface CredentialPresetPublic {
|
|
id: number;
|
|
name: string;
|
|
api_username: string;
|
|
api_port: number | null;
|
|
ssh_username: string | null;
|
|
ssh_port: number | null;
|
|
notes: string | null;
|
|
allow_operator_use: boolean;
|
|
has_api_password: boolean;
|
|
has_ssh_password: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
function toPublic(row: CredentialPresetRow): CredentialPresetPublic {
|
|
return {
|
|
id: row.id,
|
|
name: row.name,
|
|
api_username: row.api_username,
|
|
api_port: row.api_port,
|
|
ssh_username: row.ssh_username,
|
|
ssh_port: row.ssh_port,
|
|
notes: row.notes,
|
|
allow_operator_use: row.allow_operator_use !== false,
|
|
has_api_password: !!row.api_password_encrypted,
|
|
has_ssh_password: !!row.ssh_password_encrypted,
|
|
created_at: row.created_at,
|
|
updated_at: row.updated_at,
|
|
};
|
|
}
|
|
|
|
// GET /api/credential-presets — any authenticated user can list (the Add
|
|
// Device modal needs this for its picker), but secrets are never exposed.
|
|
router.get('/', async (req: Request, res: Response) => {
|
|
const role = req.user?.role;
|
|
const rows = await query<CredentialPresetRow>(
|
|
`SELECT * FROM credential_presets ORDER BY name ASC`
|
|
);
|
|
const filtered =
|
|
role === 'admin'
|
|
? rows
|
|
: rows.filter((r) => (r as CredentialPresetRow).allow_operator_use !== false);
|
|
res.json(filtered.map(toPublic));
|
|
});
|
|
|
|
// POST /api/credential-presets — admin only
|
|
router.post('/', requireAdmin, presetMutationLimiter, async (req: Request, res: Response) => {
|
|
const {
|
|
name,
|
|
api_username,
|
|
api_password,
|
|
api_port,
|
|
ssh_username,
|
|
ssh_password,
|
|
ssh_port,
|
|
notes,
|
|
allow_operator_use,
|
|
} = req.body as {
|
|
name?: string;
|
|
api_username?: string;
|
|
api_password?: string;
|
|
api_port?: number | null;
|
|
ssh_username?: string | null;
|
|
ssh_password?: string | null;
|
|
ssh_port?: number | null;
|
|
notes?: string | null;
|
|
allow_operator_use?: boolean;
|
|
};
|
|
|
|
if (!name || !api_username || !api_password) {
|
|
return res
|
|
.status(400)
|
|
.json({ error: 'name, api_username, and api_password are required' });
|
|
}
|
|
const sshUsernameError = validateRouterOsUsername(ssh_username);
|
|
if (sshUsernameError) return res.status(400).json({ error: sshUsernameError });
|
|
|
|
const existing = await queryOne<{ id: number }>(
|
|
`SELECT id FROM credential_presets WHERE name = $1`,
|
|
[name]
|
|
);
|
|
if (existing) {
|
|
return res.status(409).json({ error: 'A preset with this name already exists' });
|
|
}
|
|
|
|
const encApi = encrypt(api_password);
|
|
const encSsh = ssh_password ? encrypt(ssh_password) : null;
|
|
|
|
const allowOp = allow_operator_use !== false;
|
|
|
|
const rows = await query<CredentialPresetRow>(
|
|
`INSERT INTO credential_presets
|
|
(name, api_username, api_password_encrypted, api_port,
|
|
ssh_username, ssh_password_encrypted, ssh_port, notes, allow_operator_use)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)
|
|
RETURNING *`,
|
|
[
|
|
name,
|
|
api_username,
|
|
encApi,
|
|
api_port ?? null,
|
|
ssh_username || null,
|
|
encSsh,
|
|
ssh_port ?? null,
|
|
notes || null,
|
|
allowOp,
|
|
]
|
|
);
|
|
return res.status(201).json(toPublic(rows[0]));
|
|
});
|
|
|
|
// PUT /api/credential-presets/:id — admin only
|
|
router.put('/:id', requireAdmin, presetMutationLimiter, async (req: Request, res: Response) => {
|
|
const id = parseInt(req.params.id, 10);
|
|
if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid id' });
|
|
|
|
const existing = await queryOne<CredentialPresetRow>(
|
|
`SELECT * FROM credential_presets WHERE id = $1`,
|
|
[id]
|
|
);
|
|
if (!existing) return res.status(404).json({ error: 'Preset not found' });
|
|
|
|
const {
|
|
name,
|
|
api_username,
|
|
api_password,
|
|
api_port,
|
|
ssh_username,
|
|
ssh_password,
|
|
ssh_port,
|
|
notes,
|
|
clear_ssh_password,
|
|
allow_operator_use,
|
|
} = req.body as {
|
|
name?: string;
|
|
api_username?: string;
|
|
api_password?: string;
|
|
api_port?: number | null;
|
|
ssh_username?: string | null;
|
|
ssh_password?: string | null;
|
|
ssh_port?: number | null;
|
|
notes?: string | null;
|
|
// Explicit flag to remove a previously-saved SSH password
|
|
clear_ssh_password?: boolean;
|
|
allow_operator_use?: boolean;
|
|
};
|
|
|
|
if (typeof name === 'string' && name && name !== existing.name) {
|
|
const clash = await queryOne<{ id: number }>(
|
|
`SELECT id FROM credential_presets WHERE name = $1 AND id <> $2`,
|
|
[name, id]
|
|
);
|
|
if (clash) return res.status(409).json({ error: 'A preset with this name already exists' });
|
|
}
|
|
const sshUsernameError = validateRouterOsUsername(ssh_username);
|
|
if (sshUsernameError) return res.status(400).json({ error: sshUsernameError });
|
|
|
|
const newApiPass = api_password ? encrypt(api_password) : existing.api_password_encrypted;
|
|
let newSshPass: string | null = existing.ssh_password_encrypted;
|
|
if (clear_ssh_password) newSshPass = null;
|
|
if (ssh_password) newSshPass = encrypt(ssh_password);
|
|
|
|
await query(
|
|
`UPDATE credential_presets SET
|
|
name = COALESCE($1, name),
|
|
api_username = COALESCE($2, api_username),
|
|
api_password_encrypted = $3,
|
|
api_port = $4,
|
|
ssh_username = $5,
|
|
ssh_password_encrypted = $6,
|
|
ssh_port = $7,
|
|
notes = $8,
|
|
allow_operator_use = COALESCE($9, allow_operator_use),
|
|
updated_at = NOW()
|
|
WHERE id = $10`,
|
|
[
|
|
name ?? null,
|
|
api_username ?? null,
|
|
newApiPass,
|
|
api_port === undefined ? existing.api_port : api_port,
|
|
ssh_username === undefined ? existing.ssh_username : (ssh_username || null),
|
|
newSshPass,
|
|
ssh_port === undefined ? existing.ssh_port : ssh_port,
|
|
notes === undefined ? existing.notes : (notes || null),
|
|
allow_operator_use === undefined ? null : allow_operator_use,
|
|
id,
|
|
]
|
|
);
|
|
|
|
const updated = await queryOne<CredentialPresetRow>(
|
|
`SELECT * FROM credential_presets WHERE id = $1`,
|
|
[id]
|
|
);
|
|
return res.json(toPublic(updated!));
|
|
});
|
|
|
|
// DELETE /api/credential-presets/:id — admin only
|
|
router.delete('/:id', requireAdmin, presetMutationLimiter, async (req: Request, res: Response) => {
|
|
const id = parseInt(req.params.id, 10);
|
|
if (!Number.isFinite(id)) return res.status(400).json({ error: 'Invalid id' });
|
|
const result = await query(
|
|
`DELETE FROM credential_presets WHERE id = $1 RETURNING id`,
|
|
[id]
|
|
);
|
|
if (!result.length) return res.status(404).json({ error: 'Preset not found' });
|
|
return res.json({ message: 'Preset deleted' });
|
|
});
|
|
|
|
export default router;
|