wulf-pulse/scripts/passportal-import-wasabi.ts

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(/&#0*39;/g, "'")
.replace(/&amp;/g, '&')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&quot;/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);
});