- 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
442 lines
13 KiB
TypeScript
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();
|