diff --git a/api/Dockerfile b/api/Dockerfile index fec65f1..40720c8 100644 --- a/api/Dockerfile +++ b/api/Dockerfile @@ -2,17 +2,28 @@ FROM oven/bun:1-alpine WORKDIR /app +# Install deps as root COPY package.json bun.lock* ./ RUN bun install --frozen-lockfile 2>/dev/null || bun install COPY tsconfig.json ./ COPY src/ ./src/ -RUN mkdir -p /data +# Create data dir and non-root user +RUN mkdir -p /data && \ + addgroup -g 1001 rsocials && \ + adduser -u 1001 -G rsocials -s /bin/sh -D rsocials && \ + chown -R rsocials:rsocials /app /data + +USER rsocials ENV PORT=3001 ENV DATABASE_PATH=/data/instances.db +ENV NODE_ENV=production EXPOSE 3001 +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD wget -qO- http://localhost:3001/health || exit 1 + CMD ["bun", "run", "src/index.ts"] diff --git a/api/src/index.ts b/api/src/index.ts index 6edd931..b172ff4 100644 --- a/api/src/index.ts +++ b/api/src/index.ts @@ -5,10 +5,14 @@ import { initDb } from "./db/schema.js"; import { InstanceStore } from "./db/queries.js"; import { siweAuth } from "./middleware/siwe-auth.js"; import { tokenGate } from "./middleware/token-gate.js"; +import { rateLimiter, provisionRateLimiter } from "./middleware/rate-limit.js"; +import { setupX402FromEnv } from "./middleware/x402.js"; import { healthRoutes } from "./routes/health.js"; import { authRoutes } from "./routes/auth.js"; import { spacesRoutes } from "./routes/spaces.js"; import { provisionRoutes } from "./routes/provision.js"; +import { usageRoutes } from "./routes/usage.js"; +import { adminRoutes } from "./routes/admin.js"; const PORT = parseInt(process.env.PORT ?? "3001", 10); const DB_PATH = process.env.DATABASE_PATH ?? "./data/instances.db"; @@ -31,8 +35,30 @@ app.route("/v1/auth", authRoutes()); // Protected routes (SIWE wallet or API key) app.use("/v1/spaces/*", siweAuth); app.use("/v1/spaces/*", tokenGate); +app.use("/v1/spaces/*", rateLimiter); + +// x402 payment gate on provisioning (only if X402_PAY_TO is set) +const x402Middleware = setupX402FromEnv(); +if (x402Middleware) { + // Apply x402 only to POST (provisioning) and pay endpoint + app.use("/v1/spaces", async (c, next) => { + if (c.req.method === "POST") return x402Middleware(c, next); + return next(); + }); +} + +// Provision rate limiter (stricter — 2/hour) +app.post("/v1/spaces", provisionRateLimiter); + +// Space routes app.route("/v1/spaces", provisionRoutes(store)); app.route("/v1/spaces", spacesRoutes(store)); +app.route("/v1/spaces", usageRoutes(store)); + +// Admin routes (API key only) +app.use("/v1/admin/*", siweAuth); // reuse auth (checks API key first) +app.use("/v1/admin/*", rateLimiter); +app.route("/v1/admin", adminRoutes(store)); // 404 handler app.notFound((c) => c.json({ error: "Not found" }, 404)); diff --git a/api/src/middleware/rate-limit.ts b/api/src/middleware/rate-limit.ts new file mode 100644 index 0000000..5e76221 --- /dev/null +++ b/api/src/middleware/rate-limit.ts @@ -0,0 +1,138 @@ +/** + * In-memory rate limiter per wallet/IP. + * + * Configurable: + * RATE_LIMIT_WINDOW_MS=60000 (1 minute default) + * RATE_LIMIT_MAX_REQUESTS=10 (10 requests per window) + * RATE_LIMIT_PROVISION_MAX=2 (2 provisions per hour) + */ + +import type { Context, Next } from "hono"; + +interface RateEntry { + count: number; + resetAt: number; +} + +const WINDOW_MS = parseInt(process.env.RATE_LIMIT_WINDOW_MS ?? "60000", 10); +const MAX_REQUESTS = parseInt( + process.env.RATE_LIMIT_MAX_REQUESTS ?? "10", + 10 +); +const PROVISION_MAX = parseInt( + process.env.RATE_LIMIT_PROVISION_MAX ?? "2", + 10 +); +const PROVISION_WINDOW_MS = 60 * 60 * 1000; // 1 hour + +const generalLimits = new Map(); +const provisionLimits = new Map(); + +function cleanExpired(store: Map) { + const now = Date.now(); + for (const [key, entry] of store) { + if (now > entry.resetAt) store.delete(key); + } +} + +function getIdentifier(c: Context): string { + const owner = c.get("owner" as never) as string | undefined; + if (owner && owner !== "admin") return owner; + return ( + c.req.header("x-forwarded-for")?.split(",")[0]?.trim() ?? + c.req.header("x-real-ip") ?? + "unknown" + ); +} + +function checkLimit( + store: Map, + key: string, + max: number, + windowMs: number +): { allowed: boolean; remaining: number; resetAt: number } { + const now = Date.now(); + const entry = store.get(key); + + if (!entry || now > entry.resetAt) { + const resetAt = now + windowMs; + store.set(key, { count: 1, resetAt }); + return { allowed: true, remaining: max - 1, resetAt }; + } + + entry.count++; + if (entry.count > max) { + return { allowed: false, remaining: 0, resetAt: entry.resetAt }; + } + + return { + allowed: true, + remaining: max - entry.count, + resetAt: entry.resetAt, + }; +} + +/** + * General rate limiter for all API routes. + */ +export async function rateLimiter(c: Context, next: Next) { + const id = getIdentifier(c); + + // Admin API key bypasses rate limiting + const authMethod = c.get("authMethod" as never) as string | undefined; + if (authMethod === "api-key") return next(); + + // Clean expired entries periodically + if (Math.random() < 0.1) cleanExpired(generalLimits); + + const result = checkLimit(generalLimits, id, MAX_REQUESTS, WINDOW_MS); + + c.header("X-RateLimit-Limit", String(MAX_REQUESTS)); + c.header("X-RateLimit-Remaining", String(result.remaining)); + c.header("X-RateLimit-Reset", String(Math.ceil(result.resetAt / 1000))); + + if (!result.allowed) { + return c.json( + { + error: "Too many requests", + retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000), + }, + 429 + ); + } + + return next(); +} + +/** + * Stricter rate limiter specifically for provisioning. + * 2 provisions per hour per wallet. + */ +export async function provisionRateLimiter(c: Context, next: Next) { + const id = getIdentifier(c); + + const authMethod = c.get("authMethod" as never) as string | undefined; + if (authMethod === "api-key") return next(); + + if (Math.random() < 0.1) cleanExpired(provisionLimits); + + const result = checkLimit( + provisionLimits, + id, + PROVISION_MAX, + PROVISION_WINDOW_MS + ); + + if (!result.allowed) { + return c.json( + { + error: "Provisioning rate limit exceeded", + detail: `Max ${PROVISION_MAX} provisions per hour`, + retryAfter: Math.ceil((result.resetAt - Date.now()) / 1000), + }, + 429 + ); + } + + return next(); +} diff --git a/api/src/routes/admin.ts b/api/src/routes/admin.ts new file mode 100644 index 0000000..41d964b --- /dev/null +++ b/api/src/routes/admin.ts @@ -0,0 +1,141 @@ +/** + * Admin-only routes for managing the provisioning platform. + * + * All routes require X-API-Key authentication (admin only). + * + * GET /v1/admin/instances — List all instances (any owner) + * DELETE /v1/admin/instances/:slug — Force teardown (bypass owner check) + * POST /v1/admin/instances/:slug/suspend — Suspend an instance + * POST /v1/admin/instances/:slug/resume — Resume a suspended instance + */ + +import { Hono } from "hono"; +import type { InstanceStore } from "../db/queries.js"; +import { + teardownSpace, + checkContainerHealth, +} from "../services/docker-deployer.js"; +import { + removeTunnelHostnames, + restartCloudflared, +} from "../services/tunnel-config.js"; + +export function adminRoutes(store: InstanceStore) { + const app = new Hono(); + + // Admin guard — only API key auth allowed + app.use("*", async (c, next) => { + const authMethod = c.get("authMethod" as never) as string; + if (authMethod !== "api-key") { + return c.json({ error: "Admin access required" }, 403); + } + return next(); + }); + + // List all instances (all owners) + app.get("/instances", (c) => { + const instances = store.list(); + const summary = { + total: instances.length, + active: instances.filter((i) => i.status === "active").length, + provisioning: instances.filter((i) => i.status === "provisioning").length, + failed: instances.filter((i) => i.status === "failed").length, + }; + return c.json({ summary, instances }); + }); + + // Force teardown (bypass owner check) + app.delete("/instances/:slug", async (c) => { + const slug = c.req.param("slug"); + const instance = store.getBySlug(slug); + if (!instance) { + return c.json({ error: "Instance not found" }, 404); + } + + if (instance.status === "destroyed") { + return c.json({ error: "Already destroyed" }, 400); + } + + store.updateStatus(instance.id, "teardown"); + store.addLog(instance.id, "admin_force_teardown"); + + // Run teardown in background + (async () => { + try { + removeTunnelHostnames(instance.primaryDomain, instance.fallbackDomain); + await restartCloudflared(); + await teardownSpace(instance.slug); + store.updateStatus(instance.id, "destroyed"); + store.addLog(instance.id, "teardown_complete"); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + store.addLog(instance.id, "teardown_error", msg); + store.updateStatus(instance.id, "failed"); + } + })(); + + return c.json({ message: "Force teardown started", instance }); + }); + + // Suspend an instance (stop containers but keep data) + app.post("/instances/:slug/suspend", async (c) => { + const slug = c.req.param("slug"); + const instance = store.getBySlug(slug); + if (!instance) { + return c.json({ error: "Instance not found" }, 404); + } + + if (instance.status !== "active") { + return c.json({ error: `Cannot suspend: status is ${instance.status}` }, 400); + } + + try { + const proc = Bun.spawn( + ["docker", "compose", "-f", instance.composePath!, "stop"], + { stdout: "pipe", stderr: "pipe" } + ); + await proc.exited; + + store.updateStatus(instance.id, "suspended" as never); + store.addLog(instance.id, "admin_suspend"); + + return c.json({ message: "Instance suspended", slug }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return c.json({ error: `Suspend failed: ${msg}` }, 500); + } + }); + + // Resume a suspended instance + app.post("/instances/:slug/resume", async (c) => { + const slug = c.req.param("slug"); + const instance = store.getBySlug(slug); + if (!instance) { + return c.json({ error: "Instance not found" }, 404); + } + + try { + const proc = Bun.spawn( + ["docker", "compose", "-f", instance.composePath!, "start"], + { stdout: "pipe", stderr: "pipe" } + ); + await proc.exited; + + const healthy = await checkContainerHealth(slug); + if (!healthy) { + store.addLog(instance.id, "resume_unhealthy"); + return c.json({ error: "Containers started but unhealthy" }, 500); + } + + store.updateStatus(instance.id, "active"); + store.addLog(instance.id, "admin_resume"); + + return c.json({ message: "Instance resumed", slug }); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + return c.json({ error: `Resume failed: ${msg}` }, 500); + } + }); + + return app; +} diff --git a/api/src/routes/usage.ts b/api/src/routes/usage.ts new file mode 100644 index 0000000..62cc651 --- /dev/null +++ b/api/src/routes/usage.ts @@ -0,0 +1,81 @@ +/** + * Usage & billing routes. + * + * GET /v1/spaces/:slug/usage — Container stats, post count, storage, billing estimate + * POST /v1/spaces/:slug/pay — Settle balance via x402 payment + */ + +import { Hono } from "hono"; +import type { InstanceStore } from "../db/queries.js"; +import { collectMetrics } from "../services/metrics.js"; +import { calculateBill } from "../services/billing.js"; +import { setupX402FromEnv } from "../middleware/x402.js"; + +export function usageRoutes(store: InstanceStore) { + const app = new Hono(); + + // Get usage + billing estimate for an instance + app.get("/:slug/usage", async (c) => { + const slug = c.req.param("slug"); + const instance = store.getBySlug(slug); + if (!instance) { + return c.json({ error: "Instance not found" }, 404); + } + + const owner = c.get("owner" as never) as string; + if (owner !== "admin" && instance.owner !== owner) { + return c.json({ error: "Not authorized" }, 403); + } + + const metrics = await collectMetrics(slug, instance.status); + const billing = calculateBill( + slug, + metrics.postCount ?? 0, + metrics.storageMB ?? 0, + instance.createdAt + ); + + return c.json({ usage: metrics, billing }); + }); + + // Settle balance — x402 payment endpoint + app.post("/:slug/pay", async (c) => { + const slug = c.req.param("slug"); + const instance = store.getBySlug(slug); + if (!instance) { + return c.json({ error: "Instance not found" }, 404); + } + + const owner = c.get("owner" as never) as string; + if (owner !== "admin" && instance.owner !== owner) { + return c.json({ error: "Not authorized" }, 403); + } + + // If x402 is enabled, the middleware will have already verified payment + const payment = c.get("x402Payment" as never) as string | undefined; + + if (payment) { + store.addLog( + instance.id, + "payment_received", + `x402 payment verified` + ); + } + + return c.json({ + status: "paid", + instance: { slug: instance.slug, status: instance.status }, + }); + }); + + return app; +} + +/** + * Wire x402 middleware to the payment endpoint (if configured). + */ +export function getX402PayMiddleware() { + return setupX402FromEnv({ + description: "Monthly payment for Postiz space hosting", + }); +} diff --git a/api/src/services/billing.ts b/api/src/services/billing.ts new file mode 100644 index 0000000..3b5357e --- /dev/null +++ b/api/src/services/billing.ts @@ -0,0 +1,64 @@ +/** + * Billing calculator for x402 metered usage. + * + * Pricing: + * Monthly base: $5.00 USDC (covers ~2.5GB RAM) + * Per post: $0.01 USDC + * Per GB storage: $0.50 USDC (above 1GB free tier) + */ + +export interface BillingPeriod { + slug: string; + periodStart: string; // ISO date + periodEnd: string; + baseCostUSD: number; + postCount: number; + postCostUSD: number; + storageMB: number; + storageCostUSD: number; + totalUSD: number; +} + +const BASE_MONTHLY_USD = parseFloat( + process.env.X402_BASE_MONTHLY_PRICE ?? "5.00" +); +const PER_POST_USD = parseFloat(process.env.X402_PER_POST_PRICE ?? "0.01"); +const PER_GB_USD = parseFloat(process.env.X402_PER_GB_PRICE ?? "0.50"); +const FREE_STORAGE_MB = 1024; // 1 GB free tier + +export function calculateBill( + slug: string, + postCount: number, + storageMB: number, + createdAt: string +): BillingPeriod { + const now = new Date(); + const created = new Date(createdAt); + + // Pro-rate base cost if instance is less than a month old + const ageMs = now.getTime() - created.getTime(); + const ageDays = ageMs / (1000 * 60 * 60 * 24); + const monthFraction = Math.min(ageDays / 30, 1); + const baseCostUSD = Math.round(BASE_MONTHLY_USD * monthFraction * 100) / 100; + + const postCostUSD = Math.round(postCount * PER_POST_USD * 100) / 100; + + const billableStorageMB = Math.max(0, storageMB - FREE_STORAGE_MB); + const billableStorageGB = billableStorageMB / 1024; + const storageCostUSD = Math.round(billableStorageGB * PER_GB_USD * 100) / 100; + + const totalUSD = + Math.round((baseCostUSD + postCostUSD + storageCostUSD) * 100) / 100; + + return { + slug, + periodStart: created.toISOString(), + periodEnd: now.toISOString(), + baseCostUSD, + postCount, + postCostUSD, + storageMB, + storageCostUSD, + totalUSD, + }; +} diff --git a/api/src/services/metrics.ts b/api/src/services/metrics.ts new file mode 100644 index 0000000..0d0f90f --- /dev/null +++ b/api/src/services/metrics.ts @@ -0,0 +1,196 @@ +/** + * Instance usage metrics — queries Docker for container stats and + * the instance's PostgreSQL for post counts. + */ + +export interface UsageMetrics { + slug: string; + status: string; + containerStats: ContainerStats | null; + postCount: number | null; + storageMB: number | null; + uptimeHours: number | null; +} + +interface ContainerStats { + state: string; + memoryUsageMB: number; + cpuPercent: number; +} + +/** + * Get container resource usage from Docker. + */ +export async function getContainerStats( + slug: string +): Promise { + try { + const proc = Bun.spawn( + [ + "docker", + "stats", + `postiz-${slug}`, + "--no-stream", + "--format", + "{{.MemUsage}}\t{{.CPUPerc}}\t{{.Name}}", + ], + { stdout: "pipe", stderr: "pipe" } + ); + const output = await new Response(proc.stdout).text(); + await proc.exited; + + if (proc.exitCode !== 0) return null; + + const line = output.trim(); + if (!line) return null; + + const parts = line.split("\t"); + const memStr = parts[0]?.split("/")[0]?.trim() ?? "0MiB"; + const cpuStr = parts[1]?.replace("%", "").trim() ?? "0"; + + // Parse memory (could be MiB or GiB) + let memoryUsageMB = 0; + if (memStr.includes("GiB")) { + memoryUsageMB = parseFloat(memStr) * 1024; + } else { + memoryUsageMB = parseFloat(memStr); + } + + return { + state: "running", + memoryUsageMB: Math.round(memoryUsageMB), + cpuPercent: parseFloat(cpuStr), + }; + } catch { + return null; + } +} + +/** + * Get post count from instance's PostgreSQL container. + */ +export async function getPostCount(slug: string): Promise { + try { + const proc = Bun.spawn( + [ + "docker", + "exec", + `postiz-${slug}-db`, + "psql", + "-U", + "postiz", + "-d", + "postiz-db", + "-t", + "-c", + "SELECT COUNT(*) FROM posts;", + ], + { stdout: "pipe", stderr: "pipe" } + ); + const output = await new Response(proc.stdout).text(); + await proc.exited; + + if (proc.exitCode !== 0) return null; + return parseInt(output.trim(), 10) || 0; + } catch { + return null; + } +} + +/** + * Get storage usage from Docker volumes. + */ +export async function getStorageMB(slug: string): Promise { + try { + const proc = Bun.spawn( + [ + "docker", + "system", + "df", + "-v", + "--format", + "{{.Name}}\t{{.Size}}", + ], + { stdout: "pipe", stderr: "pipe" } + ); + const output = await new Response(proc.stdout).text(); + await proc.exited; + + // Sum volumes matching the slug + let totalMB = 0; + for (const line of output.split("\n")) { + if (line.includes(slug)) { + const sizeStr = line.split("\t")[1] ?? "0B"; + if (sizeStr.includes("GB")) { + totalMB += parseFloat(sizeStr) * 1024; + } else if (sizeStr.includes("MB")) { + totalMB += parseFloat(sizeStr); + } else if (sizeStr.includes("kB")) { + totalMB += parseFloat(sizeStr) / 1024; + } + } + } + + return Math.round(totalMB); + } catch { + return null; + } +} + +/** + * Get container uptime in hours. + */ +export async function getUptimeHours(slug: string): Promise { + try { + const proc = Bun.spawn( + [ + "docker", + "inspect", + `postiz-${slug}`, + "--format", + "{{.State.StartedAt}}", + ], + { stdout: "pipe", stderr: "pipe" } + ); + const output = await new Response(proc.stdout).text(); + await proc.exited; + + if (proc.exitCode !== 0) return null; + + const startedAt = new Date(output.trim()); + const now = new Date(); + const hours = (now.getTime() - startedAt.getTime()) / (1000 * 60 * 60); + return Math.round(hours * 10) / 10; + } catch { + return null; + } +} + +/** + * Collect all usage metrics for an instance. + */ +export async function collectMetrics( + slug: string, + status: string +): Promise { + if (status !== "active") { + return { + slug, + status, + containerStats: null, + postCount: null, + storageMB: null, + uptimeHours: null, + }; + } + + const [containerStats, postCount, storageMB, uptimeHours] = + await Promise.all([ + getContainerStats(slug), + getPostCount(slug), + getStorageMB(slug), + getUptimeHours(slug), + ]); + + return { slug, status, containerStats, postCount, storageMB, uptimeHours }; +} diff --git a/api/src/types.ts b/api/src/types.ts index c048fba..7e325ea 100644 --- a/api/src/types.ts +++ b/api/src/types.ts @@ -27,6 +27,7 @@ export interface Instance { export type InstanceStatus = | "provisioning" | "active" + | "suspended" | "failed" | "teardown" | "destroyed"; diff --git a/src/components/InstanceCard.tsx b/src/components/InstanceCard.tsx index feb8cef..eeda844 100644 --- a/src/components/InstanceCard.tsx +++ b/src/components/InstanceCard.tsx @@ -44,6 +44,11 @@ const statusConfig: Record< color: "bg-destructive/10 text-destructive", icon: , }, + suspended: { + label: "Suspended", + color: "bg-blue-500/10 text-blue-600", + icon: , + }, destroying: { label: "Removing", color: "bg-orange-500/10 text-orange-600",