549 lines
19 KiB
TypeScript
549 lines
19 KiB
TypeScript
/**
|
|
* Import Wasabi IAM credentials into Passportal
|
|
* Creates one credential per bucket entry under each client's existing Veeam subfolder
|
|
*
|
|
* Usage:
|
|
* npx tsx scripts/passportal-import-wasabi.ts --discover # List templates, clients, folders
|
|
* npx tsx scripts/passportal-import-wasabi.ts --dry-run # Preview what will be created
|
|
* npx tsx scripts/passportal-import-wasabi.ts # Run the import
|
|
*/
|
|
|
|
import { createHmac } from 'crypto';
|
|
import { readFileSync } from 'fs';
|
|
import * as path from 'path';
|
|
import * as dotenv from 'dotenv';
|
|
|
|
dotenv.config({ path: path.resolve(process.cwd(), '.env') });
|
|
|
|
// ── Config ────────────────────────────────────────────────────────────────────
|
|
|
|
const BASE_URL = 'https://us-clover.passportalmsp.com';
|
|
const HMAC_CONTENT = 'aUa&&XUQBJXz2x&';
|
|
const VEEAM_FOLDER_NAME = 'Veeam'; // case-insensitive match
|
|
const CREDS_FILE = path.resolve(process.cwd(), 'dev/WasabiIAMCredentials_20260220_172028.txt');
|
|
|
|
// These Wulf-internal buckets have no matching client — skip them
|
|
const SKIP_CLIENT_SLUGS = new Set(['internal', 'vbr', 'veeam', 'clients']);
|
|
|
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
|
|
|
interface WasabiEntry {
|
|
bucket: string;
|
|
username: string;
|
|
accessKey: string;
|
|
secretKey: string;
|
|
endpoint: string;
|
|
clientSlug: string;
|
|
}
|
|
|
|
interface PassportalToken {
|
|
access_token: string;
|
|
refresh_token: string;
|
|
expiry_time: number;
|
|
}
|
|
|
|
interface PassportalClient {
|
|
id: number | string;
|
|
name: string;
|
|
}
|
|
|
|
interface PassportalFolder {
|
|
id: number | string;
|
|
name: string;
|
|
clientId?: number | string;
|
|
}
|
|
|
|
interface PassportalTemplate {
|
|
id: number | string;
|
|
name: string;
|
|
fields?: Array<{ name: string; type: string }>;
|
|
}
|
|
|
|
// ── Parse credentials file ────────────────────────────────────────────────────
|
|
|
|
function parseCredentialsFile(filePath: string): WasabiEntry[] {
|
|
const content = readFileSync(filePath, 'utf-8');
|
|
const entries: WasabiEntry[] = [];
|
|
|
|
// Parse line-by-line to handle any line endings / blank-line variations
|
|
const current: Record<string, string> = {};
|
|
|
|
const flush = () => {
|
|
const bucket = current['bucket'] ?? '';
|
|
const username = current['username'] ?? '';
|
|
const accessKey = current['access_key'] ?? '';
|
|
const secretKey = current['secret_key'] ?? '';
|
|
const endpoint = current['endpoint'] || 'https://s3.wasabisys.com';
|
|
|
|
if (bucket && accessKey && secretKey) {
|
|
// wulf.<client>.veeam[365].immutable[1] → <client>
|
|
const match = bucket.match(/^wulf\.(.+?)\.veeam/);
|
|
const clientSlug = match ? match[1] : bucket;
|
|
entries.push({ bucket, username, accessKey, secretKey, endpoint, clientSlug });
|
|
}
|
|
|
|
for (const k of Object.keys(current)) delete current[k];
|
|
};
|
|
|
|
for (const rawLine of content.split('\n')) {
|
|
const line = rawLine.replace(/\r$/, ''); // strip CR for CRLF files
|
|
|
|
if (line.trim() === '') {
|
|
flush();
|
|
continue;
|
|
}
|
|
|
|
const colonIdx = line.indexOf(':');
|
|
if (colonIdx < 0) continue; // header separator lines (===)
|
|
|
|
const rawKey = line.slice(0, colonIdx).trim().toLowerCase().replace(/\s+/g, '_');
|
|
const value = line.slice(colonIdx + 1).trim();
|
|
|
|
// Skip header lines that don't look like credential fields
|
|
if (!['bucket', 'username', 'access_key', 'secret_key', 'endpoint'].includes(rawKey)) continue;
|
|
|
|
current[rawKey] = value;
|
|
}
|
|
|
|
flush(); // handle last block if file doesn't end with blank line
|
|
|
|
return entries;
|
|
}
|
|
|
|
// ── Auth ──────────────────────────────────────────────────────────────────────
|
|
|
|
function computeHmac(secretKey: string): string {
|
|
const hmac = createHmac('sha256', secretKey);
|
|
hmac.update(HMAC_CONTENT);
|
|
return hmac.digest('hex');
|
|
}
|
|
|
|
async function authenticate(scope = 'docs_api'): Promise<string> {
|
|
const keyId = process.env.ACCESS_KEY_ID;
|
|
const secret = process.env.SECRET_ACCESS_KEY;
|
|
|
|
if (!keyId || !secret) {
|
|
throw new Error('ACCESS_KEY_ID and SECRET_ACCESS_KEY must be set in .env');
|
|
}
|
|
|
|
const hash = computeHmac(secret);
|
|
|
|
const res = await fetch(`${BASE_URL}/api/v2/auth/client_token`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'x-key': keyId,
|
|
'x-hash': hash,
|
|
'content-type': 'application/json',
|
|
},
|
|
body: JSON.stringify({ scope, content: HMAC_CONTENT }),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(`Auth failed ${res.status}: ${text}`);
|
|
}
|
|
|
|
const data: PassportalToken = await res.json();
|
|
return data.access_token;
|
|
}
|
|
|
|
// ── API helpers ───────────────────────────────────────────────────────────────
|
|
|
|
async function apiGet<T>(token: string, endpoint: string): Promise<T> {
|
|
const res = await fetch(`${BASE_URL}/api/v2${endpoint}`, {
|
|
headers: {
|
|
'x-access-token': token,
|
|
'content-type': 'application/json',
|
|
},
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(`GET ${endpoint} failed ${res.status}: ${text}`);
|
|
}
|
|
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
async function apiPost<T>(token: string, endpoint: string, body: unknown): Promise<T> {
|
|
const res = await fetch(`${BASE_URL}/api/v2${endpoint}`, {
|
|
method: 'POST',
|
|
headers: {
|
|
'x-access-token': token,
|
|
'content-type': 'application/json',
|
|
},
|
|
body: JSON.stringify(body),
|
|
});
|
|
|
|
if (!res.ok) {
|
|
const text = await res.text();
|
|
throw new Error(`POST ${endpoint} failed ${res.status}: ${text}`);
|
|
}
|
|
|
|
return res.json() as Promise<T>;
|
|
}
|
|
|
|
// ── Client / folder matching ──────────────────────────────────────────────────
|
|
|
|
function decodeHtmlEntities(str: string): string {
|
|
return str
|
|
.replace(/�*39;/g, "'")
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/&#(\d+);/g, (_, n) => String.fromCharCode(Number(n)));
|
|
}
|
|
|
|
function slugify(name: string): string {
|
|
return decodeHtmlEntities(name).toLowerCase().replace(/[^a-z0-9]/g, '');
|
|
}
|
|
|
|
function findClient(slug: string, clients: PassportalClient[]): PassportalClient | undefined {
|
|
const target = slugify(slug);
|
|
// Exact slug match first, then prefix match
|
|
return (
|
|
clients.find(c => slugify(c.name) === target) ||
|
|
clients.find(c => slugify(c.name).includes(target)) ||
|
|
clients.find(c => target.includes(slugify(c.name)))
|
|
);
|
|
}
|
|
|
|
function findVeeamFolder(folders: PassportalFolder[]): PassportalFolder | undefined {
|
|
return folders.find(f => f.name.toLowerCase().includes(VEEAM_FOLDER_NAME.toLowerCase()));
|
|
}
|
|
|
|
// ── Discover mode ─────────────────────────────────────────────────────────────
|
|
|
|
async function tryGet(token: string, label: string, endpoints: string[]): Promise<{ endpoint: string; data: unknown } | null> {
|
|
for (const ep of endpoints) {
|
|
try {
|
|
const data = await apiGet<unknown>(token, ep);
|
|
console.log(` ✓ ${label} → ${ep}`);
|
|
return { endpoint: ep, data };
|
|
} catch (e) {
|
|
console.log(` ✗ ${ep}: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
interface PassportalDocument {
|
|
id: number;
|
|
client_id: number;
|
|
clientName: string;
|
|
templateId: number;
|
|
templateName: string;
|
|
type: string;
|
|
label: string;
|
|
folder_id?: number;
|
|
folderName?: string;
|
|
[key: string]: unknown;
|
|
}
|
|
|
|
async function fetchAllDocuments(token: string): Promise<PassportalDocument[]> {
|
|
const all: PassportalDocument[] = [];
|
|
let page = 1;
|
|
|
|
while (true) {
|
|
const res = await apiGet<{ results?: PassportalDocument[]; success?: boolean } | PassportalDocument[]>(
|
|
token,
|
|
`/documents?page=${page}&limit=100`
|
|
);
|
|
const batch = Array.isArray(res) ? res : (res as { results?: PassportalDocument[] }).results ?? [];
|
|
all.push(...batch);
|
|
if (batch.length < 100) break; // last page
|
|
page++;
|
|
}
|
|
|
|
return all;
|
|
}
|
|
|
|
async function fetchFolders(token: string, clientId: number | string): Promise<PassportalFolder[]> {
|
|
// The /folders endpoint requires the raw HMAC auth (x-key + x-hash), not the JWT
|
|
const keyId = process.env.ACCESS_KEY_ID!;
|
|
const secret = process.env.SECRET_ACCESS_KEY!;
|
|
const hash = computeHmac(secret);
|
|
|
|
for (const endpoint of [
|
|
`/folders?clientId=${clientId}`,
|
|
`/folders?client_id=${clientId}`,
|
|
]) {
|
|
try {
|
|
const res = await fetch(`${BASE_URL}/api/v2${endpoint}`, {
|
|
headers: {
|
|
'x-key': keyId,
|
|
'x-hash': hash,
|
|
'x-access-token': token,
|
|
'content-type': 'application/json',
|
|
},
|
|
});
|
|
if (!res.ok) continue;
|
|
const data = await res.json() as { results?: PassportalFolder[] } | PassportalFolder[];
|
|
return Array.isArray(data) ? data : (data as { results?: PassportalFolder[] }).results ?? [];
|
|
} catch { /* try next */ }
|
|
}
|
|
return [];
|
|
}
|
|
|
|
async function runDiscover(token: string, entries: WasabiEntry[]) {
|
|
console.log('\n=== FETCHING ALL DOCUMENTS ===');
|
|
const docs = await fetchAllDocuments(token);
|
|
console.log(` Total documents: ${docs.length}`);
|
|
|
|
// Unique templates
|
|
const templates = new Map<number, { name: string; type: string }>();
|
|
for (const d of docs) {
|
|
if (!templates.has(d.templateId)) {
|
|
templates.set(d.templateId, { name: d.templateName, type: d.type });
|
|
}
|
|
}
|
|
console.log(`\n=== TEMPLATES (${templates.size} unique) ===`);
|
|
for (const [id, t] of [...templates.entries()].sort((a, b) => a[0] - b[0])) {
|
|
console.log(` templateId=${id} type=${t.type.padEnd(20)} name="${t.name}"`);
|
|
}
|
|
|
|
// Look for folder fields in any document
|
|
const docWithFolder = docs.find(d => d.folder_id || d.folderName);
|
|
if (docWithFolder) {
|
|
console.log('\n=== SAMPLE DOC WITH FOLDER FIELDS ===');
|
|
console.log(JSON.stringify(docWithFolder, null, 2));
|
|
} else {
|
|
console.log('\n No folder_id/folderName found in document list response');
|
|
}
|
|
|
|
// Try fetching a single document's full detail to see if it has more fields
|
|
if (docs.length > 0) {
|
|
console.log(`\n=== SINGLE DOCUMENT DETAIL (id=${docs[0].id}) ===`);
|
|
try {
|
|
const detail = await apiGet<unknown>(token, `/documents/${docs[0].id}`);
|
|
console.log(JSON.stringify(detail, null, 2));
|
|
} catch (e) {
|
|
console.log(' Could not fetch detail:', (e as Error).message);
|
|
}
|
|
}
|
|
|
|
// Unique clients from documents
|
|
const clientMap = new Map<number, string>();
|
|
for (const d of docs) {
|
|
if (!clientMap.has(d.client_id)) clientMap.set(d.client_id, d.clientName);
|
|
}
|
|
console.log(`\n=== CLIENTS IN DOCUMENTS (${clientMap.size}) ===`);
|
|
for (const [id, name] of [...clientMap.entries()].sort((a, b) => decodeHtmlEntities(a[1]).localeCompare(decodeHtmlEntities(b[1])))) {
|
|
console.log(` client_id=${id} name="${decodeHtmlEntities(name)}"`);
|
|
}
|
|
|
|
// Try folders endpoint with both JWT and raw auth
|
|
console.log('\n=== FOLDER ENDPOINT PROBE ===');
|
|
if (clientMap.size > 0) {
|
|
const firstClientId = [...clientMap.keys()][0];
|
|
const folders = await fetchFolders(token, firstClientId);
|
|
if (folders.length > 0) {
|
|
console.log(` ✓ Got ${folders.length} folder(s) for client ${firstClientId}:`);
|
|
console.log(JSON.stringify(folders.slice(0, 5), null, 2));
|
|
} else {
|
|
console.log(` ✗ No folders returned for client ${firstClientId}`);
|
|
// Try raw endpoint probe
|
|
const keyId = process.env.ACCESS_KEY_ID!;
|
|
const hash = computeHmac(process.env.SECRET_ACCESS_KEY!);
|
|
for (const ep of ['/folders', `/folders?clientId=${firstClientId}`, '/passwords', '/credentials']) {
|
|
try {
|
|
const res = await fetch(`${BASE_URL}/api/v2${ep}`, {
|
|
headers: { 'x-key': keyId, 'x-hash': hash, 'content-type': 'application/json' },
|
|
});
|
|
const text = await res.text();
|
|
console.log(` raw-auth ${ep} → ${res.status}: ${text.slice(0, 200)}`);
|
|
} catch (e) {
|
|
console.log(` raw-auth ${ep} → error: ${(e as Error).message}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Show client slug matching preview
|
|
const slugs = [...new Set(entries.map(e => e.clientSlug))].filter(s => !SKIP_CLIENT_SLUGS.has(s));
|
|
console.log(`\n=== CLIENT SLUG → CLIENT MATCH PREVIEW ===`);
|
|
const clients: PassportalClient[] = [...clientMap.entries()].map(([id, name]) => ({ id, name }));
|
|
for (const slug of slugs) {
|
|
const match = findClient(slug, clients);
|
|
if (match) {
|
|
console.log(` ✓ "${slug}" → "${decodeHtmlEntities(match.name)}" (id=${match.id})`);
|
|
} else {
|
|
console.log(` ✗ "${slug}" → NO MATCH`);
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Main import ───────────────────────────────────────────────────────────────
|
|
|
|
async function runImport(token: string, entries: WasabiEntry[], dryRun: boolean) {
|
|
// 1. Build client list from documents endpoint (client API returns 500)
|
|
console.log('Fetching documents to build client list...');
|
|
const allDocs = await fetchAllDocuments(token);
|
|
console.log(` Found ${allDocs.length} documents`);
|
|
|
|
const clientMap = new Map<number, string>();
|
|
for (const d of allDocs) {
|
|
if (!clientMap.has(d.client_id)) clientMap.set(d.client_id, d.clientName);
|
|
}
|
|
const clients: PassportalClient[] = [...clientMap.entries()].map(([id, name]) => ({ id, name }));
|
|
console.log(` Found ${clients.length} unique clients`);
|
|
|
|
// 2. Fetch templates and find best fit
|
|
console.log('Fetching templates...');
|
|
let templateUid: string | number | undefined;
|
|
try {
|
|
const res = await apiGet<unknown>(token, '/templates');
|
|
const templates: PassportalTemplate[] = Array.isArray(res) ? res : ((res as { data?: PassportalTemplate[] }).data ?? []);
|
|
// Prefer a template named something like "Username & Password" or "AWS" or "S3"
|
|
const preferred = templates.find(t =>
|
|
/username|password|credential|aws|s3|wasabi/i.test(t.name)
|
|
) ?? templates[0];
|
|
if (preferred) {
|
|
templateUid = preferred.id;
|
|
console.log(` Using template: "${preferred.name}" (${preferred.id})`);
|
|
}
|
|
} catch (e) {
|
|
console.warn(' Could not fetch templates — templateUid will be omitted');
|
|
}
|
|
|
|
// 3. Group entries by client slug, skip internal buckets
|
|
const bySlug = new Map<string, WasabiEntry[]>();
|
|
for (const entry of entries) {
|
|
if (SKIP_CLIENT_SLUGS.has(entry.clientSlug)) continue;
|
|
const list = bySlug.get(entry.clientSlug) ?? [];
|
|
list.push(entry);
|
|
bySlug.set(entry.clientSlug, list);
|
|
}
|
|
|
|
const skipped = entries.filter(e => SKIP_CLIENT_SLUGS.has(e.clientSlug));
|
|
if (skipped.length > 0) {
|
|
console.log(`\nSkipping ${skipped.length} internal bucket(s): ${skipped.map(e => e.bucket).join(', ')}`);
|
|
}
|
|
|
|
// 4. Process each client slug
|
|
let created = 0;
|
|
let failed = 0;
|
|
const unmatched: string[] = [];
|
|
|
|
for (const [slug, slugEntries] of bySlug) {
|
|
const client = findClient(slug, clients);
|
|
|
|
if (!client) {
|
|
console.warn(` ✗ No Passportal client found for slug "${slug}" — skipping ${slugEntries.length} entry/entries`);
|
|
unmatched.push(slug);
|
|
continue;
|
|
}
|
|
|
|
// Fetch folders for this client
|
|
let folders: PassportalFolder[] = [];
|
|
for (const endpoint of [
|
|
`/folders?clientId=${client.id}`,
|
|
`/clients/${client.id}/folders`,
|
|
]) {
|
|
try {
|
|
const res = await apiGet<unknown>(token, endpoint);
|
|
folders = Array.isArray(res) ? res : ((res as { data?: PassportalFolder[] }).data ?? []);
|
|
if (folders.length > 0) break;
|
|
} catch { /* try next */ }
|
|
}
|
|
|
|
const veeamFolder = findVeeamFolder(folders);
|
|
if (!veeamFolder) {
|
|
console.warn(` ✗ No "${VEEAM_FOLDER_NAME}" folder found for client "${client.name}" — skipping`);
|
|
failed += slugEntries.length;
|
|
continue;
|
|
}
|
|
|
|
// Create one credential per entry
|
|
for (const entry of slugEntries) {
|
|
const title = `Wasabi S3 - ${entry.bucket}`;
|
|
const doc = {
|
|
...(templateUid !== undefined ? { templateUid } : {}),
|
|
clientId: client.id,
|
|
folderId: veeamFolder.id,
|
|
title,
|
|
// Common username/password fields (field names vary by template)
|
|
username: entry.accessKey,
|
|
password: entry.secretKey,
|
|
// Extra context fields
|
|
notes: [
|
|
`Bucket: ${entry.bucket}`,
|
|
`IAM User: ${entry.username}`,
|
|
`Access Key: ${entry.accessKey}`,
|
|
`Endpoint: ${entry.endpoint}`,
|
|
].join('\n'),
|
|
// Template-specific aliases
|
|
access_key: entry.accessKey,
|
|
secret_key: entry.secretKey,
|
|
url: entry.endpoint,
|
|
application_name: `Wasabi S3 - ${entry.bucket}`,
|
|
};
|
|
|
|
if (dryRun) {
|
|
console.log(` [DRY RUN] Would create "${title}" under ${client.name} / ${veeamFolder.name}`);
|
|
} else {
|
|
try {
|
|
await apiPost(token, '/documents', [doc]);
|
|
console.log(` ✓ Created "${title}" under ${client.name} / ${veeamFolder.name}`);
|
|
created++;
|
|
} catch (e) {
|
|
console.error(` ✗ Failed to create "${title}": ${(e as Error).message}`);
|
|
failed++;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 5. Summary
|
|
console.log('\n=== Summary ===');
|
|
if (dryRun) {
|
|
const total = [...bySlug.values()].reduce((n, v) => n + v.length, 0);
|
|
console.log(`Would create: ${total} credentials`);
|
|
} else {
|
|
console.log(`Created: ${created}`);
|
|
console.log(`Failed: ${failed}`);
|
|
}
|
|
if (unmatched.length > 0) {
|
|
console.log(`\nUnmatched client slugs (${unmatched.length}) — no Passportal client found:`);
|
|
unmatched.forEach(s => console.log(` - ${s}`));
|
|
console.log('\nTip: run --discover to see the full client list and adjust matching manually.');
|
|
}
|
|
}
|
|
|
|
// ── Entry point ───────────────────────────────────────────────────────────────
|
|
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
const isDiscover = args.includes('--discover');
|
|
const isDryRun = args.includes('--dry-run');
|
|
|
|
console.log('=== Passportal Wasabi IAM Import ===');
|
|
if (isDiscover) console.log('Mode: DISCOVER');
|
|
else if (isDryRun) console.log('Mode: DRY RUN (no changes)');
|
|
else console.log('Mode: LIVE IMPORT');
|
|
|
|
const entries = parseCredentialsFile(CREDS_FILE);
|
|
console.log(`\nParsed ${entries.length} credential entries from file`);
|
|
|
|
const slugCounts = new Map<string, number>();
|
|
for (const e of entries) {
|
|
slugCounts.set(e.clientSlug, (slugCounts.get(e.clientSlug) ?? 0) + 1);
|
|
}
|
|
console.log(`Unique client slugs: ${[...slugCounts.keys()].join(', ')}`);
|
|
|
|
console.log('\nAuthenticating...');
|
|
const token = await authenticate();
|
|
console.log('✓ Authenticated');
|
|
|
|
if (isDiscover) {
|
|
await runDiscover(token, entries);
|
|
} else {
|
|
await runImport(token, entries, isDryRun);
|
|
}
|
|
}
|
|
|
|
main().catch(err => {
|
|
console.error('\nFatal:', err.message);
|
|
process.exit(1);
|
|
});
|