changemaker.lite/api/src/services/pangolin.client.ts
bunker-admin 08d8066157 Add ticketed events, Jitsi meeting integration, social features, and calendar system
- Ticketed events: full CRUD, ticket tiers (free/paid/donation), Stripe checkout,
  QR-based check-in scanner, public event pages, ticket confirmation emails
- Event formats: IN_PERSON/ONLINE/HYBRID with auto Jitsi meeting room lifecycle,
  ticket-gated meeting access, moderator JWT tokens, feature-flag guarded
- Social engagement: challenges with scoring/leaderboards, referral tracking,
  volunteer spotlight, impact stories, campaign celebrations, wall of fame
- Social calendar: personal calendar layers, shared calendar items with
  recurrence, scheduling polls, mobile day view
- MCP server: events tool pack with full admin CRUD + meeting token generation
- Unified calendar: eventFormat-aware tags, online event indicators
- Updated docs site, pangolin configs, and various admin UI improvements

Bunker Admin
2026-03-06 14:33:33 -07:00

442 lines
13 KiB
TypeScript

import { env } from '../config/env';
import { logger } from '../utils/logger';
// --- Types ---
export interface PangolinSite {
siteId: string;
name: string;
orgId: string;
niceId: string;
pubKey?: string;
subnet?: string;
megabytesIn?: number;
megabytesOut?: number;
lastSeen?: string;
online?: boolean;
type?: string;
address?: string;
}
export interface PangolinExitNode {
exitNodeId: string;
name: string;
location?: string;
region?: string;
online: boolean;
capacity?: number;
latency?: number;
}
export interface PangolinResource {
resourceId: string;
name: string;
subdomain?: string;
fullDomain?: string;
ssl?: boolean;
blockAccess?: boolean;
active?: boolean;
proxyPort?: number;
protocol?: string;
domainBindings?: string[];
http?: boolean;
// Target info (returned by list endpoints)
targets?: PangolinTarget[];
}
export interface PangolinTarget {
targetId: string;
resourceId: string;
siteId: string;
ip: string;
port: number;
method: string;
enabled?: boolean;
}
export interface PangolinNewt {
newtId: string;
secret: string;
siteId: string;
}
export interface PangolinSiteDefaults {
newtId: string;
newtSecret: string;
address: string;
}
export interface CreateSitePayload {
name: string;
type?: string;
subnet?: string;
exitNodeId?: string;
// Newt credentials from pickSiteDefaults
newtId?: string;
secret?: string;
address?: string;
}
// HTTP Resource (public proxy) - Correct Pangolin API schema
// Uses endpoint: PUT /org/{orgId}/resource (NOT /site-resource)
export interface CreateHttpResourcePayload {
name: string;
domainId: string;
subdomain: string; // Subdomain only (e.g., "app") or empty string for root
http: true; // REQUIRED: marks as HTTP proxy resource
protocol: 'tcp'; // REQUIRED for HTTP resources
// Note: ssl and enabled are NOT valid creation fields — set via updateResource()
}
export interface CreateTargetPayload {
siteId: string | number; // REQUIRED: which site routes this traffic (Pangolin expects numeric)
ip: string; // Target hostname/IP (e.g., "nginx")
port: number; // Target port (e.g., 80)
method: 'http' | 'https';
enabled?: boolean;
}
export interface PangolinDomain {
domainId: string;
baseDomain: string;
verified: boolean;
type?: string;
failed?: boolean;
configManaged?: boolean;
}
export interface UpdateResourcePayload {
name?: string;
subdomain?: string;
fullDomain?: string;
ssl?: boolean;
active?: boolean;
blockAccess?: boolean;
proxyPort?: number;
protocol?: string;
domainBindings?: string[];
}
export interface PangolinCertificate {
certId: string;
domainId: string;
domain: string;
status: 'PENDING' | 'ACTIVE' | 'EXPIRED' | 'FAILED';
issuedAt?: string;
expiresAt?: string;
autoRenew?: boolean;
issuer?: string;
}
export interface UpdateCertificatePayload {
autoRenew?: boolean;
}
export interface PangolinConnectedClient {
clientId: string;
resourceId: string;
ipAddress: string;
connectedAt: string;
lastSeen: string;
bytesIn: number;
bytesOut: number;
online: boolean;
}
// --- Client ---
class PangolinClient {
private get baseUrl(): string {
return env.PANGOLIN_API_URL;
}
private get apiKey(): string {
return env.PANGOLIN_API_KEY;
}
private get orgId(): string {
return env.PANGOLIN_ORG_ID;
}
get configured(): boolean {
return !!(this.baseUrl && this.apiKey && this.orgId);
}
private async request<T>(method: string, path: string, body?: unknown): Promise<T> {
if (!this.baseUrl || !this.apiKey) {
throw new Error('Pangolin API not configured. Set PANGOLIN_API_URL and PANGOLIN_API_KEY.');
}
const url = `${this.baseUrl}${path}`;
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 15000);
try {
logger.debug(`Pangolin ${method} ${path}${body ? ` body=${JSON.stringify(body)}` : ''}`);
const res = await fetch(url, {
method,
headers: {
'Authorization': `Bearer ${this.apiKey}`,
'Content-Type': 'application/json',
},
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
});
if (!res.ok) {
const text = await res.text().catch(() => '');
logger.error(`Pangolin API ${method} ${path} returned ${res.status}: ${text}`);
throw new Error(`Pangolin API ${method} ${path} returned ${res.status}: ${text}`);
}
const contentType = res.headers.get('content-type') || '';
if (contentType.includes('application/json')) {
const json = await res.json();
// Pangolin wraps responses in { data: {...}, success, status }
// Unwrap if present
return this.unwrapResponse<T>(json);
}
return {} as T;
} finally {
clearTimeout(timeout);
}
}
/**
* Unwrap Pangolin's response envelope: { data: {...}, success, status }
* Returns the inner data object, or the raw response if not wrapped.
*/
private unwrapResponse<T>(json: unknown): T {
if (json && typeof json === 'object' && !Array.isArray(json)) {
const obj = json as Record<string, unknown>;
// If it has a 'data' key and 'success' key, it's a wrapped response
if ('data' in obj && 'success' in obj) {
logger.debug('Unwrapped Pangolin response envelope');
return obj.data as T;
}
}
return json as T;
}
async healthCheck(): Promise<boolean> {
try {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
try {
const res = await fetch(`${this.baseUrl}/`, {
headers: { 'Authorization': `Bearer ${this.apiKey}` },
signal: controller.signal,
});
return res.ok;
} finally {
clearTimeout(timeout);
}
} catch (err) {
logger.warn('Pangolin health check failed:', err instanceof Error ? err.message : err);
return false;
}
}
// --- Site Defaults ---
/**
* Get pre-generated Newt credentials for a new site.
* Must be called BEFORE createSite() and passed into it.
* Endpoint: GET /org/{orgId}/pick-site-defaults
*/
async pickSiteDefaults(): Promise<PangolinSiteDefaults> {
const res = await this.request<unknown>('GET', `/org/${this.orgId}/pick-site-defaults`);
const obj = res as Record<string, unknown>;
// Response format: { newtId, newtSecret, clientAddress, subnet, ... }
// Note: `address` is the exit node address, `clientAddress` is the site address
const newtId = obj.newtId as string || '';
const newtSecret = obj.newtSecret as string || obj.secret as string || '';
const address = obj.clientAddress as string || obj.address as string || '';
if (!newtId || !newtSecret) {
logger.warn('pickSiteDefaults response missing newtId/newtSecret:', JSON.stringify(res));
throw new Error('Pangolin did not return Newt credentials from pick-site-defaults');
}
logger.info(`pickSiteDefaults: newtId=${newtId}, address=${address}`);
return { newtId, newtSecret, address };
}
// --- Sites ---
async listSites(): Promise<PangolinSite[]> {
const res = await this.request<unknown>('GET', `/org/${this.orgId}/sites`);
return this.extractArray(res, 'sites', 'listSites');
}
async listExitNodes(): Promise<PangolinExitNode[]> {
try {
const res = await this.request<unknown>('GET', `/org/${this.orgId}/exit-nodes`);
return this.extractArray(res, 'exitNodes', 'listExitNodes');
} catch (err) {
if (err instanceof Error && err.message.includes('404')) {
logger.info('Pangolin exit-nodes endpoint not available (self-hosted mode)');
return [];
}
logger.warn('Failed to fetch exit nodes:', err);
return [];
}
}
async getSite(siteId: string): Promise<PangolinSite> {
return this.request<PangolinSite>('GET', `/site/${siteId}`);
}
async createSite(data: CreateSitePayload): Promise<PangolinSite & { newt?: PangolinNewt }> {
return this.request<PangolinSite & { newt?: PangolinNewt }>(
'PUT',
`/org/${this.orgId}/site`,
data,
);
}
async deleteSite(siteId: string): Promise<void> {
await this.request<void>('DELETE', `/site/${siteId}`);
}
// --- HTTP Resources (public proxy) ---
/**
* List HTTP proxy resources (public resources).
* Endpoint: GET /org/{orgId}/resources (NOT /site-resources)
*/
async listResources(): Promise<PangolinResource[]> {
const res = await this.request<unknown>('GET', `/org/${this.orgId}/resources`);
return this.extractArray(res, 'resources', 'listResources');
}
async getResource(resourceId: string): Promise<PangolinResource> {
return this.request<PangolinResource>('GET', `/resource/${resourceId}`);
}
/**
* Create an HTTP proxy resource (public resource).
* Endpoint: PUT /org/{orgId}/resource (NOT /site-resource)
*/
async createResource(data: CreateHttpResourcePayload): Promise<PangolinResource> {
logger.info(`createResource: ${data.name} (subdomain: ${data.subdomain || '(root)'})`);
return this.request<PangolinResource>(
'PUT',
`/org/${this.orgId}/resource`,
data,
);
}
async updateResource(resourceId: string, data: UpdateResourcePayload): Promise<PangolinResource> {
return this.request<PangolinResource>('POST', `/resource/${resourceId}`, data);
}
async deleteResource(resourceId: string): Promise<void> {
await this.request<void>('DELETE', `/resource/${resourceId}`);
}
// --- Targets ---
/**
* Create a target for a resource (routes traffic to a backend).
* Endpoint: PUT /resource/{resourceId}/target (PUT, not POST)
*/
async createTarget(resourceId: string, data: CreateTargetPayload): Promise<PangolinTarget> {
logger.info(`createTarget: resource=${resourceId}, ip=${data.ip}:${data.port}, method=${data.method}`);
// Pangolin expects siteId as a number, not a string
const payload = { ...data, siteId: Number(data.siteId) };
return this.request<PangolinTarget>(
'PUT',
`/resource/${resourceId}/target`,
payload,
);
}
/**
* List targets for a resource.
* Endpoint: GET /resource/{resourceId}/targets (plural — NOT /target)
*/
async listTargets(resourceId: string): Promise<PangolinTarget[]> {
const res = await this.request<unknown>('GET', `/resource/${resourceId}/targets`);
return this.extractArray(res, 'targets', 'listTargets');
}
/**
* Delete a target by ID.
* Endpoint: DELETE /target/{targetId} (NOT nested under /resource/)
*/
async deleteTarget(targetId: string): Promise<void> {
await this.request<void>('DELETE', `/target/${targetId}`);
}
// --- Domains ---
async listDomains(): Promise<PangolinDomain[]> {
const res = await this.request<unknown>('GET', `/org/${this.orgId}/domains`);
return this.extractArray(res, 'domains', 'listDomains');
}
// --- Certificates ---
async getCertificate(domainId: string, domain: string): Promise<PangolinCertificate> {
return this.request<PangolinCertificate>(
'GET',
`/org/${this.orgId}/certificate/${domainId}/${domain}`,
);
}
async updateCertificate(certId: string, data: UpdateCertificatePayload): Promise<PangolinCertificate> {
return this.request<PangolinCertificate>('POST', `/certificate/${certId}`, data);
}
// --- Clients ---
async listClients(resourceId: string): Promise<PangolinConnectedClient[]> {
const res = await this.request<unknown>('GET', `/resource/${resourceId}/clients`);
return this.extractArray(res, 'clients', 'listClients');
}
// --- Helpers ---
/**
* Extract an array from a Pangolin response that may be:
* - A direct array
* - An object with the array under a named key (e.g., { sites: [...] })
* - An object with { data: [...] }
*/
private extractArray<T>(res: unknown, key: string, context: string): T[] {
if (Array.isArray(res)) {
return res as T[];
}
if (res && typeof res === 'object') {
const obj = res as Record<string, unknown>;
// Check named key (e.g., "sites", "resources", "domains")
if (Array.isArray(obj[key])) {
return obj[key] as T[];
}
// Check nested data.{key}
if (obj.data && typeof obj.data === 'object') {
const dataObj = obj.data as Record<string, unknown>;
if (Array.isArray(dataObj[key])) {
return dataObj[key] as T[];
}
}
// Fallback: { data: [...] }
if (Array.isArray(obj.data)) {
return obj.data as T[];
}
}
logger.warn(`${context}: could not extract array from response, returning empty`);
return [];
}
}
export const pangolinClient = new PangolinClient();